Cache: cache-aside, write-through e TTL

1. Visão Geral

Cache ajuda a reduzir a carga no banco de dados e diminuir o tempo de resposta. Em troca, você precisa aceitar que os dados podem estar desatualizados. Este artigo apresenta duas estratégias populares de leitura/escrita de cache: cache-aside e write-through. O artigo também explica como usar TTL (time-to-live) para limitar o tempo em que os dados podem estar desatualizados.

Após ler, você será capaz de implementar ambas as estratégias em Java puro, migrar para anotações do Spring Cache, configurar TTL para Caffeine e Redis, e evitar alguns erros comuns.

2. Conceitos Fundamentais

Antes de entrarmos no código, vamos esclarecer alguns termos:

  • Cache hit: dados disponíveis no cache, retornados imediatamente.
  • Cache miss: o cache não tem os dados, precisa ler da fonte original (banco de dados, outro serviço).
  • Stale data: dados no cache que diferem do banco de dados.
  • TTL: tempo de vida de uma entrada. Após expirar, a entrada é considerada inválida.

Toda estratégia de cache gira em torno de uma pergunta: quando os dados mudam, quem é responsável por atualizar o cache, e quando fazer isso?

Usaremos um domínio simples ao longo deste artigo:

public record Product(long id, String name, BigDecimal price) {}

public interface ProductRepository {
    Optional<Product> findById(long id);
    Product save(Product product);
}

3. Um Cache com TTL Escrito do Zero

Para entender bem o mecanismo, vamos escrever um pequeno cache com TTL. O tempo é obtido através de Supplier<Instant>, permitindo que testes avancem o relógio.

public class TtlCache<K, V> {

    private record Entry<V>(V value, Instant expiresAt) {}

    private final Map<K, Entry<V>> store = new ConcurrentHashMap<>();
    private final Duration ttl;
    private final Supplier<Instant> now;

    public TtlCache(Duration ttl, Supplier<Instant> now) {
        this.ttl = ttl;
        this.now = now;
    }

    public Optional<V> get(K key) {
        Entry<V> entry = store.get(key);
        if (entry == null) {
            return Optional.empty();
        }
        if (now.get().isAfter(entry.expiresAt())) {
            store.remove(key, entry);
            return Optional.empty();
        }
        return Optional.of(entry.value());
    }

    public void put(K key, V value) {
        store.put(key, new Entry<>(value, now.get().plus(ttl)));
    }

    public void evict(K key) {
        store.remove(key);
    }
}

Como esta classe funciona:

  • put salva o valor com o momento de expiração expiresAt = agora + ttl.
  • get verifica a expiração. Se a entrada está expirada, ela é removida e a função retorna Optional.empty(), ou seja, um cache miss.
  • store.remove(key, entry) remove apenas a entrada exatamente lida. Assim, se outra thread tiver acabado de fazer put com um novo valor, esse valor não será removido acidentalmente.

Esta classe é suficiente para fins de demonstração. Na produção, você deve usar Caffeine ou Redis (seção 6).

4. Cache-Aside

4.1. A Ideia

Com cache-aside (também chamado de lazy loading), a aplicação gerencia o cache:

  1. Leitura: procure no cache primeiro. Se miss, leia do banco de dados, salve o resultado no cache e retorne.
  2. Escrita: atualize o banco de dados primeiro, depois remova a entrada do cache. A próxima leitura recarregará os dados novos.
public class ProductService {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public ProductService(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Optional<Product> findById(long id) {
        Optional<Product> cached = cache.get(id);
        if (cached.isPresent()) {
            return cached;                       // cache hit
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> cache.put(id, p)); // carregar no cache
        return fromDb;
    }

    public Product updatePrice(long id, BigDecimal newPrice) {
        Product current = repository.findById(id).orElseThrow();
        Product saved = repository.save(new Product(id, current.name(), newPrice));
        cache.evict(id);                         // remover após o BD ter sido escrito
        return saved;
    }
}

Explicação:

  • findById chama repository apenas no cache miss. Se o produto não existe, não cacheamos nada.
  • updatePrice escreve no banco de dados primeiro, depois faz evict. Esta ordem é importante; a seção 8 explica o motivo.
  • Escolhemos remover a entrada em vez de sobrescrever com um novo valor. Isso é mais simples, pois o cache é carregado de apenas um lugar: o caminho de leitura.

4.2. Quando Usar

Cache-aside é adequado quando:

