Capa do artigo: Cache em arquiteturas distribuídas: padrões, trade-offs e o teorema CAP
Série Cache em Produção · Parte 3

Cache em arquiteturas distribuídas: padrões, trade-offs e o teorema CAP

Série Cache em Produção Parte 3

No artigo anterior desta série, vimos que o cache existe porque o acesso a dados próximos é ordens de magnitude mais rápido do que o acesso a dados distantes, e que esse princípio se aplica desde os registradores de um processador até os servidores de borda de uma CDN. O princípio da localidade de referência, formalizado por Peter Denning em 1968, é o que torna o cache eficaz, pois dados acessados recentemente têm alta probabilidade de serem acessados novamente em breve.

Mas entender por que o cache funciona é diferente de saber como usá-lo dentro de uma arquitetura de sistema. Um cache colocado sem critério no lugar errado, com o padrão de integração errado, pode gerar inconsistências difíceis de diagnosticar, criar pontos únicos de falha, ou simplesmente não oferecer o benefício esperado. Este artigo explora os principais padrões de integração entre cache e banco de dados, os contextos em que cada um faz sentido, e como o teorema CAP enquadra as decisões de consistência que esses padrões implicam.

O problema que os padrões resolvem

Antes de apresentar os padrões individualmente, vale entender o problema geral que todos eles compartilham. Um sistema com cache tem dois locais onde um mesmo dado pode existir, que são o cache e o banco de dados. O banco é a origem autoritativa dos dados, o local onde a versão mais atualizada e confiável reside. O cache é uma cópia, possivelmente mais antiga, de parte desses dados.

O problema central é manter essas duas representações em um estado coerente o suficiente para que o sistema se comporte de forma correta do ponto de vista do usuário. Quando e como o cache é populado, quando é invalidado, e quem é responsável por escrever na origem são as perguntas que os padrões de integração respondem. Cada resposta carrega consigo um conjunto diferente de garantias e limitações.

Cache-aside: o padrão mais usado

O cache-aside, também chamado de lazy loading, é o padrão onde a aplicação controla explicitamente a interação com o cache. A responsabilidade de consultar o cache, detectar um miss, buscar na origem, e popular o cache recai sobre o código da aplicação.

O fluxo de leitura começa com a aplicação consultando o cache pela chave do dado desejado. Se o dado estiver presente (o que se chama de cache hit), ele é retornado diretamente. Se não estiver presente (o que se chama de cache miss), a aplicação consulta o banco de dados, armazena o resultado no cache com um TTL definido, e retorna o resultado ao chamador. TTL, ou time-to-live, é o tempo máximo que uma entrada permanece válida no cache antes de ser descartada automaticamente. A partir do segundo acesso ao mesmo dado, dentro do período de validade do TTL, o banco não é envolvido.

sequenceDiagram
    participant App as Aplicação
    participant Cache as Cache (Redis)
    participant DB as Banco de Dados

    App->>Cache: GET produto:123
    alt Cache Hit
        Cache-->>App: retorna dado
    else Cache Miss
        Cache-->>App: nil
        App->>DB: SELECT * FROM produtos WHERE id = 123
        DB-->>App: retorna dado
        App->>Cache: SET produto:123 [dado] EX 300
        App-->>App: retorna dado ao chamador
    end

Uma das propriedades mais valorizadas do cache-aside é a resiliência à falha do cache. Se o Redis ficar indisponível, a aplicação continua funcionando com degradação de performance, pois todas as requisições passam a ir direto ao banco. O sistema perde performance, mas mantém a correção.

Outra propriedade é a flexibilidade do modelo de dados armazenado. Como a aplicação controla o que vai para o cache, o dado armazenado pode ser uma transformação do dado original, uma agregação de múltiplas queries, ou qualquer estrutura que faça sentido para o padrão de leitura da aplicação. Essa flexibilidade desaparece nos padrões onde o cache gerencia a busca na origem automaticamente.

A limitação mais relevante do cache-aside está na janela de inconsistência entre escritas e leituras. Quando um dado é atualizado no banco de dados, a entrada correspondente no cache pode permanecer desatualizada até que o TTL expire. Isso é aceitável para dados que toleram leve desatualização, como uma contagem de visualizações ou a lista de produtos mais populares. Para dados que precisam refletir a versão mais recente imediatamente, como o saldo de uma conta bancária, essa janela de inconsistência é inaceitável.

