Redis: escolher cache-aside, write-through ou write-behind
1. Visão geral
Adicionar Redis na frente do banco de dados é o jeito mais rápido de reduzir latência e carga no sistema. O difícil é outra coisa: você precisa decidir como gravar dados para que cache e banco de dados não fiquem dessincronizados. Existem três estratégias populares: cache-aside, write-through e write-behind.
Este artigo implementa as três com Spring Boot 3 e Redis. Em seguida, compara-as por consistência, desempenho e risco de perda de dados. Depois de ler, você saberá qual estratégia escolher para cada tipo de dado.
2. Preparação
2.1. Dependências
Usamos Spring Data Redis, Spring Data JPA com H2 como banco de dados de exemplo, e Testcontainers para testes:
<dependencies>
<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>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<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>
</dependencies>
Configuração de conexão com Redis quando rodar local:
spring.data.redis.host=localhost
spring.data.redis.port=6379
2.2. Entity, repositório e RedisTemplate
@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;
}
// getters e setters
}
public interface ProductRepository extends JpaRepository<Product, Long> {
}
Spring Boot já cria StringRedisTemplate automaticamente. Para salvar Product como JSON, declaramos um RedisTemplate adicional:
@Configuration
@EnableScheduling
public class RedisConfig {
@Bean
RedisTemplate<String, Product> productRedisTemplate(RedisConnectionFactory factory) {
var template = new RedisTemplate<String, Product>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Product.class));
return template;
}
}
Chave tem formato product:{id}, valor é JSON do Product. Anotação @EnableScheduling é usada na seção write-behind.
As três estratégias precisam executar um pedaço de código após o commit da transaction. Agrupamos isso em um helper:
public final class AfterCommit {
public static void run(Runnable action) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
action.run();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
action.run();
}
});
}
}
Se estiver dentro de uma transaction, action é executada apenas quando o commit for bem-sucedido. Se a transaction sofrer rollback, o cache não é tocado.
3. Cache-aside (lazy loading)
Em cache-aside, a aplicação gerencia o cache por conta própria:
- Leitura: procura em Redis primeiro. Se não encontrar (miss), lê do banco de dados e grava o resultado em Redis com TTL.
- Escrita: atualiza o banco de dados, depois deleta a chave em Redis.
@Service
public class ProductCacheAsideService {
private static final Duration TTL = Duration.ofMinutes(10);
private final ProductRepository repository;
private final RedisTemplate<String, Product> redis;
public ProductCacheAsideService(ProductRepository repository,
RedisTemplate<String, Product> redis) {
this.repository = repository;
this.redis = redis;
}
public Optional<Product> findById(Long id) {
Product cached = redis.opsForValue().get(key(id));
if (cached != null) {
return Optional.of(cached);
}
Optional<Product> fromDb = repository.findById(id);
fromDb.ifPresent(p -> redis.opsForValue().set(key(id), p, TTL));
return fromDb;
}
@Transactional
public Product updatePrice(Long id, BigDecimal newPrice) {
Product product = repository.findById(id).orElseThrow();
product.setPrice(newPrice);
AfterCommit.run(() -> redis.delete(key(id)));
return product;
}
static String key(Long id) {
return "product:" + id;
}
}
Comportamento do código acima:
- Primeira chamada a
findById(1L)lê do banco de dados e cria a chaveproduct:1com TTL de 10 minutos. - Chamadas seguintes dentro desses 10 minutos retornam do Redis, sem tocar o banco.
updatePricemodifica a entity, dirty checking do JPA grava no banco quando faz commit, depois a chave é deletada. Próxima leitura carregará o novo valor.
Verificar com redis-cli:
redis-cli GET product:1
redis-cli TTL product:1
"{\"id\":1,\"name\":\"Teclado mecânico\",\"price\":1200000.00}"
(integer) 587
Atenção: Quando gravar, delete a chave em vez de fazer
setcom o novo valor. Suponha dois requests atualizando simultaneamente. Se ambos fizeremset, a ordem de gravação em Redis pode ser o oposto da ordem de commit no banco, e o cache fica com o valor antigo indefinidamente. Deletar a chave garante que próxima leitura sempre pega a versão mais nova. Além disso, delete após o commit. Se deletar antes, uma leitura que chegar entre a deleção e o novosetpode carregar o dado antigo para o cache novamente.
Mesmo fazendo certo, cache-aside ainda tem uma pequena janela de corrida: uma leitura pega o valor antigo do banco e o grava em cache depois que a chave foi deletada. TTL é a rede de segurança final para esse caso. Nunca faça cache sem definir TTL.
Para dados simples, você pode usar @Cacheable e @CacheEvict do Spring Cache para obter o mesmo resultado com menos código. Sua essência permanece cache-aside.
4. Write-through
Em write-through, cada escrita atualiza banco de dados e cache de forma síncrona. O request só retorna quando ambos terminarem. A leitura continua igual ao cache-aside, mas a taxa de hit é mais alta porque o dado que foi escrito já está em Redis.
@Service
public class ProductWriteThroughService {
private static final Duration TTL = Duration.ofMinutes(30);
private final ProductRepository repository;
private final RedisTemplate<String, Product> redis;
public ProductWriteThroughService(ProductRepository repository,
RedisTemplate<String, Product> redis) {
this.repository = repository;
this.redis = redis;
}
@Transactional
public Product save(Product product) {
Product saved = repository.saveAndFlush(product);
AfterCommit.run(() -> redis.opsForValue()
.set(ProductCacheAsideService.key(saved.getId()), saved, TTL));
return saved;
}
}
saveAndFlush grava no banco imediatamente para obter o id e detectar erros de restrição cedo. Após o commit, o registro é colocado em Redis. Quando um produto recém-criado é lido novamente, o resultado é um cache hit na primeira leitura.
Vantagens do write-through:
- Cache fica “quente” com dados que acabaram de ser gravados, sem miss inicial.
- Adequado para dados frequentemente lidos logo após serem escritos, como perfil
Customerou carrinho de compras.
Desvantagens:
- Cada escrita custa mais uma viagem de ida e volta (round-trip) até Redis, aumentando a latência.
- Cache fica cheio com registros que ninguém lê. TTL ajuda a limpar, mas ainda consome memória.
- Continua tendo corrida quando múltiplos writers fazem
set, conforme descrito na seção 3. Para dados atualizados simultaneamente, você pode adicionar versão, por exemplo usando@Versione só gravar quando a versão for mais nova, ou voltar a deletar a chave.
Atenção: Se Redis falhar após o banco ter feito commit, o cache perderá o novo registro. Capture a exceção e delete a chave como fallback. Não deixe o request falhar quando o banco já foi gravado com sucesso.
5. Write-behind (write-back)
Em write-behind, a aplicação grava apenas em Redis e retorna imediatamente. Um processo em background coleta as mudanças e as grava no banco em lotes. O banco não está mais no caminho da requisição, então escrever é muito rápido. Em troca, Redis temporariamente se torna o lugar que mantém os dados originais.
@Service
public class ProductWriteBehindService {
private static final String DIRTY_SET = "products:dirty";
private static final Logger log = LoggerFactory.getLogger(ProductWriteBehindService.class);
private final ProductRepository repository;
private final RedisTemplate<String, Product> redis;
private final StringRedisTemplate stringRedis;
public ProductWriteBehindService(ProductRepository repository,
RedisTemplate<String, Product> redis,
StringRedisTemplate stringRedis) {
this.repository = repository;
this.redis = redis;
this.stringRedis = stringRedis;
}
public void updatePrice(Long id, BigDecimal newPrice) {
String key = ProductCacheAsideService.key(id);
Product product = Optional.ofNullable(redis.opsForValue().get(key))
.or(() -> repository.findById(id))
.orElseThrow();
product.setPrice(newPrice);
redis.opsForValue().set(key, product); // sem TTL
stringRedis.opsForSet().add(DIRTY_SET, id.toString());
}
@Scheduled(fixedDelay = 5000)
public void flush() {
String id;
while ((id = stringRedis.opsForSet().pop(DIRTY_SET)) != null) {
Product product = redis.opsForValue().get(ProductCacheAsideService.key(Long.valueOf(id)));
if (product == null) {
continue;
}
try {
repository.save(product);
} catch (RuntimeException ex) {
log.warn("Flush falhou para product {}, tentando novamente depois", id, ex);
stringRedis.opsForSet().add(DIRTY_SET, id);
break;
}
}
}
}
Fluxo de processamento é assim:
updatePriceatualiza o registro em Redis e adiciona oidao setproducts:dirty. O request termina em alguns milissegundos.- A cada 5 segundos,
flush()retira cadaidusandoSPOPe grava o registro correspondente no banco. - Se um produto for atualizado 100 vezes em 5 segundos, o banco recebe apenas uma gravação. Este é o maior benefício do write-behind.
- Quando a gravação falha, o
idé devolvido ao set para ser tentado novamente no próximo ciclo.
Este é o padrão mais arriscado entre as três estratégias. Alguns pontos obrigatórios para lidar com:
- Perda de dados quando Redis cai. Você precisa habilitar AOF com
appendfsync everysecoualways, e rodar Redis com réplica. Mesmo assim, pode perder até 1 segundo de dados. - Eviction. Chaves dirty não têm TTL. Defina
maxmemory-policycomonoevictionouvolatile-*, senão Redis pode deletar dados que ainda não foram sincronizados. - Múltiplas instâncias. Graças a
SPOPser atômico, duas instâncias não pegam o mesmoid. Porém, se uma instância morrer logo apósSPOPe antes desave, aquela mudança fica presa em Redis até a próxima atualização. Para mais garantia, use Redis Streams com consumer group eXACK. - Query no banco vê dados antigos. Relatórios, jobs ou outros services que leem direto do banco ficarão alguns segundos atrasados em relação à realidade.
Atenção: Não use write-behind para saldo de conta,
Order,PaymentService, ou qualquer dado onde perder um registro é um incidente. Use para visualizações, likes, posição de leitura, last-seen: dados que são gravados frequentemente e toleram perder um pouco.
6. Testando cache-aside
O teste abaixo usa Redis de verdade rodando em Testcontainers. @ServiceConnection configura automaticamente host e porta para Spring Boot:
@SpringBootTest
@Testcontainers
class ProductCacheAsideServiceTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redisContainer =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Autowired
ProductCacheAsideService service;
@Autowired
ProductRepository repository;
@Autowired
RedisTemplate<String, Product> redis;
@Test
void shouldCacheOnReadAndEvictAfterUpdate() {
Product saved = repository.save(new Product("Teclado mecânico", new BigDecimal("1200000")));
String key = ProductCacheAsideService.key(saved.getId());
service.findById(saved.getId());
assertThat(redis.hasKey(key)).isTrue();
service.updatePrice(saved.getId(), new BigDecimal("990000"));
assertThat(redis.hasKey(key)).isFalse();
Product reloaded = service.findById(saved.getId()).orElseThrow();
assertThat(reloaded.getPrice()).isEqualByComparingTo("990000");
assertThat(redis.hasKey(key)).isTrue();
}
}
O teste verifica o ciclo de vida completo da chave: é criada ao ler, deletada após atualizar, depois recarregada com o novo valor. A classe de teste não é anotada com @Transactional. Se fosse, a transaction do teste sofreria rollback, o callback afterCommit nunca executaria, e o segundo assertion falharia.
7. Comparação e como escolher
| Critério | Cache-aside | Write-through | Write-behind |
|---|---|---|---|
| Latência de leitura primeira vez | Alta (miss) | Baixa | Baixa |
| Latência de escrita | Baixa | Média (escreve 2 lugares) | Muito baixa |
| Consistência com DB | Boa (TTL é rede de segurança) | Boa | Eventual, atraso de segundos |
| Risco de perda de dados | Não | Não | Sim |
| Complexidade | Baixa | Média | Alta |
| Reduz carga de escrita no DB | Não | Não | Sim (agrupa em lotes) |
Algumas orientações ao escolher:
- Por padrão, use cache-aside. É simples, tolera falhas bem (se Redis cair, ainda lê do banco), e é suficiente para a maioria dos dados que são lidos muito e escritos pouco, como catálogo de produtos ou configuração.
- Escolha write-through quando o dado é frequentemente lido logo após ser escrito e você quer evitar o miss inicial. Na prática, muitos sistemas combinam: escrevem tipo write-through, leem tipo cache-aside.
- Use write-behind apenas quando o banco é realmente um gargalo de escrita e o dado tolera perda. Se não tiver números mostrando que o banco está sobrecarregado, não vale a pena pagar o preço dessa complexidade.
- Você não precisa escolher uma estratégia para o sistema todo. Cada tipo de dado pode usar uma estratégia diferente.
Alguns erros comuns com as três estratégias:
- Cache stampede. Uma chave quente expira, milhares de requisições sofrem miss simultaneamente e bombardeiam o banco. Pode ser evitado com TTL jitter aleatório, ou lock usando
SET key value NX PXpara apenas uma requisição recarregar. - Cache penetration. Uma requisição pergunta continuamente por um
idque não existe, então sempre sofre miss. Cache também o resultado vazio com TTL curto. - Mudar estrutura da class e esquecer de limpar cache. JSON antigo não desserializa para a class nova. Adicione versão à chave, por exemplo
product:v2:{id}.
8. Conclusão
O artigo implementou três estratégias de cache com Redis e Spring Boot 3. Cache-aside carrega dados quando necessário e deleta a chave após commit. Write-through sincroniza ambos os lugares quando escreve. Write-behind troca durabilidade de dados por alta velocidade de escrita.
Regra curta e grossa: comece com cache-aside e sempre defina TTL. Suba para write-through quando precisar de cache quente. Use write-behind apenas para dados que toleram perda, com AOF, política de eviction adequada e mecanismo de retry explícito.