Todo engenheiro que já operou um sistema em produção conhece aquele momento em que os alertas começam a aparecer ao mesmo tempo. O tempo de resposta sobe. A CPU dos servidores de banco de dados vai a 100%. As requisições se acumulam. Os usuários relatam erros. A causa quase sempre segue o mesmo padrão. Um volume de acesso acima do esperado atingiu um sistema que não estava preparado para ele, e o banco de dados se tornou o ponto de colapso.
Esse padrão é suficientemente comum para ter nome na literatura de sistemas distribuídos. Chama-se degradação progressiva sob carga, e ele ocorre quando um componente começa a responder mais devagar, os componentes que dependem dele também ficam mais lentos, novas requisições chegam antes que as anteriores terminem, e a fila cresce até que o sistema começa a rejeitar trabalho. Entender por que isso acontece, e por que o cache é a resposta mais frequente para esse tipo de crise, é o ponto de partida desta série.
Como um banco de dados relacional se comporta sob pressão
Um banco de dados relacional como PostgreSQL ou MySQL é um sistema projetado para armazenar dados de forma persistente e devolvê-los com garantias de consistência. Para isso, ele percorre, a cada query recebida, uma sequência de etapas que vai da análise do SQL à verificação de permissões, ao acesso aos dados em memória ou em disco, à filtragem e à montagem do resultado final. Cada uma dessas etapas tem um custo computacional que, individualmente, é pequeno. O problema aparece quando muitas queries chegam ao mesmo tempo.
O PostgreSQL mantém um buffer pool na memória RAM para armazenar páginas de dados recentemente acessadas. Quando uma query precisa de dados que já estão nesse buffer, a resposta é rápida. Quando os dados precisam ser lidos do disco, o custo aumenta drasticamente. Jeff Dean catalogou em apresentações sobre sistemas de larga escala no Google as ordens de grandeza de latência entre diferentes tipos de acesso, números que se tornaram referência na literatura de sistemas distribuídos. Uma leitura de memória RAM leva cerca de 100 nanossegundos, enquanto uma leitura sequencial de disco magnético leva cerca de 20 milissegundos, uma diferença de 200.000 vezes. Mesmo com SSDs modernos, que operam na faixa de décimos de milissegundo para acesso aleatório, a diferença para a memória ainda é de duas a três ordens de grandeza.
Quando o volume de requisições cresce além do que o buffer pool consegue absorver, o banco começa a ir ao disco com mais frequência. Cada ida ao disco aumenta o tempo de resposta da query. Queries que demoram mais mantêm as conexões abertas por mais tempo. Conexões que ficam abertas por mais tempo esgotam o pool de conexões disponíveis. Novas requisições passam a esperar por uma conexão livre. A latência sobe. E esse ciclo se retroalimenta.
graph TD
A[Volume de requisições cresce] --> B[Buffer pool não absorve]
B --> C[Banco vai ao disco com mais frequência]
C --> D[Queries demoram mais]
D --> E[Conexões ficam abertas por mais tempo]
E --> F[Pool de conexões se esgota]
F --> G[Novas requisições esperam por conexão]
G --> H[Latência sobe]
H --> AEsse fenômeno é chamado de contenção de recursos. Ele ocorre quando múltiplos processos ou threads competem pelo mesmo recurso limitado ao mesmo tempo. No contexto de banco de dados, o recurso disputado pode ser o tempo de CPU, o espaço no buffer pool, o acesso a uma página de dados específica, ou as conexões disponíveis no pool.
"A medida em que o número de transações concorrentes aumenta, o sistema gasta uma fração crescente de seu tempo gerenciando conflitos e uma fração decrescente executando trabalho útil."
— Jim Gray e Andreas Reuter, Transaction Processing: Concepts and Techniques, Morgan Kaufmann, 1992
À medida que o número de conexões simultâneas cresce, o custo de coordenação entre elas cresce junto, e o throughput efetivo do sistema começa a cair mesmo que o hardware ainda tenha capacidade disponível.
Por que as mesmas queries são executadas milhares de vezes
Um padrão frequente em sistemas web é que a maioria das requisições acaba sendo por um conjunto pequeno de dados. Em plataformas de e-commerce, os produtos mais populares recebem a maior parte dos acessos. Em serviços de streaming, os conteúdos em alta são requisitados por muitos usuários ao mesmo tempo. Em aplicações de notícias, os artigos mais recentes concentram a leitura.
Essa distribuição de acesso segue o que a literatura de sistemas chama de distribuição de lei de potência. Vilfredo Pareto, economista italiano do século XIX, observou que 80% da riqueza da Itália pertencia a 20% da população. Essa regularidade, chamada de distribuição de Pareto, se mostrou consistente em domínios tão distintos que passou a funcionar como heurística de projeto em sistemas computacionais. Breslau et al. documentaram em 1999, no trabalho Web Caching and Zipf-like Distributions: Evidence and Implications, que padrões de acesso web seguem distribuições do tipo Zipf, onde a probabilidade de acesso ao n-ésimo item mais popular é inversamente proporcional a n (P(n) ∝ 1/n^s), com o expoente s variando entre 0.6 e 1.0 dependendo do domínio. Na prática, isso significa que o item mais popular é acessado aproximadamente o dobro do segundo mais popular, o triplo do terceiro, e assim por diante.
A consequência prática dessa distribuição é que o banco de dados pode receber a mesma query, com os mesmos parâmetros, dezenas ou centenas de vezes por segundo. Cada execução percorre o mesmo caminho de análise do SQL, busca dos dados, montagem do resultado e devolução ao cliente. O resultado é idêntico em todas as execuções, mas o custo é pago integralmente a cada vez. Esse é o desperdício computacional que o cache se propõe a eliminar.
O que é cache e por que ele funciona
Cache é o ato de guardar o resultado de uma operação custosa para reutilizá-lo quando o mesmo pedido aparecer novamente. A ideia é simples, mas sua eficácia depende de uma propriedade observável nos sistemas computacionais, a localidade de referência.
"Um programa durante qualquer intervalo de tempo t usa apenas uma fração ativa de suas páginas. Chamamos esse conjunto de páginas de working set do programa."
— Peter Denning, The Working Set Model for Program Behavior, Communications of the ACM, 1968
Denning observou que programas tendem a acessar repetidamente o mesmo conjunto de dados em um dado intervalo de tempo, fenômeno que chamou de localidade temporal. Além disso, o acesso a um dado tende a ser seguido pelo acesso a dados adjacentes, fenômeno chamado de localidade espacial. Essas duas propriedades são o que torna o cache viável. Se um dado foi pedido agora, há boa probabilidade de que seja pedido de novo em breve. Guardá-lo em um lugar de acesso mais rápido, portanto, tem alto valor esperado.
Na prática de sistemas web, o cache mais comum para esse tipo de problema é um servidor de armazenamento em memória como o Redis. Redis é um armazenamento de chave-valor projetado para acesso de baixíssima latência. Enquanto o banco de dados relacional executa uma query com overhead de parsing, planejamento, acesso a disco e gerenciamento de transações, o Redis devolve um valor armazenado em memória em menos de 1 milissegundo. A diferença de latência entre os dois é de duas a três ordens de grandeza para os casos em que os dados já estão em cache.
O padrão de uso mais comum é o cache-aside, também chamado de lazy loading. Nele, a aplicação consulta o cache primeiro. Se o dado estiver lá, o resultado é usado diretamente, sem nenhuma chamada ao banco. Isso é chamado de cache hit. Se o dado não estiver no cache, a aplicação consulta o banco, armazena o resultado no cache com um tempo de expiração definido, e devolve o resultado ao cliente. Isso é chamado de cache miss. A partir do segundo acesso ao mesmo dado, dentro do período de expiração, o banco não é mais envolvido.
sequenceDiagram
participant C as Cliente
participant A as Aplicação
participant R as Redis
participant B as Banco de Dados
C->>A: Requisição
A->>R: Consulta cache
alt Cache Hit
R-->>A: Retorna dado
A-->>C: Resposta (< 1ms)
else Cache Miss
R-->>A: Dado não encontrado
A->>B: Consulta banco
B-->>A: Retorna dado
A->>R: Armazena no cache com TTL
A-->>C: Resposta
endA visão de componentes mostra onde cada peça reside e como o Redis se posiciona como camada intermediária entre a aplicação e o banco.
graph LR
subgraph clientes [Clientes]
C1([" "]) & C2([" "]) & C3([" "])
end
A[Aplicação]
subgraph camada_cache [Camada de Cache]
R[(Redis)]
end
subgraph banco [Banco de Dados]
BP[Buffer Pool]
PG[(PostgreSQL)]
BP <--> PG
end
C1 & C2 & C3 --> A
A -- "verifica cache" --> R
R -- "cache hit" --> A
A -- "cache miss" --> PGO impacto desse padrão em um cenário de degradação é significativo. Se 80% das requisições ao banco de dados passam a ser atendidas pelo cache, o volume de queries que chega ao banco cai para 20% do original. A CPU do banco se recupera. O buffer pool para de sofrer pressão. As conexões se liberam. A latência cai. O sistema volta a responder dentro de parâmetros aceitáveis.
O que o cache resolve e o que ele não resolve
Esse é o ponto onde a conversa sobre cache precisa ir além do incidente, porque a recuperação imediata do sistema pode criar uma impressão equivocada de que o problema foi resolvido. O cache resolveu o sintoma, mas o problema estrutural continua existindo por baixo.
O problema estrutural, nesse cenário, é que o sistema foi projetado sem considerar que o volume de requisições poderia crescer além de uma certa faixa. Quando o volume cresceu, o banco de dados se tornou o gargalo porque a arquitetura colocava todas as leituras diretamente nele, sem nenhuma camada de absorção. O cache adicionado em resposta à crise cobre esse gargalo, mas a arquitetura ainda tem o mesmo ponto vulnerável. Se o cache falhar, se o Redis ficar indisponível por qualquer motivo, todas as requisições voltam direto ao banco, e o problema original reaparece imediatamente.
Ao adicionar o Redis como camada de cache, a arquitetura passou a ter uma nova dependência crítica. O Redis se tornou um componente no caminho de cada requisição, e sua disponibilidade passou a ser tão importante quanto a disponibilidade do banco de dados. Esse é um ponto que costuma ser subestimado. O Redis precisa ser monitorado, mantido, atualizado e ter sua capacidade gerenciada ao longo do tempo. Conforme o volume de dados cresce, ele precisa de mais memória. Conforme o tráfego cresce, pode ser necessário adicionar instâncias ou configurar replicação. Em ambientes de nuvem, esse conjunto de necessidades se traduz em uma linha não trivial no orçamento de infraestrutura, com custo que cresce em proporção direta à memória alocada e ao número de instâncias. Adicionar Redis às pressas em resposta a um incidente é compreensível, mas o custo de operá-lo de forma adequada precisa ser planejado e justificado.
Esse conjunto de consequências é o que a literatura de arquitetura de software chama de custo de complexidade acidental. Fred Brooks, em seu ensaio No Silver Bullet: Essence and Accidents of Software Engineering, publicado em 1987 no IEEE Computer, distingue a complexidade essencial de um sistema, que decorre do problema que ele resolve, da complexidade acidental, que decorre das decisões de implementação. Adicionar Redis resolve um problema, mas traz consigo complexidade acidental na forma de mais um componente para operar, mais uma dependência para gerenciar, mais uma fonte de falha para monitorar e mais um custo para justificar.
"A complexidade acidental é aquela que não é inerente ao problema, mas que aparece nas soluções que construímos hoje."
— Fred Brooks, No Silver Bullet: Essence and Accidents of Software Engineering, IEEE Computer, 1987
O que a recuperação via cache revela sobre a arquitetura anterior
Quando o cache resolve um problema de degradação sob carga, ele está revelando ao mesmo tempo algo sobre como o sistema foi construído. Em geral, sistemas que colapsam sob picos de tráfego por falta de cache foram projetados com a suposição implícita de que o volume de requisições ficaria dentro de uma faixa previsível e de que o banco de dados seria capaz de responder a todas elas diretamente.
Essa suposição não é necessariamente um erro de projeto. Em sistemas novos, com usuários limitados e orçamento restrito, fazer queries diretas ao banco para tudo é simples, correto, e suficiente. O problema aparece quando o sistema cresce além do ponto onde essa simplicidade continua sendo viável, e nenhuma camada de cache havia sido planejada para essa transição.
O que o incidente revela é que o sistema cruzou um limiar de escala para o qual não estava preparado. A arquitetura tinha um único ponto de pressão, e qualquer volume acima desse limiar o saturava. Essa constatação abre um território de decisões que vai além de adicionar Redis.
Há camadas distintas onde o problema pode estar. Na camada dos dados, a modelagem das tabelas e a forma como os relacionamentos foram estruturados determinam o custo de cada operação antes de qualquer cache entrar em cena. Na camada da arquitetura do projeto, o problema pode estar no código que acessa o banco. Chamadas N+1, por exemplo, surgem quando uma operação que deveria resultar em uma única query se transforma em dezenas ou centenas, consumindo conexões e CPU de forma que o cache não consegue endereçar diretamente. Na camada de solução, o sistema pode ser reorganizado para separar os caminhos de leitura dos de escrita, ou para distribuir os dados entre múltiplas instâncias em vez de concentrá-los em uma só.
O risco está em escolher a resposta errada para o problema que existe. Tratar um problema de modelagem com cache é adiar uma conta que vai crescer. Reorganizar toda a arquitetura para um volume que ainda não existe é gastar antes da hora. O que orienta essa escolha é entender em qual camada o problema está e quanta mudança o contexto atual consegue absorver, considerando equipe, orçamento e ritmo de crescimento.
O cache comprou tempo. O trabalho de construir um sistema que escale com confiança ainda está pela frente.
O que vem a seguir
Este capítulo cobriu o problema de degradação sob carga e apresentou o cache como a intervenção mais comum e mais imediata. Mas cache é um tema extenso o suficiente para ocupar um livro inteiro, porque cada aspecto dele, quando examinado de perto, revela trade-offs e decisões arquiteturais que importam.
Nos próximos capítulos, o caminho parte dos princípios computacionais que explicam por que o cache funciona, desde a hierarquia de memória dos processadores até as redes de distribuição de conteúdo em escala global. Depois, os padrões arquiteturais que determinam como o cache se integra ao banco de dados, aos serviços ao redor, e aos usuários finais. Em seguida, os custos estruturais, operacionais e cognitivos que o cache introduz, com atenção especial ao problema de invalidação, que é reconhecidamente um dos mais difíceis de resolver bem em sistemas distribuídos.
"Existem apenas dois problemas difíceis em ciência da computação: invalidação de cache e dar nomes às coisas."
— Phil Karlton, citado por Martin Fowler em TwoHardThings, martinfowler.com