A documentação de padrões de cache da AWS, publicada como whitepaper técnico sobre estratégias de cache com Redis, descreve esse padrão como o mais indicado para workloads de leitura intensa onde a consistência imediata entre cache e banco não é um requisito.

"Uma cache cache-aside é atualizada após o dado ser requisitado. [...] Quando seu aplicativo precisa ler dados do banco de dados, ele verifica o cache primeiro para determinar se os dados estão disponíveis. Se os dados estiverem disponíveis, eles são retornados ao chamador sem consultar o banco de dados."

— AWS, Database Caching Strategies Using Redis, AWS Whitepaper

Cache stampede e o thundering herd

Existe um problema que afeta especialmente o padrão cache-aside em sistemas de alta carga e que merece atenção antes de analisar outros padrões. Esse problema é chamado de cache stampede, uma manifestação do fenômeno mais amplo conhecido como thundering herd. Ele ocorre quando o TTL de um dado muito popular expira e várias requisições concorrentes detectam o mesmo cache miss. O thundering herd descreve situações em que muitos processos são despertados simultaneamente para disputar um mesmo recurso. No caso do cache stampede, várias requisições detectam o miss ao mesmo tempo e acabam consultando o banco de dados em paralelo.

Imagine um artigo com um milhão de leitores por dia. Enquanto o dado está no cache, todas as requisições são atendidas em microssegundos. No instante em que o TTL expira, porém, centenas ou milhares de requisições chegam simultaneamente, todas detectam o cache miss, e todas disparam uma consulta ao banco de dados ao mesmo tempo. O banco, que estava protegido pelo cache, é repentinamente atingido por um volume de requisições para o qual não foi dimensionado. O tempo de resposta sobe, o banco pode começar a rejeitar conexões, e o cache demora para ser repovoado porque cada operação de leitura no banco competindo com as demais fica mais lenta. O problema se agrava a si mesmo.

O cache stampede é uma das formas mais documentadas de custo oculto associado ao uso de cache. Existem estratégias específicas para mitigá-lo, e elas serão exploradas em detalhe no próximo artigo desta série, junto com outros custos estruturais e operacionais que o cache introduz.

Read-through: delegando a responsabilidade ao cache

No padrão read-through, o cache age como intermediário entre a aplicação e o banco de dados. A aplicação sempre consulta o cache, e o cache gerencia a busca na origem quando necessário. A diferença para o cache-aside é que a lógica de consultar o banco e popular o cache fica dentro do cache (ou de uma camada de abstração que o envolve), e não no código da aplicação.

Do ponto de vista da aplicação, a interação é simples. Ela pede um dado ao cache e sempre recebe uma resposta. O cache se encarrega de ir ao banco se necessário, armazenar o resultado, e devolvê-lo. A aplicação não precisa implementar a lógica de miss.

Isso simplifica o código da aplicação, especialmente quando múltiplos serviços ou instâncias precisam acessar os mesmos dados. Com cache-aside, cada instância precisa implementar o mesmo padrão de miss-handling. Com read-through, a lógica centralizada no cache garante que todos os chamadores se comportem da mesma forma.

A limitação está no primeiro acesso a qualquer dado. Como o cache só é populado quando o dado é requisitado pela primeira vez, o primeiro usuário a pedir um determinado recurso sempre paga o custo da busca na origem. Uma prática comum para mitigar isso é o cache warming, ou pré-carregamento, onde os dados mais prováveis de serem acessados são carregados no cache durante a inicialização do sistema ou em horários de baixa carga.

O padrão read-through é especialmente adequado para CDNs, que são essencialmente implementações de read-through em escala global. Quando um usuário solicita um arquivo de imagem servido por uma CDN, o servidor de borda verifica se tem aquele arquivo em cache. Se não tiver, busca na origem, armazena localmente, e entrega ao usuário. Todos os usuários subsequentes que solicitarem o mesmo arquivo a partir do mesmo servidor de borda receberão a versão em cache, sem envolver a origem.

Write-through: mantendo cache e banco sincronizados nas escritas

Nos dois padrões anteriores, as escritas sempre vão diretamente ao banco de dados, e o cache é atualizado de forma reativa, seja por TTL, seja por invalidação explícita. O write-through muda essa dinâmica. Quando um dado é escrito, a aplicação atualiza o banco de dados e o cache de forma síncrona, antes de retornar confirmação ao chamador.

