Entendendo o Cenário do Cache Stampede
Em sistemas de alta concorrência, quando um item de cache popular expira simultaneamente, múltiplas requisições podem atingir o banco de dados ao mesmo tempo, causando um pico de carga, conhecido como cache stampede. A técnica de expiração lógica oferece uma solução robusta para este problema, permitindo que o cache retorne dados "expirados" imediatamnete enquanto atualiza o valor em segundo plano.
Princípios da Solução com Expiração Lógica
Esta abordagem segue um fluxo distinto da expiração física do Redis:
- Pré-aquecimento dos Dados: Os dados são carregados no cache com um metadado de validade lógica, ignorando a configuração de TTL do Redis.
- Verificação de Validade: Ao acessar o cache, o aplicativo compara o timestamp lógico armazenado com o horário atual.
- Atualização em Segundo Plano: Se os dados estiverem logicamente expirados, o sistema retorna imediatamente o valor antigo para o cliente e dispara de forma assíncrona um porcesso para recarregar os dados da fonte (ex: banco de dados).
- Controle de Concorrência: Apenas uma thread é autorizada a executar a recarga em segundo plano por vez, utilizando um mecanismo de bloqueio (lock) distribuído. As demais threads continuam servindo os dados desatualizados.
Implementação Prática com Spring Boot e Redis
A implementação envolve a criação de uma classe auxiliar para encapsular os dados e sua validade, seguida por uma lógica de serviço que gerencia o fluxo de lietura e atualização.
Estrutura de Dados para o Cache
Primeiro, definimos um contêiner genérico que armazena o objeto real e sua data de validade lógica.
@Data
public class CacheEntry<T> {
private LocalDateTime expiresAt;
private T payload;
}
Serviço de Negócio com Lógica de Cache Inteligente
No serviço que gerencia os dados (por exemplo, uma loja), implementamos os métodos para popular o cache e consultá-lo com a lógica de expiração.
@Service
public class ShopService {
private final StringRedisTemplate redisTemplate;
private static final String SHOP_CACHE_PREFIX = "cache:shop:";
private static final String LOCK_PREFIX = "lock:shop:";
// Pool de threads dedicado para atualizações assíncronas do cache
private final ExecutorService cacheRefreshExecutor = Executors.newCachedThreadPool();
// Injeção de dependência via construtor (omitido por brevidade)
/**
* Pré-aquece o cache de uma loja com uma validade lógica específica.
*/
public void preloadShopToCache(Long shopId, long logicalExpirationSeconds) {
Shop shop = fetchShopFromDatabase(shopId); // Método para buscar no DB
CacheEntry<Shop> entry = new CacheEntry<>();
entry.setPayload(shop);
entry.setExpiresAt(LocalDateTime.now().plusSeconds(logicalExpirationSeconds));
String cacheKey = SHOP_CACHE_PREFIX + shopId;
String serializedEntry = JSONUtil.toJsonStr(entry);
redisTemplate.opsForValue().set(cacheKey, serializedEntry);
}
/**
* Busca uma loja usando a estratégia de expiração lógica.
*/
public Shop getShopWithLogicalExpiration(Long shopId) {
String cacheKey = SHOP_CACHE_PREFIX + shopId;
String rawEntry = redisTemplate.opsForValue().get(cacheKey);
// 1. Cache miss: retorna null ou busca no DB (conforme regra de negócio)
if (StrUtil.isBlank(rawEntry)) {
return null;
}
// 2. Desserializa o entry do cache
CacheEntry<Shop> cacheEntry = JSONUtil.toBean(rawEntry, new TypeReference<CacheEntry<Shop>>() {});
Shop cachedShop = cacheEntry.getPayload();
// 3. Verifica se a validade lógica ainda é válida
if (LocalDateTime.now().isBefore(cacheEntry.getExpiresAt())) {
return cachedShop; // Dados válidos, retorna imediatamente
}
// 4. Dados logicamente expirados: tenta obter um lock para atualização
String lockKey = LOCK_PREFIX + shopId;
boolean lockAcquired = tryAcquireLock(lockKey);
if (lockAcquired) {
// 4.1. Lock adquirido: dispara atualização assíncrona
cacheRefreshExecutor.submit(() -> {
try {
// Atualiza o cache com novos dados e nova validade lógica
preloadShopToCache(shopId, 30L); // Exemplo: nova validade de 30 segundos
} catch (Exception e) {
// Tratar exceção de atualização
} finally {
releaseLock(lockKey);
}
});
}
// 5. Retorna os dados antigos imediatamente, enquanto a atualização ocorre em background
return cachedShop;
}
// Métodos auxiliares para lock (ex.: usando Redis com SETNX) omitidos.
// Métodos fetchShopFromDatabase (via @Repository) omitidos.
}