Cache com Redis: TTL, Eviction e Cache Stampede

1. Visão geral

Redis é uma escolha popular para cachear dados, reduzir carga no banco de dados e acelerar o tempo de resposta. Mas usar Redis não é tão simples quanto apenas set e get — você precisa entender como TTL (Time To Live) funciona, que mecanismo Redis usa para deletar chaves quando a memória acaba (eviction), e o mais importante: como evitar cache stampede — a situação onde centenas de requisições caem no banco de dados simultaneamente quando uma chave hot expira.

Este artigo vai desde a configuração básica de TTL, as políticas de eviction do Redis, até as técnicas práticas para evitar stampede: mutex lock, expiração lógica e coalescência de requisições. Ao terminar de ler, você saberá como projetar uma camada de cache que aguente alto volume de carga sem derrubar o banco de dados.

2. Configurando Redis com Spring Boot

Primeiro, adicione a dependência:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Configurar a conexão:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8

O bean RedisTemplate é usado para operar com pares chave/valor com tipos de dados personalizados:

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    return template;
}

GenericJackson2JsonRedisSerializer armazena objetos em JSON com informações de classe, ajudando a desserializar corretamente ao ler — mais conveniente que JdkSerializationRedisSerializer, que é difícil de ler com redis-cli.

3. TTL — Time To Live

3.1. Definir TTL ao escrever uma chave

@Service
public class ProductCacheService {

    private final RedisTemplate<String, Object> redisTemplate;

    public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheProduct(Long productId, Product product) {
        String key = "product:" + productId;
        redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    }
}

A linha set(key, product, Duration.ofMinutes(10)) escreve a chave com TTL de 10 minutos. Após esse período, Redis deleta a chave automaticamente — chamado de expiração passiva combinado com expiração ativa (Redis varre aleatoriamente chaves com TTL a cada 100ms para limpeza antecipada, evitando deixar chaves expiradas ocupando RAM à espera de serem acessadas).

Verificar o TTL restante:

redis-cli TTL product:123
(integer) 542

O valor -1 significa que a chave existe mas não tem TTL (persiste indefinidamente). O valor -2 significa que a chave não existe.

3.2. TTL Fixo vs TTL Aleatório (jitter)

Se você cachear milhares de chaves simultaneamente com o mesmo TTL, elas expirarão todas ao mesmo tempo — causando uma onda de “cache miss” em cascata. A solução é adicionar jitter: adicione aleatoriamente um pequeno intervalo ao TTL.

public void cacheProductWithJitter(Long productId, Product product) {
    long baseSeconds = 600;
    long jitter = ThreadLocalRandom.current().nextLong(0, 60);
    redisTemplate.opsForValue()
        .set("product:" + productId, product, Duration.ofSeconds(baseSeconds + jitter));
}

Cada chave expirará em um tempo ligeiramente diferente (600–660 segundos), evitando o efeito de “rebanho trovejante” quando muitas chaves expiram no mesmo momento.

Observação: Jitter não resolve cache stampede para uma única chave hot, por exemplo, página inicial, produtos mais vendidos. Esse problema requer a técnica da seção 5.

4. Política de Eviction quando Redis fica sem memória

TTL controla o expira automático das chaves ao longo do tempo. Mas quando Redis atinge o limite maxmemory, ele precisa remover chaves de acordo com a política de eviction, independentemente de a chave ter TTL ou não.

Configurar em redis.conf:

maxmemory 512mb
maxmemory-policy allkeys-lru

As políticas comuns:

Política Significado
noeviction Não remove nada, retorna erro OOM ao escrever. Padrão do Redis.
allkeys-lru Remove a chave menos usada recentemente (Least Recently Used) em todas as chaves.
volatile-lru Igual ao anterior, mas remove apenas chaves com TTL.
allkeys-lfu Remove chaves com frequência de acesso mais baixa (Least Frequently Used).
volatile-ttl Prioriza remover chaves prestes a expirar (TTL mais curto).
volatile-random Remove aleatoriamente entre chaves com TTL.