  • A taxa de leitura é muito maior que a de escrita, como em catálogos de produtos ou perfis de usuários.
  • Você quer cachear apenas dados realmente acessados.
  • O sistema precisa continuar funcionando se o cache cair. Nesse caso, todas as requisições vão direto ao banco de dados: mais lento, mas sem erro.

Desvantagem: a primeira requisição após cada miss é sempre lenta. Além disso, se outro processo escrever diretamente no banco de dados sem invalidar o cache, os dados ficarão desatualizados até expirar o TTL.

5. Write-Through

5.1. A Ideia

Com write-through, toda operação de escrita passa pela camada de cache. Esta camada escreve sincronamente no banco de dados e depois atualiza o cache com o novo valor. Quando o comando de escrita retorna, cache e banco de dados estão em sincronismo.

public class WriteThroughProductStore {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public WriteThroughProductStore(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Product save(Product product) {
        Product saved = repository.save(product); // 1. escrever BD (síncrono)
        cache.put(saved.id(), saved);             // 2. atualizar cache
        return saved;
    }

    public Optional<Product> findById(long id) {
        return cache.get(id).or(() -> {
            Optional<Product> fromDb = repository.findById(id);
            fromDb.ifPresent(p -> cache.put(id, p));
            return fromDb;
        });
    }
}

Diferenças em relação ao cache-aside:

  • save sobrescreve o cache com o valor recém salvo, em vez de deletá-lo. A próxima leitura é um cache hit.
  • Escrevemos no cache apenas quando repository.save tem sucesso. Se o banco de dados lança uma exceção, o cache não é tocado, então não há risco do cache conter dados nunca salvos.
  • findById ainda precisa de um fallback, pois a entrada pode ter expirado ou nunca ter sido escrita via save.

5.2. Quando Usar

Write-through é adequado para dados que são relidos logo após a escrita, como carrinhos de compras ou preferências de usuário. O preço a pagar:

  • Cada escrita é um pouco mais lenta porque precisa escrever em dois lugares.
  • O cache pode conter muitos dados que ninguém lê. Você deve combinar com TTL e limites de tamanho.

Nota: Não confunda write-through com write-behind (write-back). Write-behind escreve no cache primeiro e depois no banco de dados assincronamente. Escrita mais rápida, mas se o nó do cache morre antes de fazer flush, você perde dados. Use write-behind apenas se aceitar esse risco.

6. Usando Spring Cache

Escrever à mão ajuda a entender o mecanismo, mas em projetos reais você deve usar Spring Cache com Caffeine (cache local) ou Redis (cache distribuído). Adicione a dependência:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

Habilite cache com @EnableCaching:

@SpringBootApplication
@EnableCaching
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

6.1. Cache-Aside com @Cacheable e @CacheEvict

@Service
public class CatalogService {

    private final ProductRepository repository;

    public CatalogService(ProductRepository repository) {
        this.repository = repository;
    }

    @Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
    public Optional<Product> findById(long id) {
        return repository.findById(id);
    }

    @CacheEvict(cacheNames = "products", key = "#product.id")
    public Product update(Product product) {
        return repository.save(product);
    }
}
  • @Cacheable é cache-aside de forma declarativa. Spring verifica o cache primeiro; no miss, invoca o método e salva o resultado.
  • Com tipo de retorno Optional, Spring cacheia o valor interno. unless = "#result == null" evita cachear o caso não encontrado.
  • @CacheEvict executa após o método retornar com sucesso. Esta é exatamente a ordem “escrever BD e depois deletar cache”.

6.2. Write-Through com @CachePut

@CachePut(cacheNames = "products", key = "#result.id")
public Product save(Product product) {
    return repository.save(product);
}

@CachePut sempre invoca o método e depois escreve o valor retornado no cache. Isso é equivalente a WriteThroughProductStore.save da seção 5.

Nota: Cache por anotação funciona via proxy. Se você chamar this.findById(...) de outro método na mesma classe, o cache é completamente ignorado. Chame através de outro bean.

7. Configurando TTL

7.1. Caffeine

spring:
  cache:
    type: caffeine
    cache-names: products
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m
  • expireAfterWrite=10m: entrada expira 10 minutos após a última escrita.
  • maximumSize=10000: quando excedido, Caffeine remove entradas menos usadas. Sempre defina este limite para evitar o cache consumir toda a memória heap.

7.2. Redis

Adicione spring-boot-starter-data-redis, depois configure:

spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=shop::

Redis remove chaves automaticamente quando expiram. Este cache é compartilhado entre múltiplas instâncias da aplicação.

7.3. Qual TTL Escolher?

Não há um número único para todos os casos. Pergunte-se: quanto tempo os dados podem ficar desatualizados no máximo?

Taxa de câmbio, estoque        : alguns segundos – 1 minuto
Preço de produto, catálogo : 5 – 30 minutos
Configuração, lista de cidades/regiões : algumas horas – 1 dia

TTL curto garante dados mais frescos, mas taxa de hit menor. TTL longo faz o oposto. Mesmo com invalidação manual, você deve definir um TTL como rede de segurança para evicts que possam ser perdidos.

7.4. Adicionar Jitter para Evitar Expiração Simultânea

Suponha que você carrega 10.000 produtos de uma vez com TTL de 10 minutos. Todos expiram ao mesmo tempo, e o banco de dados recebe um pico de requisições simultâneas. Isso é chamado de cache stampede. A solução é adicionar um intervalo aleatório ao TTL:

public void put(K key, V value) {
    long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 60);
    Instant expiresAt = now.get().plus(ttl).plusSeconds(jitterSeconds);
    store.put(key, new Entry<>(value, expiresAt));
}

Com Spring + Caffeine, você pode adicionar @Cacheable(..., sync = true). Quando múltiplas threads fazem miss na mesma chave, apenas uma é invocada no banco de dados, as outras aguardam o resultado.

8. Escrevendo Testes

Usaremos um repositório simulado para contar acessos ao banco de dados e um relógio controlável:

class ProductServiceTest {

