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