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 chave product:1 com TTL de 10 minutos.
  • Chamadas seguintes dentro desses 10 minutos retornam do Redis, sem tocar o banco.
  • updatePrice modifica 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 set com o novo valor. Suponha dois requests atualizando simultaneamente. Se ambos fizerem set, 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 novo set pode 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 Customer ou 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 @Version e 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:

  • updatePrice atualiza o registro em Redis e adiciona o id ao set products:dirty. O request termina em alguns milissegundos.
  • A cada 5 segundos, flush() retira cada id usando SPOP e 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 everysec ou always, 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-policy como noeviction ou volatile-*, senão Redis pode deletar dados que ainda não foram sincronizados.
  • Múltiplas instâncias. Graças a SPOP ser atômico, duas instâncias não pegam o mesmo id. Porém, se uma instância morrer logo após SPOP e antes de save, aquela mudança fica presa em Redis até a próxima atualização. Para mais garantia, use Redis Streams com consumer group e XACK.
  • 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 PX para apenas uma requisição recarregar.
  • Cache penetration. Uma requisição pergunta continuamente por um id que 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.

rediscachespring-bootjavadesempenho