Para um caso de uso de cache puro, allkeys-lru ou allkeys-lfu são escolhas seguras porque Redis liberará memória automaticamente em vez de retornar erros. Se você usar Redis tanto como cache quanto para armazenar dados importantes que não podem ser perdidos (como tokens de sessão), separe a instância ou use volatile-lru para remover apenas chaves marcadas com TTL.

Observação: noeviction (padrão) fará com que a aplicação receba o erro OOM command not allowed quando Redis fica sem memória. Se usar Redis como cache, sempre configure maxmemory-policy apropriadamente, não deixe no padrão.

Verificar a configuração atual:

redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"

5. O que é Cache Stampede

Cache stampede (também chamado de dogpile effect) ocorre quando uma chave hot — lida por centenas/milhares de requisições a cada segundo — expira de repente. Todas as requisições faltam cache quase simultaneamente, todas caindo no banco de dados para reconstruir os dados, fazendo o banco de dados ficar sobrecarregado por alguns centenas de milissegundos.

public Product getProduct(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }
    // A chave acabou de expirar -> centenas de threads entram aqui
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    return product;
}

Para uma chave de produto mais vendido, o código acima é totalmente razoável em situações normais, mas exatamente quando o TTL expira, productRepository.findById pode ser chamado centenas de vezes simultaneamente para o mesmo id.

5.1. Solução: Lock Distribuído (mutex)

Permite que apenas uma requisição reconstrua o cache, outras requisições aguardam ou usam o valor antigo.

public Product getProductSafely(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }

    String lockKey = "lock:product:" + id;
    Boolean acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

    if (Boolean.TRUE.equals(acquired)) {
        try {
            Product product = productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
            redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
            return product;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // Não conseguiu a lock -> aguarde um pouco e tente ler o cache novamente
        return waitAndRetry(key, id);
    }
}

private Product waitAndRetry(String key, Long id) {
    for (int i = 0; i < 10; i++) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        Product cached = (Product) redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }
    }
    // Hết thời gian chờ, fallback gọi thẳng DB
    return productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
}

setIfAbsent é equivalente ao comando SET lockKey 1 NX EX 5 no Redis — atômico, apenas uma thread consegue criar a lock com sucesso. Thread que não conseguir a lock fará polling no cache a cada 100ms em vez de chamar o banco de dados diretamente.

5.2. Solução: Expiração Lógica (sem TTL real)

Em vez de deixar Redis deletar a chave automaticamente, você armazena o tempo de expiração lógica no valor, e nunca deixa a chave desaparecer de verdade. Quando detectar dados “logicamente expirados”, uma thread reconstruirá em background, outras threads ainda retornam dados antigos imediatamente (stale-while-revalidate).

public record CachedValue<T>(T data, Instant logicalExpireAt) {}

public Product getProductWithLogicalTtl(Long id) {
    String key = "product:" + id;
    CachedValue<Product> cached =
        (CachedValue<Product>) redisTemplate.opsForValue().get(key);

    if (cached == null) {
        return rebuildAndCache(id, key);
    }

    if (Instant.now().isAfter(cached.logicalExpireAt())) {
        String lockKey = "lock:product:" + id;
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
        if (Boolean.TRUE.equals(acquired)) {
            // Reconstrói de forma assíncrona, retorna dados antigos imediatamente para requisição atual
            CompletableFuture.runAsync(() -> {
                try {
                    rebuildAndCache(id, key);
                } finally {
                    redisTemplate.delete(lockKey);
                }
            });
        }
    }
    return cached.data(); // luôn trả data, dù có thể hơi cũ
}

private Product rebuildAndCache(Long id, String key) {
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    CachedValue<Product> value = new CachedValue<>(product, Instant.now().plusSeconds(600));
    // TTL real mais longo que TTL lógico para evitar que a chave desapareça durante rebuild
    redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(15));
    return product;
}