É importante entender quem faz o quê nesse fluxo. O Redis opera sem conectividade nativa com bancos de dados relacionais, cabendo à aplicação propagar as escritas nos dois sistemas. A ordem recomendada é escrever primeiro no banco de dados, aguardar a confirmação, e só então escrever no cache. Essa sequência garante que, se o sistema falhar entre as duas operações, o banco de dados, a origem autoritativa dos dados, terá o dado correto. Falhar após a escrita no banco mas antes da escrita no cache significa apenas que o próximo acesso ao cache resultará em um miss, o que é uma situação tratável. Falhar após a escrita no cache mas antes da escrita no banco geraria uma situação mais grave, pois o cache teria um dado que o banco não confirma.

sequenceDiagram
    participant Caller as Chamador
    participant App as Aplicação
    participant DB as Banco de Dados
    participant Cache as Cache (Redis)

    Caller->>App: atualizar produto:123
    App->>DB: UPDATE produtos SET ... WHERE id = 123
    DB-->>App: confirmação
    App->>Cache: SET produto:123 [novo dado] EX 300
    Cache-->>App: confirmação
    App-->>Caller: confirmação

O benefício é que o cache se mantém atualizado para os dados que passaram por escritas recentes. Se um produto tem seu preço atualizado e o sistema usa write-through, a próxima leitura desse produto no cache já terá o preço novo, porque a escrita atualizou os dois locais na mesma operação.

O custo é a latência de escrita. A aplicação precisa esperar que tanto o banco quanto o cache confirmem a operação antes de continuar. Se o banco de dados for lento, toda operação de escrita no sistema fica mais lenta pelo mesmo fator. Em sistemas com alta taxa de escrita, isso pode se tornar um gargalo relevante.

Há também o risco de inconsistência no sentido oposto ao do cache-aside. Com cache-aside, a inconsistência é o cache estar desatualizado em relação ao banco. Com write-through sem cache-aside complementar para leituras, é possível que o cache contenha dados que nunca serão lidos, ocupando memória sem benefício. A combinação mais comum na prática é usar write-through para as escritas e cache-aside (ou read-through) para as leituras, garantindo que os dados mais acessados estejam em cache e que as escritas mantenham o cache atualizado.

Write-behind: priorizando performance nas escritas

O write-behind, também chamado de write-back, inverte a ordem de prioridade do write-through. A aplicação escreve no cache, recebe confirmação imediatamente, e um processo externo, geralmente um worker de background ou um consumer de fila, é responsável por ler as escritas pendentes do cache e propagá-las ao banco de dados de forma assíncrona. O Redis em si não tem mecanismo nativo de write-behind. A aplicação não espera o banco confirmar diretamente.

O ganho de performance é significativo em sistemas com alta taxa de escrita. Se dez mil atualizações chegam em um segundo, o cache pode absorver todas elas imediatamente e enviá-las ao banco em um único batch, reduzindo drasticamente o número de operações de escrita no banco e eliminando a latência de espera para cada operação individual.

O risco é proporcional ao benefício. Se o cache falhar antes de propagar as escritas pendentes ao banco, essas escritas são perdidas permanentemente. O banco ficará em um estado anterior ao que o sistema julgava ter confirmado. Para dados financeiros, transações ou qualquer informação que não possa ser perdida, esse risco é inaceitável. Para dados onde uma perda ocasional pode ser tolerada, como estatísticas de acesso, contadores de likes, ou logs de eventos de baixa criticidade, o write-behind pode ser uma escolha válida.

A documentação da Oracle para o Oracle Coherence, um dos primeiros sistemas de cache distribuído empresarial a documentar formalmente esses padrões, descreve o write-behind assim:

"O padrão write-behind muda o timing da escrita no sistema de registro. Em vez de escrever no sistema de registro enquanto a thread que faz a atualização aguarda (como no write-through), o write-behind enfileira o dado para escrita em um momento posterior."

— Oracle, Read-Through, Write-Through, Write-Behind Caching and Refresh-Ahead, Oracle Coherence Documentation

A própria documentação da Oracle citada acima lista um quinto padrão: o refresh-ahead, onde o cache recarrega proativamente os dados antes que o TTL expire, sem esperar um miss. A vantagem é eliminar a latência do primeiro acesso para dados com padrão de acesso previsível e regular. A limitação é que exige conhecimento antecipado de quais dados serão requisitados, tornando-o mais adequado para casos como rankings recalculados periodicamente ou configurações com ciclo de atualização fixo do que para dados com padrão de acesso imprevisível.

