Evitando Cache Stampede no Spring Boot com Redis: Expiração Lógica

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:

  1. 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.
  2. Verificação de Validade: Ao acessar o cache, o aplicativo compara o timestamp lógico armazenado com o horário atual.
  3. 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).
  4. 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.
}

Tags: Redis Spring Boot Cache Aside Alta Concorrência Expiração Lógica

Publicado em 5-31 08:51 por Thomas