Ponto crucial: o TTL real no Redis (15 minutos) é sempre maior que o TTL lógico (10 minutos). A chave nunca é deletada subitamente pelo Redis — ela apenas é marcada como “antiga” e reconstruída em background. Requisições nunca precisam esperar pelo banco de dados, nem mesmo logo após os dados expirarem logicamente.

6. Testando o comportamento anti-stampede

Escreva um teste confirmando que há apenas uma chamada ao banco de dados quando múltiplas threads têm cache miss simultaneamente:

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
class ProductCacheServiceTest {

    @Autowired
    private ProductCacheService cacheService;

    @MockBean
    private ProductRepository productRepository;

    @Test
    void apenasUmaThreadPodenChamarDatabaseQuandoCacheMiss() throws InterruptedException {
        Long productId = 42L;
        Product product = new Product(productId, "Ban phim co");
        given(productRepository.findById(productId)).willReturn(Optional.of(product));

        int threadCount = 50;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                cacheService.getProductSafely(productId);
                latch.countDown();
            });
        }
        latch.await(5, TimeUnit.SECONDS);
        executor.shutdown();

        verify(productRepository, times(1)).findById(productId);
    }
}
@Test
void semLockODatabaseEhChamadoVariasVezes() {
    Long productId = 99L;
    Product product = new Product(productId, "Ban phim co");
    given(productRepository.findById(productId)).willReturn(Optional.of(product));

    List<Product> results = IntStream.range(0, 50)
        .parallel()
        .mapToObj(i -> cacheService.getProductUnsafe(productId))
        .toList();

    assertThat(results).hasSize(50);
    verify(productRepository, atLeast(2)).findById(productId); // Prova de que há stampede
}

O primeiro teste usa getProductSafely (com lock), esperando que findById seja chamado exatamente 1 vez mesmo com 50 threads executando. O segundo teste usa a versão sem lock para ilustrar o oposto — você verá findById sendo chamado múltiplas vezes, comprovando que stampede ocorre sem tratamento.

7. Alguns erros comuns

  • Esquecer de definir TTL: a chave persiste indefinidamente, Redis gradualmente enche de RAM, forçando a depender completamente da política de eviction — fácil deletar a chave errada.
  • TTL muito curto para chave hot: aumenta a frequência de cache miss, mais propenso a gerar stampede. Para chaves com alto tráfego, prefira expiração lógica em vez de TTL real curto.
  • Lock sem TTL: se a instância que segura a lock cair no meio, a lock persiste indefinidamente, outras threads ficam esperando para sempre. Sempre defina TTL para a lock (setIfAbsent com Duration).
  • Usar noeviction para Redis cache: a aplicação lançará erros quando Redis ficar cheio em vez de fazer limpeza automática. Só faz sentido se Redis for usado para armazenar dados que não podem ser perdidos.
  • Serializar objetos inconsistentemente: alterar fields na classe Product sem lidar com versionamento pode causar erros ao desserializar dados antigos em cache. Deve-se adicionar um mecanismo de cache-busting (alterar prefixo de chave) ao mudar o schema.

8. Conclusão

TTL ajuda dados em cache se renovarem ao longo do tempo, mas precisa adicionar jitter para evitar muitas chaves expirarem simultaneamente. A política de eviction determina como Redis lida quando fica sem memória — escolha allkeys-lru/allkeys-lfu para cache puro, evite noeviction padrão. Para chaves hot, apenas TTL não é suficiente: você precisa de mutex lock ou expiração lógica para garantir que apenas uma thread reconstrua o cache, enquanto o resto lê dados antigos em vez de sobrecarregar o banco. Aplicar essas três técnicas corretamente fará sua camada de cache suportar carga muito melhor do que um simples set/get.

rediscachespring-bootperformancejava