Os padrões descritos até aqui são agnósticos em relação à ferramenta: qualquer sistema de cache pode implementá-los. O que muda de ferramenta para ferramenta são as garantias que cada padrão oferece na prática e as limitações que ele carrega. Essas diferenças ficam mais claras quando se entende como uma ferramenta foi construída e para qual problema ela foi projetada.

Quando o problema define a ferramenta: a história do Redis

Ferramentas são moldadas pelos contextos em que foram criadas. Suas decisões de design refletem as restrições e necessidades que seus criadores enfrentavam naquele momento. Entender esse contexto ajuda a explicar por que uma ferramenta prioriza determinados aspectos e onde estão seus limites.

Salvatore Sanfilippo começou o projeto em 2009 quando trabalhava em sua startup italiana LLOOGG, um analisador de logs web com processamento de baixa latência. O MySQL não conseguia acompanhar o volume de operações de leitura e escrita necessários para análise com baixíssima latência, então Sanfilippo prototipou um banco de dados em memória em Tcl com cerca de 300 linhas de código. Após algumas semanas de uso interno bem-sucedido, transformou o protótipo em C, adicionou o tipo de dados lista, e publicou o projeto no Hacker News em fevereiro de 2009 com a ajuda de David Welton.

A resposta inicial foi tímida. Poucas pessoas responderam ao post original, e a maioria apontou que já existiam projetos similares. Entre as respostas positivas estava a de Ezra Zygmuntowicz, desenvolvedor conhecido na comunidade Ruby on Rails, que produziu um dos primeiros clientes Ruby para Redis. Esse trabalho abriu as portas para a adoção do Redis na comunidade Ruby, que incluía o GitHub e o Instagram como primeiros usuários em produção.

O Instagram, lançado em outubro de 2010, foi construído inteiramente sobre Redis em seus primeiros anos. A arquitetura original, documentada em apresentações técnicas de Mike Krieger, co-fundador da empresa, usava nginx, Redis, memcached, PostgreSQL, Gearman e Django. Redis era central para o feed de fotos, os contadores de likes e seguidores, e as sessões de usuário.

A história do Instagram com Redis ilustra tanto o poder quanto as limitações da abordagem. Em seu crescimento acelerado, chegando a 15 milhões de usuários em meados de 2011 antes mesmo de lançar o app para Android, o Redis absorveu cargas que teriam destruído uma arquitetura puramente baseada em banco de dados relacional. Mas à medida que o Instagram cresceu ainda mais, migrou partes de sua infraestrutura para Apache Cassandra, especialmente para os dados que precisavam de escalabilidade horizontal mais agressiva e tolerância a falhas com disponibilidade garantida.

Essa migração refletiu o reconhecimento de que ferramentas diferentes são projetadas para trade-offs diferentes, e que o Redis, com seu modelo de dados em memória e sua replicação assíncrona, foi otimizado para velocidade antes de consistência forte. Entender essa escolha de design é o passo necessário antes de analisar onde o Redis se posiciona no teorema CAP.

O contexto distribuído e o teorema CAP

Até aqui, os padrões foram descritos no contexto de um único servidor de cache interagindo com um banco de dados. Em sistemas distribuídos com múltiplas instâncias de cache, múltiplos nós de banco de dados, e múltiplas regiões geográficas, a questão da consistência fica mais complexa.

Em 2000, o professor Eric Brewer da Universidade da Califórnia em Berkeley apresentou no Symposium on Principles of Distributed Computing uma conjectura que ficou conhecida como teorema CAP. Em 2002, Seth Gilbert e Nancy Lynch do MIT publicaram a prova formal. O teorema afirma que qualquer sistema de dados distribuído pode garantir no máximo duas das três propriedades seguintes ao mesmo tempo.

A primeira propriedade é a consistência, onde toda leitura recebe o dado mais recente que foi escrito, ou um erro. A segunda é a disponibilidade, onde toda requisição recebe uma resposta, sem garantia de que contém o dado mais recente. A terceira é a tolerância a partições, que garante que o sistema continua operando mesmo que a comunicação entre nós seja perdida.

"Um sistema de dados distribuído só pode fornecer duas das três seguintes garantias: consistência, disponibilidade e tolerância a partições."