    static class CountingRepository implements ProductRepository {
        final Map<Long, Product> data = new HashMap<>();
        int findCalls = 0;

        public Optional<Product> findById(long id) {
            findCalls++;
            return Optional.ofNullable(data.get(id));
        }

        public Product save(Product p) {
            data.put(p.id(), p);
            return p;
        }
    }

    AtomicReference<Instant> clock = new AtomicReference<>(Instant.parse("2026-01-01T00:00:00Z"));
    CountingRepository repository = new CountingRepository();
    ProductService service = new ProductService(
            repository, new TtlCache<>(Duration.ofMinutes(10), clock::get));

    @BeforeEach
    void setUp() {
        repository.save(new Product(1L, "Nón lá", new BigDecimal("50000")));
    }

    @Test
    void secondReadIsServedFromCache() {
        service.findById(1L);
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(1);
    }

    @Test
    void entryExpiresAfterTtl() {
        service.findById(1L);
        clock.set(clock.get().plus(Duration.ofMinutes(11)));
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(2);
    }

    @Test
    void updateEvictsStaleValue() {
        service.findById(1L);
        service.updatePrice(1L, new BigDecimal("45000"));

        assertThat(service.findById(1L))
                .get()
                .extracting(Product::price)
                .isEqualTo(new BigDecimal("45000"));
    }
}
  • secondReadIsServedFromCache: a segunda leitura não acessa o banco de dados.
  • entryExpiresAfterTtl: após avançar o relógio além de 10 minutos, há cache miss e o banco de dados é chamado novamente.
  • updateEvictsStaleValue: após atualizar, o leitor vê o novo preço, não o valor antigo do cache.

9. Erros Comuns

Deletar cache antes de escrever no banco de dados. Considere este cenário: thread A deleta o cache. Thread B lê, tem miss, busca o valor antigo do banco de dados e o escreve no cache. Depois thread A escreve o novo valor no banco de dados. O resultado é que o cache mantém o valor antigo até expirar o TTL. Escreva no banco de dados primeiro, depois delete o cache.

Cache dentro de uma transação não confirmada. Se você faz @CachePut dentro de um método @Transactional e a transação sofrer rollback, o cache conterá dados que não existem no banco de dados. Coloque operações de cache fora da transação ou use TransactionAwareCacheManagerProxy.

Cache local em múltiplas instâncias. Caffeine fica na JVM de cada instância. @CacheEvict em instância A não deleta cache da instância B. Ao executar múltiplas instâncias que precisam de consistência, use Redis ou aceite dados desatualizados com TTL curto.

Cachear objetos mutáveis. Caffeine retorna a própria referência armazenada no cache. Se o código modificar o objeto, você modificou o cache. Use record ou objetos imutáveis como nos exemplos deste artigo.

Nota: Não cachear dados que mudam continuamente e requerem consistência absoluta, como saldo de conta ou assentos disponíveis no momento do pagamento. Nesses casos, ler diretamente do banco de dados com o lock apropriado é mais seguro.

10. Conclusão

Este artigo comparou duas estratégias principais de cache:

  • Cache-aside: a aplicação carrega o cache no miss e remove após escrita. Simples e tolerante a falhas, adequado para a maioria dos cenários de leitura frequente.
  • Write-through: toda escrita atualiza simultaneamente banco de dados e cache, fornecendo dados frescos logo após a escrita.

Qualquer que seja a estratégia escolhida, você ainda precisa de TTL como rede de segurança, deve limitar o tamanho do cache e deve adicionar jitter para evitar stampede. Comece com @Cacheable + Caffeine. Quando a aplicação escala para múltiplas instâncias, migre para Redis e sempre tenha testes provando que o cache realmente reduz acessos ao banco de dados.

system-designcachingspring-bootcaffeineredis