Caching strategies: write-through, write-behind, cache-aside
1. Fundamentos de Cache em Arquitetura de Software
1.1. O papel do cache na redução de latência e na escalabilidade horizontal
Em sistemas distribuídos modernos, o cache atua como uma camada de armazenamento temporário de alta velocidade que reduz significativamente o tempo de acesso a dados. Quando implementado corretamente, um cache pode reduzir a latência de leitura de dezenas de milissegundos (acesso a banco de dados) para menos de um milissegundo (acesso a memória). Além disso, o cache permite que sistemas escalem horizontalmente ao absorver picos de tráfego sem sobrecarregar o banco de dados principal.
1.2. Trade-offs fundamentais: consistência vs. desempenho vs. tolerância a falhas
Toda estratégia de cache envolve um equilíbrio delicado entre três dimensões:
- Consistência: quão atualizados os dados no cache estão em relação ao banco de dados
- Desempenho: velocidade de leitura e escrita percebida pelo usuário
- Tolerância a falhas: capacidade do sistema de se recuperar sem perda de dados
Nenhuma estratégia maximiza as três simultaneamente. A escolha depende dos requisitos de negócio.
1.3. Mapeamento entre estratégias de cache e padrões de acesso
- Read-heavy (80%+ leituras): Cache-Aside é frequentemente a melhor escolha
- Write-heavy (escritas frequentes): Write-Behind oferece melhor throughput
- Dados críticos com baixa taxa de escrita: Write-Through garante consistência forte
2. Cache-Aside (Lazy Loading): Estratégia Clássica sob Demanda
2.1. Fluxo de leitura: miss no cache → busca no banco → população do cache
O Cache-Aside é a estratégia mais comum e intuitiva. A aplicação é responsável por gerenciar o cache explicitamente.
1. Aplicação verifica cache (Redis/Memcached) pela chave "usuario:123"
2. Cache retorna MISS (dado não encontrado)
3. Aplicação consulta banco de dados: SELECT * FROM usuarios WHERE id = 123
4. Aplicação insere resultado no cache: SET usuario:123 = {dados}
5. Aplicação retorna dados para o cliente
Exemplo de código (pseudocódigo):
function getUsuario(id):
dados = cache.get("usuario:" + id)
if dados is None:
dados = banco.query("SELECT * FROM usuarios WHERE id = ?", id)
cache.set("usuario:" + id, dados, ttl=3600)
return dados
2.2. Fluxo de escrita: escrita direta no banco + invalidação explícita do cache
Na escrita, o padrão Cache-Aside exige que o cache seja invalidado (não atualizado) para evitar inconsistências.
function updateUsuario(id, novosDados):
banco.execute("UPDATE usuarios SET nome = ? WHERE id = ?", novosDados.nome, id)
cache.delete("usuario:" + id)
// Próxima leitura fará cache miss e buscará o dado atualizado
2.3. Riscos de arquitetura: cache stampede, TTL inadequado e consistência eventual
Cache Stampede: quando múltiplas requisições simultâneas sofrem cache miss e todas tentam reconstruir o cache ao mesmo tempo, sobrecarregando o banco.
Mitigação: usar mutex de reentrada ou "probabilistic early expiration".
function getUsuarioComMutex(id):
dados = cache.get("usuario:" + id)
if dados is None:
if cache.lock("lock:usuario:" + id, timeout=5):
try:
dados = banco.query(...)
cache.set("usuario:" + id, dados, ttl=3600)
finally:
cache.unlock("lock:usuario:" + id)
else:
sleep(0.1)
return getUsuarioComMutex(id)
return dados
3. Write-Through: Consistência Forte com Penalidade de Latência
3.1. Fluxo síncrono: escrita simultânea no cache e no banco (coordenação atômica)
No Write-Through, toda escrita atualiza simultaneamente o cache e o banco de dados. O cache nunca contém dados obsoletos.
function writeThrough(id, dados):
// Escrita coordenada - idealmente em uma transação distribuída
cache.set("usuario:" + id, dados)
banco.execute("UPDATE usuarios SET ... WHERE id = ?", id)
// Ou ordem inversa: banco primeiro, depois cache
3.2. Impacto na arquitetura: aumento do tempo de escrita, mas leituras sempre quentes
- Latência de escrita: aumenta porque duas operações (cache + banco) ocorrem de forma síncrona
- Latência de leitura: mínima, pois o cache sempre contém dados frescos
- Throughput de escrita: reduzido em comparação com outras estratégias
3.3. Casos de uso: sistemas financeiros, catálogos de produtos com baixa taxa de escrita
Aplicações onde a consistência é crítica e a taxa de escrita é baixa:
- Saldos de contas bancárias
- Preços de produtos em e-commerce (atualizações controladas)
- Configurações de sistema que mudam raramente
4. Write-Behind (Write-Back): Alta Performance com Risco de Perda
4.1. Fluxo assíncrono: escrita apenas no cache + descarga diferida para o banco
O Write-Behind prioriza desempenho: a aplicação escreve apenas no cache e retorna imediatamente. Um processo assíncrono (descarga) sincroniza os dados com o banco posteriormente.
function writeBehind(id, dados):
cache.set("usuario:" + id, dados)
fila.enqueue({"tipo": "UPDATE_USUARIO", "id": id, "dados": dados})
return "OK" // Resposta imediata ao cliente
// Processo separado (worker)
function descargaWorker():
while true:
item = fila.dequeue()
banco.execute("UPDATE usuarios SET ... WHERE id = ?", item.dados)
cache.set("sincronizado:" + item.id, true)
4.2. Mecanismos de descarga: filas, batch processing e janelas de consistência
- Filas (RabbitMQ, Kafka): garantem entrega e ordenação
- Batch processing: agrupa múltiplas escritas em uma única transação de banco
- Janelas de consistência: período máximo tolerado entre escrita no cache e persistência no banco
4.3. Riscos arquiteturais: perda de dados em falha do cache, duplicação e idempotência
Risco principal: se o cache falhar antes da descarga, os dados são perdidos permanentemente.
Mitigações:
- Cache persistente (Redis com AOF/RDB)
- Filas duráveis com confirmação de entrega
- Operações idempotentes no banco para evitar duplicação
// Operação idempotente usando versão
function descargaIdempotente(id, dados, versao):
banco.execute("""
UPDATE usuarios
SET nome = ?, versao = ?
WHERE id = ? AND versao < ?
""", dados.nome, versao, id, versao)
5. Comparação Arquitetural: Quando Usar Cada Estratégia
5.1. Matriz de decisão: latência, consistência, throughput e complexidade operacional
| Característica | Cache-Aside | Write-Through | Write-Behind |
|---|---|---|---|
| Latência de leitura | Baixa (após cache quente) | Muito baixa | Muito baixa |
| Latência de escrita | Média | Alta | Muito baixa |
| Consistência | Eventual | Forte | Eventual (janela) |
| Risco de perda | Baixo | Baixo | Alto |
| Complexidade | Baixa | Média | Alta |
5.2. Combinações híbridas: cache-aside para leituras + write-behind para escritas
Sistemas reais frequentemente combinam estratégias:
function getUsuario(id):
// Cache-Aside para leitura
dados = cache.get("usuario:" + id)
if dados is None:
dados = banco.query(...)
cache.set("usuario:" + id, dados, ttl=3600)
return dados
function updateUsuario(id, dados):
// Write-Behind para escrita
cache.set("usuario:" + id, dados)
fila.enqueue({"id": id, "dados": dados})
5.3. Padrões de degradação: circuit breaker, fallback para banco e cache warming
- Circuit breaker: se o cache falha repetidamente, desvia tráfego diretamente para o banco
- Fallback: em caso de falha do cache, toda operação vai para o banco
- Cache warming: pré-carregar dados críticos no cache durante inicialização do sistema
6. Desafios Transversais e Padrões de Mitigação
6.1. Invalidação de cache distribuído: broadcast, pub/sub e leases
Em sistemas distribuídos com múltiplas instâncias de cache, a invalidação deve ser propagada:
// Publicação de evento de invalidação
cache.publish("cache:invalidate", "usuario:123")
// Todas as instâncias escutam
cache.subscribe("cache:invalidate", function(chave):
cache.delete(chave)
)
6.2. Cache stampede: mutex de reentrada, dogpile prevention e probabilistic early expiration
Probabilistic Early Expiration (PEE): expirar o cache antes do TTL real com probabilidade crescente, evitando que todas as requisições expirem simultaneamente.
function shouldRefresh(ttlOriginal, idadeAtual):
// Quanto mais próximo do TTL, maior a chance de refresh antecipado
probabilidade = idadeAtual / ttlOriginal
return random() < (probabilidade ** 2)
6.3. Monitoramento: hit ratio, staleness metrics e alertas de divergência
Métricas essenciais:
- Hit ratio: percentual de leituras servidas pelo cache (ideal > 90%)
- Staleness: idade média dos dados no cache
- Divergência: diferença entre valor no cache e no banco (para Write-Behind)
7. Integração com Padrões de Arquitetura Moderna
7.1. Cache em microsserviços: cache local vs. cache global (Redis, Memcached)
- Cache local (Caffeine, Guava): baixa latência, mas inconsistência entre instâncias
- Cache global (Redis Cluster): consistência entre instâncias, mas latência de rede
7.2. Relação com eventos e CQRS: cache como materialized view assíncrona
No padrão CQRS, o cache pode atuar como uma materialized view que reflete o estado atual do sistema, atualizada por eventos de domínio.
// Evento de atualização de usuário
eventBus.publish(UsuarioAtualizado(123, novosDados))
// Handler atualiza cache
function handleUsuarioAtualizado(evento):
cache.set("usuario:" + evento.id, evento.dados)
7.3. Estratégias em ambientes cloud-native: CDN, edge caching e multi-tier caching
- CDN: cache de conteúdo estático em bordas geográficas
- Edge caching: Cloudflare Workers, AWS Lambda@Edge para cache em borda
- Multi-tier: L1 (cache local) → L2 (Redis) → L3 (banco de dados)
Referências
- Redis Documentation: Cache Sidecar Pattern — Guia oficial da Redis sobre implementação de cache-aside com exemplos práticos
- AWS Caching Strategies and Best Practices — Documentação da AWS sobre estratégias de cache em ambientes cloud-native
- Martin Fowler: Cache-Aside Pattern — Artigo clássico de Martin Fowler explicando o padrão Cache-Aside
- Microsoft Azure: Cache-Aside Pattern — Documentação oficial da Microsoft sobre implementação do padrão em arquiteturas Azure
- High Scalability: Write-Behind Caching — Análise detalhada de write-behind caching com estudos de caso de sistemas reais
- Redis: Write-Through vs Write-Behind — Comparação oficial Redis entre estratégias write-through e write-behind
- System Design Interview: Caching Strategies — Guia prático de estratégias de cache para design de sistemas em entrevistas técnicas