Manter cache e database consistentes ao usar Redis

1. Visão geral

Colocar Redis na frente do banco de dados reduz carga e acelera leituras. Em troca, você tem dois lugares armazenando o mesmo dado, e podem ficar fora de sincronia. Um usuário muda o preço de um produto, mas a página de detalhe ainda mostra o preço antigo por 10 minutos. Esse é o erro de consistência típico.

Este artigo usa o padrão cache-aside: ao ler, busca do cache primeiro; ao escrever, atualiza o banco, depois deleta a chave do cache após o commit da transação. Vamos ver as race conditions remanescentes e como estreitar suas janelas. No final há um teste de integração com um Redis real usando Testcontainers.

2. Por que cache e database ficam fora de sincronia

Database e Redis não compartilham uma transação. Não há forma de escrever em ambos atomicamente. A ordem das operações e o tempo das operações determinam se os dados ficam dessincronizados.

Três erros mais comuns:

  • Atualizar cache em vez de deletar. Duas requisições escrevendo simultaneamente podem escrever no banco na ordem A→B, mas no cache na ordem B→A. O cache fica com o valor de A para sempre, ou até a TTL expirar.
  • Deletar cache antes do commit. Uma requisição de leitura se encaixa entre a deleção do cache e o commit. Aquela requisição lê o valor antigo do banco e o recarrega no cache.
  • Falha ao deletar cache e ninguém fica sabendo. Redis timeout, exception silenciada, e o cache fica com o dado antigo.

O resto do artigo trata cada um desses erros sequencialmente.

3. Configurando o projeto

Adicione as dependências para Redis, JPA e um banco para testar:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Configure a conexão com Redis:

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=500ms

O timeout curto é proposital. Se Redis ficar lento, você quer que a requisição caia para o banco rapidamente, não ficar presa por alguns segundos.

Entity e repositório:

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;

    protected Product() {}
    public Product(String name, BigDecimal price) { this.name = name; this.price = price; }

    public Long getId() { return id; }
    public String getName() { return name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
}

public interface ProductRepository extends JpaRepository<Product, Long> {}

Não cachear a entity diretamente, mas cachear um record imutável:

public record ProductView(Long id, String name, BigDecimal price) {
    static ProductView from(Product p) {
        return new ProductView(p.getId(), p.getName(), p.getPrice());
    }
}

Assim evita o erro de serialização do proxy Hibernate. Também mantém o formato no cache independente da estrutura da tabela.

4. Leitura com cache-aside

@Service
public class ProductService {

    private static final Duration TTL = Duration.ofMinutes(10);

    private final ProductRepository repository;
    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;
    private final ApplicationEventPublisher events;

    public ProductService(ProductRepository repository, StringRedisTemplate redis,
                          ObjectMapper mapper, ApplicationEventPublisher events) {
        this.repository = repository;
        this.redis = redis;
        this.mapper = mapper;
        this.events = events;
    }

    static String key(Long id) { return "product:" + id; }

    public ProductView findById(Long id) {
        String cached = redis.opsForValue().get(key(id));
        if (cached != null) {
            return read(cached);
        }
        ProductView view = repository.findById(id)
                .map(ProductView::from)
                .orElseThrow(() -> new NoSuchElementException("Product " + id));
        redis.opsForValue().set(key(id), write(view), TTL);
        return view;
    }