— Eric Brewer, Towards Robust Distributed Systems, PODC Keynote, 2000, formalizado por Gilbert e Lynch em Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services, ACM SIGACT News, 2002

Em sistemas distribuídos em produção, partições de rede são inevitáveis. Um cabo pode ser cortado. Um datacenter pode ficar temporariamente isolado. Um nó pode falhar e perder conectividade com os demais. Por isso, na prática, a escolha se reduz a uma tensão entre consistência e disponibilidade durante uma partição.

O cache tem uma relação direta com esse trade-off. Um cache que sempre retorna dados locais, mesmo quando não tem certeza de que estão atualizados, prioriza disponibilidade. Um sistema que invalida o cache e consulta a origem durante qualquer incerteza sobre consistência prioriza consistência, mas pode degradar a performance ou falhar completamente se a origem estiver indisponível.

Como ferramentas de cache se posicionam no teorema CAP

Toda ferramenta de cache distribuído ocupa algum lugar no espaço delimitado pelo teorema CAP, e essa posição depende tanto das escolhas de design da ferramenta quanto de como ela é configurada. O Redis é a ferramenta mais amplamente usada nesse contexto, e sua análise ilustra os trade-offs que qualquer sistema similar enfrenta. Afirmar que o Redis é simplesmente um sistema CP, aquele que garante consistência e tolerância a partições ao custo de disponibilidade, seria uma simplificação que oculta trade-offs importantes. A resposta depende de como o Redis está configurado e de quais garantias específicas estão sendo avaliadas.

Para começar, o teorema CAP se aplica a sistemas com múltiplos nós que precisam manter dados replicados. Em configuração de nó único, o Redis funciona como qualquer banco de dados em memória convencional, e a questão de partições de rede simplesmente não se coloca. O nó está disponível ou não está.

O quadro muda quando se adiciona replicação. O Redis, por padrão, usa replicação assíncrona entre o nó primário e seus nós secundários. Isso significa que uma escrita confirmada pelo primário pode não ter chegado aos secundários no momento da confirmação. Se o primário falhar e um secundário for promovido a novo primário antes de receber todas as escritas pendentes, essas escritas são perdidas para sempre. Martin Kleppmann, em seu livro "Designing Data-Intensive Applications" publicado pela O'Reilly em 2017, descreve exatamente esse cenário ao discutir replicação:

"Como a replicação é assíncrona, um seguidor pode estar atrasado em relação ao líder [...] Se o líder falhar e você promover um seguidor como novo líder, qualquer dado escrito para o líder antigo que ainda não foi replicado para o novo líder será perdido."

— Martin Kleppmann, Designing Data-Intensive Applications, O'Reilly, 2017

Esse comportamento enquadra o Redis com replicação assíncrona entre os sistemas AP, que priorizam disponibilidade e tolerância a partições em detrimento de consistência forte. Em termos práticos, o Redis com replicação assíncrona pode confirmar escritas que não sobrevivem a um failover. O banco de dados pós-failover terá um estado diferente do que o cliente acredita que confirmou.

O comportamento se aproxima mais de CP no Redis Cluster quando um lado da partição fica sem quórum. Nesse caso, o cluster recusa escritas no lado minoritário, preservando a consistência ao custo de ficar indisponível. Mas essa proteção é limitada. Escritas aceitas antes da partição ser detectada ainda podem ser perdidas se o nó primário falhar durante o período de incerteza.

A conclusão honesta é que o Redis, em sua configuração padrão com replicação assíncrona, prioriza baixa latência e alta disponibilidade. Consistência eventual é a consequência direta dessa escolha de design.

Para casos onde a perda de escritas confirmadas é inaceitável, é importante distinguir dois problemas diferentes.

O primeiro envolve a durabilidade em relação a crashes do processo. O modo AOF com appendfsync always garante que cada escrita seja persistida em disco antes de ser confirmada, protegendo contra perda de dados em reinicializações do servidor.

O segundo envolve a durabilidade em relação a failovers de replicação. Mesmo com AOF síncrono ativo no primário, escritas que não foram replicadas antes do failover continuam vulneráveis, pois o risco vem do atraso de replicação entre primário e réplica, independente de como os dados foram persistidos localmente. Para esse caso, o Redis oferece as configurações min-replicas-to-write e min-replicas-max-lag, que fazem o primário recusar escritas quando não houver réplicas suficientemente atualizadas, ou o comando WAIT, que bloqueia a confirmação de uma escrita específica até que um número mínimo de réplicas a tenha recebido. Qualquer dessas abordagens implica latência adicional, e para requisitos de consistência forte sem concessões, ferramentas projetadas com esse objetivo desde o início são a alternativa mais adequada.

Ferramentas como Apache Cassandra e Amazon DynamoDB oferecem controle ainda mais granular, permitindo que o cliente defina o quórum necessário para cada leitura e escrita individualmente. Essa granularidade confirma que consistência e disponibilidade formam um espectro ajustável por operação, com cada requisição podendo calibrar o ponto de equilíbrio adequado ao seu contexto.

Brewer revisitou o teorema CAP em 2012, refinando a análise original para reconhecer exatamente esse ponto.

"O objetivo moderno do CAP deveria ser maximizar as combinações de consistência e disponibilidade que fazem sentido para a aplicação específica. Tal abordagem incorpora planos para operação durante uma partição e para recuperação após ela."

— Eric Brewer, CAP Twelve Years Later: How the 'Rules' Have Changed, IEEE Computer, 2012

Essa perspectiva é o que guia as decisões de cache em sistemas maduros. Para dados de catálogo de produtos, a disponibilidade com leve inconsistência temporal é aceitável, pois mostrar um preço com alguns segundos de atraso não compromete a experiência do usuário. Para dados de inventário com atualização contínua, a consistência é mais importante, já que vender um produto que já esgotou tem consequência direta no negócio.

Cache distribuído: replicação e consistência entre instâncias

Quando um sistema tem múltiplas instâncias de cache, ou múltiplos nós em um cluster de Redis, surge a questão de como manter a consistência entre eles. Se dois nós de cache têm versões diferentes do mesmo dado, qual é a versão correta? Como garantir que uma invalidação feita por uma instância da aplicação seja propagada para todos os nós de cache que possam ter uma cópia do dado?

O Redis Sentinel e o Redis Cluster são as duas principais soluções para esse problema. O Sentinel oferece alta disponibilidade para um único primário com suas réplicas, realizando failover automático. Se o nó primário falhar, o Sentinel detecta a falha por meio de votação entre os processos Sentinel, promove uma das réplicas a primário e reconfigura os clientes para apontar para o novo primário. O Cluster distribui os dados entre múltiplos nós por meio de hash slots, divididos em 16.384 posições fixas. Cada chave é mapeada a um slot via CRC16(chave) mod 16384, e cada slot é atribuído a um nó específico do cluster. Essa abordagem garante que ao adicionar ou remover nós apenas os slots do nó afetado precisam ser redistribuídos.

Em ambos os casos, há uma latência de replicação entre o primário e os secundários. Escritas confirmadas no primário podem demorar alguns milissegundos para aparecer nos secundários. Durante esse intervalo, uma leitura feita a partir de um secundário pode retornar um dado desatualizado. Isso é consistência eventual em ação, onde o sistema converge para a consistência, mas há um período em que diferentes réplicas têm estados diferentes.

graph TD
    App1[Instância da App 1] --> Primary[Redis Primary]
    App2[Instância da App 2] --> Primary
    Primary -->|replicação assíncrona| Replica1[Redis Replica 1]
    Primary -->|replicação assíncrona| Replica2[Redis Replica 2]
    App3[Instância da App 3] --> Replica1
    App4[Instância da App 4] --> Replica2
    Sentinel[Redis Sentinel] -.monitoramento.-> Primary
    Sentinel -.monitoramento.-> Replica1
    Sentinel -.monitoramento.-> Replica2

Para a maioria das aplicações web, essa inconsistência temporária é aceitável. Um usuário que atualiza seu perfil e imediatamente recarrega a página pode ver a versão anterior por uma fração de segundo. Isso raramente é um problema percebível. Mas para aplicações financeiras, sistemas de reserva onde dois usuários não podem reservar o mesmo recurso, ou qualquer sistema onde a leitura de um dado desatualizado pode levar a uma ação incorreta, a replicação assíncrona precisa ser tratada com cuidado.

Cache local em memória e o custo de coerência