    private ProductView read(String json) {
        try { return mapper.readValue(json, ProductView.class); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }

    private String write(ProductView view) {
        try { return mapper.writeValueAsString(view); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }
}

O fluxo de processamento é:

  • redis.opsForValue().get(...) tenta buscar do cache. Se tiver, retorna na hora. Esse é o cache hit.
  • Se não tiver (cache miss), o serviço lê do banco e escreve no Redis com TTL de 10 minutos.
  • TTL é uma rede de segurança. Todos os mecanismos nas seções seguintes podem falhar. O TTL garante que dados incorretos não permaneçam por mais de 10 minutos.

Na primeira chamada com id = 1, Redis conterá:

127.0.0.1:6379> GET product:1
"{\"id\":1,\"name\":\"Ban phim co\",\"price\":1200000}"
127.0.0.1:6379> TTL product:1
(integer) 598

5. Escrita: atualizar banco, depois deletar cache após commit

5.1. Deletar em vez de atualizar

Quando o preço muda, deleta a chave em vez de escrever o novo preço no cache. A próxima leitura buscará os dados do banco, e o banco é a fonte da verdade. Deletar é uma operação idempotente: deletar duas vezes tem o mesmo resultado. A ordem entre comandos de deleção também não importa. Assim o erro “sobrescrita fora de ordem” da seção 2 desaparece.

5.2. Aguardar o commit da transação

Se você chamar redis.delete(...) dentro de um método @Transactional, o comando delete executa antes do commit. Aqui está o cenário possível:

T1 (escrita)                     T2 (leitura)
UPDATE price = 900 (sem commit)
DEL product:1
                                 GET product:1 -> miss
                                 SELECT -> price = 1200 (valor antigo)
                                 SET product:1 = 1200
COMMIT
=> cache fica com 1200 até expirar a TTL

A solução é publicar um evento e processá-lo na fase AFTER_COMMIT:

public record ProductChangedEvent(Long productId) {}

Adicione um método de escrita ao ProductService:

@Transactional
public void updatePrice(Long id, BigDecimal newPrice) {
    Product product = repository.findById(id).orElseThrow();
    product.setPrice(newPrice);
    events.publishEvent(new ProductChangedEvent(id));
}

Um listener fica responsável por deletar o cache:

@Component
public class ProductCacheEvictor {

    private static final Logger log = LoggerFactory.getLogger(ProductCacheEvictor.class);
    private final StringRedisTemplate redis;

    public ProductCacheEvictor(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onProductChanged(ProductChangedEvent event) {
        evict(ProductService.key(event.productId()));
    }

    void evict(String key) {
        try {
            redis.delete(key);
        } catch (RuntimeException e) {
            log.warn("Falha ao deletar cache {}, será feito rely na TTL", key, e);
        }
    }
}

Alguns pontos importantes:

  • @TransactionalEventListener(phase = AFTER_COMMIT) só executa quando a transação faz commit com sucesso. Se a transação fizer rollback, o cache não é deletado. Isso está correto, pois o dado não mudou.
  • Erro ao deletar o cache não destrói a requisição de escrita. O banco já fez commit, lançar exception aqui só leva o cliente a pensar que a operação falhou.
  • O log de aviso ajuda você a monitorar a frequência de erros. Enquanto isso, a TTL limita quanto tempo dados antigos podem existir.

Nota: Se publishEvent for chamado em um lugar sem transação, @TransactionalEventListener por padrão ignorará o evento e o cache nunca será deletado. Certifique-se de que o método de escrita tem @Transactional. Se necessário, configure fallbackExecution = true.

6. Race conditions remanescentes e delayed double delete

Mesmo deletando após commit, ainda há um cenário raro:

T2 (leitura)                      T1 (escrita)
GET product:1 -> miss
SELECT -> price = 1200
                                  UPDATE price = 900, COMMIT
                                  DEL product:1
SET product:1 = 1200   <- escrita tardia

T2 lê o valor antigo antes de T1 fazer commit, mas escreve no cache depois que T1 já deletou. Este cenário exige que T2 fique lenta no momento certo, por exemplo por pausa de GC ou rede instável. É raro mas possível.

Uma forma simples de mitigar é delayed double delete: deletar novamente após um curto intervalo.

@Component
public class DelayedCacheEvictor {

    private final ProductCacheEvictor evictor;
    private final TaskScheduler scheduler;

    public DelayedCacheEvictor(ProductCacheEvictor evictor, TaskScheduler scheduler) {
        this.evictor = evictor;
        this.scheduler = scheduler;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void scheduleSecondDelete(ProductChangedEvent event) {
        String key = ProductService.key(event.productId());
        scheduler.schedule(() -> evictor.evict(key),
                Instant.now().plusMillis(500));
    }
}

Spring Boot configura automaticamente um TaskScheduler quando você habilita @EnableScheduling em uma classe de configuração. A segunda deleção após 500ms varrará o valor antigo que T2 escreveu tardiamente. Escolha o atraso maior que o tempo típico para uma leitura do seu banco de dados.

Nota: Delayed double delete apenas estreita a janela de erro, não elimina completamente. O agendador fica em memória e é perdido se a aplicação reiniciar. Não o considere como uma garantia absoluta.

7. Quando deletar cache deve ser absolutamente bem-sucedido

Com dados como saldo de conta ou estoque, “depender da TTL” pode ser inaceitável. Então há duas abordagens comuns:

  • Transactional outbox: escrever uma linha de cache_invalidation no próprio banco de dados, na mesma transação da mudança do negócio. Um job periódico lê esta tabela, deleta a chave do Redis e marca a linha como concluída apenas quando a deleção funciona. Como a linha de outbox faz commit junto com os dados, o evento de invalidação nunca se perde.
  • Change Data Capture (CDC): um processo lê o binlog/WAL do banco de dados e deleta o cache correspondente. Esta abordagem separa a lógica de cache da lógica de negócio, em troca de infraestrutura mais complexa.

Ambas são eventual consistency. Garantem que o cache eventualmente estará correto, mas não que esteja correto imediatamente.

8. Testando com Redis real

Mockar StringRedisTemplate não detecta erros sobre o tempo de commit. Teste com um Redis real via Testcontainers:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class ProductServiceIT {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redisContainer =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired ProductService productService;
    @Autowired ProductRepository repository;
    @Autowired StringRedisTemplate redis;

    @Test
    void updatePrice_evictsCache_andNextReadReturnsNewPrice() {
        Long id = repository.save(new Product("Ban phim co", new BigDecimal("1200000"))).getId();

        productService.findById(id); // carrega cache
        assertThat(redis.hasKey(ProductService.key(id))).isTrue();

        productService.updatePrice(id, new BigDecimal("900000"));

        assertThat(redis.hasKey(ProductService.key(id))).isFalse();
        assertThat(productService.findById(id).price())
                .isEqualByComparingTo("900000");
    }
}

Este teste verifica três coisas:

  • @ServiceConnection(name = "redis") aponta automaticamente spring.data.redis.* para o container, então você não precisa de configuração extra.
  • Após updatePrice, a chave não está mais no Redis. Isso prova que o listener AFTER_COMMIT executou.
  • A próxima leitura retorna o novo preço do banco de dados.

Nota: Não marque este método de teste como @Transactional. Spring fará rollback da transação do teste no final, então AFTER_COMMIT nunca será disparado e a assertion falhará.

Resultado ao executar:

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

9. Quando não usar esta abordagem

  • Dados que exigem consistência forte, como saldo de conta usado para decidir se permite uma transação. Não leia deles do cache. Leia diretamente do banco com locks apropriados.
  • Dados com mais escritas que leituras. O cache é constantemente invalidado então hit rate é baixo. Você paga o custo de complexidade e ganha quase nada.
  • Chaves compostas por múltiplas entidades, como “top 10 produtos”. Difícil saber qual chave deletar quando uma entidade muda. Com este tipo de chave, TTL curta geralmente é mais simples e seguro que invalidação precisa.
  • Múltiplos serviços escrevendo na mesma tabela. Eventos na sua aplicação não veem mudanças de outros serviços. Aqui CDC faz mais sentido.

10. Conclusão

Manter Redis e banco de dados consistentes não significa mantê-los sempre perfeitamente sincronizados. O objetivo é fazer a janela de dessincronização pequena e limitada. A fórmula básica tem quatro passos:

  1. Ler com cache-aside, sempre configure TTL.
  2. Escrever no banco, depois deletar a chave, não atualizar a chave.
  3. Deletar apenas após commit, usando @TransactionalEventListener(phase = AFTER_COMMIT).
  4. Adicionar delayed double delete, outbox ou CDC dependendo de quão sensível são seus dados.

Por fim, teste com um Redis real e não deixe @Transactional no seu teste esconder o comportamento após commit.

rediscachespring-bootconsistencyjava