Além do cache distribuído como Redis, muitos sistemas usam cache local em memória, dentro de cada instância da aplicação. Isso é implementado com estruturas de dados diretamente na heap da JVM, do processo Python, ou da runtime Go. A latência de acesso é menor ainda, pois elimina o overhead de rede e de serialização que qualquer cache remoto carrega. Um acesso a um mapa em memória leva nanossegundos, enquanto uma chamada de rede ao Redis leva microsegundos.

O custo do cache local é a coerência entre instâncias. Se uma aplicação tem dez instâncias rodando em paralelo, cada uma tem seu próprio cache local com sua própria versão dos dados. Quando um dado é atualizado, como o preço de um produto, apenas a instância que fez a atualização sabe disso. As outras nove instâncias continuam servindo o preço antigo até que seus TTLs expirem ou até que recebam uma notificação de invalidação.

Essa ausência de coerência é tolerável para dados altamente estáticos, como configurações do sistema que mudam raramente ou listas de países que nunca mudam. Para dados que podem mudar com frequência, o cache local em memória introduz janelas de inconsistência proporcionais ao TTL, multiplicadas pelo número de instâncias da aplicação.

Uma solução intermediária é combinar cache local com cache distribuído em duas camadas. A aplicação consulta primeiro o cache local. Se não encontrar, consulta o Redis. Se não encontrar no Redis, vai ao banco. A invalidação pode ser propagada via pub/sub do Redis, onde todas as instâncias se inscrevem em um canal e recebem notificações quando um dado é invalidado, podendo então remover a entrada do seu cache local.

Integração com sistemas orientados a eventos

Em arquiteturas que usam filas de mensagens ou sistemas de streaming como Apache Kafka, o cache pode ser integrado ao fluxo de eventos para manter os dados atualizados de forma mais granular do que o TTL permite.

Quando um evento de atualização é publicado, como um evento de PriceUpdated para um produto, um consumidor desse evento invalida ou atualiza proativamente a entrada correspondente no cache. Isso é chamado de invalidação orientada a eventos, e elimina a janela de inconsistência do TTL para os dados que têm eventos correspondentes no sistema.

A vantagem é que o cache reflete o estado atual dos dados quase instantaneamente, sem depender de um intervalo de expiração fixo. A desvantagem é a maior complexidade, pois o sistema de cache passa a depender do sistema de mensageria. Se a fila ficar para trás ou perder mensagens, o cache pode ficar desatualizado sem que haja um mecanismo automático de recuperação, como o TTL fornece.

As prioridades que cada padrão carrega

Cada padrão de cache reflete uma escolha sobre o que o sistema prioriza. Cache-aside com TTL longo prioriza performance de leitura ao custo de tolerar dados levemente desatualizados. Write-through prioriza consistência ao custo de maior latência nas escritas. Write-behind prioriza throughput de escrita ao custo de aceitar o risco de perda de dados em falhas. Cache local prioriza latência mínima ao custo de coerência entre instâncias.

Nenhum padrão é universalmente superior. A escolha depende do tipo de dado, do padrão de acesso, dos requisitos de consistência do negócio, e da tolerância ao risco de perda de dados. Sistemas com estratégia de cache bem definida aplicam padrões diferentes para tipos diferentes de dados dentro da mesma aplicação. Write-through para dados financeiros, cache-aside com TTL curto para dados de perfil, cache local em memória para configurações estáticas.

Comparativo dos padrões

Padrão Consistência Latência de escrita Risco de perda Caso de uso típico
Cache-aside Eventual (TTL) Baixa (escrita vai direto ao banco) Nenhum (dado desatualizado no máximo) Leitura intensa com tolerância a leve desatualização
Read-through Eventual (TTL) Baixa (escrita não passa pelo cache) Nenhum CDNs, dados públicos com alto volume de leitura
Write-through Forte (para escritas recentes) Alta (banco e cache síncronos) Nenhum Dados que precisam de leitura consistente após escrita
Write-behind Eventual (assíncrono) Muito baixa (confirmação imediata) Alto (escritas pendentes perdem-se em falha) Contadores, logs, estatísticas de baixa criticidade
Refresh-ahead Eventual (TTL antecipado) N/A (otimização de leitura) Nenhum Rankings, configurações, dados com acesso regular e previsível

No próximo artigo desta série, o foco se desloca para os custos que o cache introduz, sejam eles financeiros, operacionais ou cognitivos. A invalidação de cache é reconhecida como um dos problemas mais difíceis da engenharia de software, e o custo de fazer isso errado vai muito além do dado desatualizado que o usuário ocasionalmente vê.