Na maioria dos diagramas de arquitetura, um cache é uma caixa entre um serviço e o banco de dados dele. Essa caixa esconde as perguntas que decidem se o cache ajuda ou atrapalha: quem coloca dados nele, quem grava no banco e o que um leitor vê no meio do caminho.
Quatro estratégias respondem a essas perguntas de formas diferentes: cache-aside, read-through, write-through e write-back. Este guia passa por cada uma com o mesmo exemplo, compara as quatro, cobre as falhas que aparecem em produção e termina com um diagrama que você pode reutilizar.
Duas perguntas que todo cache responde
Toda estratégia de cache é um par de respostas:
- Quem carrega os dados no cache em uma leitura? O código da sua aplicação ou uma camada de cache que sabe chegar ao banco.
- Quem grava no banco, e quando? A sua aplicação, a camada de cache na mesma requisição ou a camada de cache mais tarde.
| Estratégia | Leitura: quem preenche o cache | Escrita: quem grava no banco |
|---|---|---|
| Cache-aside | A aplicação, depois de um miss | A aplicação, que depois invalida a chave |
| Read-through | A camada de cache, depois de um miss | Não é definido pela estratégia; costuma ser combinado com uma das duas seguintes |
| Write-through | Costuma ser combinado com read-through ou cache-aside | A camada de cache, na mesma requisição |
| Write-back (write-behind) | Costuma ser combinado com read-through | A camada de cache, mais tarde e muitas vezes em lotes |
O exemplo usado do começo ao fim é uma API de produtos com uma tabela products no Postgres e um cache Redis. Uma página de produto lê product:42, e uma ferramenta de administração atualiza preços.
Cache-aside (lazy loading)
A aplicação conversa com os dois armazenamentos. Ela consulta primeiro o cache; em um miss, lê o banco e coloca o resultado no cache com um tempo de vida (TTL). A documentação do ElastiCache da AWS descreve o mesmo padrão como lazy loading.
def get_product(product_id):
key = f"product:{product_id}"
cached = redis.get(key)
if cached is not None:
return json.loads(cached) # hit
row = db.fetch_one("SELECT * FROM products WHERE id = %s", product_id)
if row is not None:
redis.set(key, json.dumps(row), ex=300) # fill, with a 300 s TTL
return row
def update_price(product_id, price):
db.execute("UPDATE products SET price = %s WHERE id = %s", price, product_id)
redis.delete(f"product:{product_id}") # invalidate, don't overwrite
Nas escritas, apague a chave em vez de gravar o novo valor no cache. A próxima leitura carrega a linha atual. O padrão cache-aside da Microsoft descreve a mesma regra de escrita.
O que você ganha: só ficam em cache os dados que alguém pediu e, se o cache cair, as leituras continuam funcionando no banco, só que mais devagar. O que você paga: cada miss custa três idas e voltas (cache, banco, cache), e existe uma condição de corrida. Um leitor que teve miss pode ler a linha antiga, o escritor atualiza a linha e apaga a chave, e então o leitor grava de volta o valor antigo. Ele fica desatualizado até o TTL acabar, um dos motivos pelos quais o cache-aside sempre precisa de TTL.
Read-through
A aplicação só chama o cache. Em um miss, a camada de cache carrega o valor do banco por meio de um loader que você configura, guarda o valor e o devolve. O resultado da leitura é o mesmo do cache-aside, mas o código de carga fica em um só lugar, e não em cada chamador.
Um servidor Redis comum não lê do seu banco sozinho. O read-through vem de uma camada que sabe fazer isso: uma biblioteca de cache com uma função de carga, um proxy ou um cache gerenciado feito para um banco específico. O Amazon DynamoDB Accelerator (DAX) é um exemplo: em um miss, ele repassa a leitura ao DynamoDB, devolve o resultado e o coloca em cache.
A contrapartida: a camada de cache passa a ficar no caminho de leitura. Se ela falhar, as leituras falham, a menos que o cliente tenha um plano B direto no banco.
Write-through
A aplicação grava na camada de cache, e a camada grava no banco na mesma operação. A aplicação só recebe sucesso quando as duas gravações dão certo. O DAX documenta exatamente isso: os dados são gravados primeiro na tabela do DynamoDB, depois no cache, e a chamada só tem sucesso se as duas gravações tiverem.
O que você ganha: dados gravados pelo cache estão atualizados nele, então uma leitura logo depois da escrita já os vê. O que você paga: toda escrita espera por dois armazenamentos, e o cache se enche de dados que são gravados mas nunca lidos. Adicione um TTL para que eles expirem. Um nó de cache novo e vazio também não tem nada até os dados serem gravados de novo; é por isso que o write-through costuma ser combinado com read-through ou lazy loading.
Write-back (write-behind)
A aplicação grava no cache e recebe sucesso na hora. A camada de cache grava no banco mais tarde, muitas vezes em lotes, e atualizações repetidas da mesma chave viram uma única gravação no banco.
O que você ganha: a menor latência de escrita e muito menos gravações no banco durante picos. O que você paga: tudo o que ainda não foi descarregado se perde se o nó de cache cair, a menos que o buffer seja replicado ou apoiado por um log durável. O banco também fica atrás do cache, então relatórios, outros serviços e backups veem dados mais antigos. O write-back combina com contadores de visualização e dados parecidos, em que perder alguns segundos é aceitável.
Lado a lado
| Cache-aside | Read-through | Write-through | Write-back | |
|---|---|---|---|---|
| O código do app conhece o banco | Sim | Não | Não | Não |
| Latência de escrita | Gravação no banco mais uma remoção no cache | Depende da estratégia de escrita | Cache e banco | Só o cache |
| Leituras desatualizadas | Até a invalidação ou o TTL | Até a invalidação ou o TTL | Raras para escritas que passam pelo cache | O banco fica atrás do cache |
| Perda de um nó de cache | Leituras mais lentas | Leituras falham sem plano B | Dados ausentes até serem regravados | Escritas não descarregadas se perdem |
| Uso típico | O padrão com Redis | Uma biblioteca ou um cache gerenciado | Dado atualizado logo após a escrita | Contadores com muitas escritas e picos |
TTLs, despejo e três falhas clássicas
Um TTL limita o quanto um valor pode ficar desatualizado. Escolha um por tipo de dado, de acordo com o quanto o produto tolera: a descrição de um produto pode viver por minutos; o nível de estoque, talvez segundos. Quando uma chave expirada é lida, ela se comporta como se não existisse.
O despejo (eviction) é outro mecanismo: o que o cache remove quando a memória acaba. No Redis, maxmemory define o limite e maxmemory-policy escolhe o que remover. O padrão no redis.conf é noeviction, que devolve erros para comandos que adicionam dados quando a memória está cheia. Para um cache puro, a documentação do Redis sugere allkeys-lru como um bom padrão. Serviços gerenciados definem seus próprios padrões, então confira o seu. Meça a taxa de acerto a partir de INFO stats como keyspace_hits / (keyspace_hits + keyspace_misses).
Três falhas aparecem em quase todo cache:
- Stampede. Uma chave muito acessada expira e centenas de requisições têm miss ao mesmo tempo, todas consultando o banco. Deixe uma requisição reconstruir o valor e faça as outras esperarem um pouco ou servirem o valor antigo. Um lock no Redis resolve:
SET lock:product:42 <token> NX PX 5000só tem sucesso para um chamador. O artigo do Facebook sobre memcache resolve o mesmo problema com leases e relata que, nas chaves sujeitas a isso, o pico de consultas ao banco caiu de 17 mil para 1,3 mil por segundo. - Penetração. Requisições para IDs que não existem sempre têm miss e sempre chegam ao banco. Coloque em cache o resultado "não encontrado" com um TTL curto, ou consulte antes um filtro de Bloom com os IDs válidos.
- Avalanche. Muitas chaves expiram juntas, por exemplo depois de uma carga em massa ou de um reinício do cache, e o banco recebe toda a carga de uma vez. Adicione uma variação aleatória aos TTLs, como 300 segundos mais até 60, e aqueça o cache aos poucos.
O diagrama
Quatro linhas com os mesmos três participantes, então a diferença está nas conexões: quem fala com o banco, e quando.
Conteúdo do diagrama
- Product API
- Redis
- Postgres
- Cache layer
- 1 GET (miss)
- 2 SELECT
- 3 SET, TTL 300 s
- 1 read
- 2 on miss: SELECT
- 1 write
- 2 UPDATE, same request
- 1 write, OK at once
- 2 flush later, batched
Como ler:
- Linha 1, cache-aside. A API de produtos lê o Redis (1), lê o Postgres por conta própria em um miss (2) e preenche o Redis com um TTL (3). A conexão com o Postgres contorna o cache: a aplicação é dona das duas chamadas.
- Linha 2, read-through. A API só fala com a camada de cache, que carrega do Postgres em um miss.
- Linha 3, write-through. A escrita passa pela camada de cache até o Postgres na mesma requisição.
- Linha 4, write-back. A API recebe o sucesso na hora. A conexão tracejada é o descarregamento posterior, em lotes.
Como desenhar caches em diagramas de arquitetura
- Dê nome à estratégia e ao TTL. "Cache-aside, TTL 300 s" no nó de cache ou na conexão responde à primeira pergunta de quem revisa.
- Desenhe quem fala com o banco. No cache-aside, o serviço tem uma conexão com o banco além da conexão com o cache. Um cache desenhado em linha na frente do banco sugere read-through, e os leitores vão supor isso.
- Desenhe o caminho de invalidação. Mostre quem apaga chaves, incluindo jobs em lote e ferramentas de administração que gravam direto no banco. Um escritor sem caminho até o cache é a origem dos dados desatualizados.
- Registre o comportamento em falhas. "Cache fora: leituras vão para o Postgres" diz mais ao plantão do que um segundo ícone de cache.
- Coloque os números no canvas. A meta de taxa de acerto, o limite de memória e a política de despejo cabem em uma nota pequena ao lado do cache.
- Deixe tracejado tudo o que é assíncrono. Um descarregamento de write-back ou um evento de invalidação não faz parte da requisição.
Como escolher
Comece com cache-aside e um TTL. Funciona com qualquer cache e qualquer banco e, se o cache falhar, as leituras só ficam mais lentas. Passe para read-through quando uma biblioteca ou um cache gerenciado oferecer um loader. Adicione write-through quando leituras logo após uma escrita precisarem ver o novo valor e as escritas aguentarem a latência extra. Use write-back só se você puder perder ou reprocessar escritas não descarregadas.
Para o contexto em volta do cache, veja o exemplo de diagrama de design de sistemas, em que o Redis atende o caminho de leitura de um encurtador de URL, e as regras do diagrama de arquitetura de microsserviços para mostrar qual serviço é dono de quais dados.
Monte no Archboard
- Entre com Google ou GitHub e crie um diagrama.
- Para cada estratégia, adicione um título Texto do pack Básico e coloque três nós em linha: um Contêiner do pack Modelo C4 para a API de produtos, um Redis do pack Dados para o cache e um Postgres do pack Dados para o banco. Nas linhas 2 a 4, rotule o cache como "Cache layer".
- Ligue cada linha com rótulos numerados: "1 GET (miss)", "2 SELECT" e "3 SET, TTL 300 s" para o cache-aside, e os passos de leitura ou escrita nas outras linhas. Faça a conexão do cache-aside entre a API e o Postgres passar por cima do cache.
- Deixe tracejada a conexão de descarregamento do write-back.
- Adicione embaixo de cada linha uma nota Texto do pack Básico com a regra de escrita e o que acontece quando o cache falha.
O exemplo usa 12 nós de diagrama e fica dentro do plano Free. Para o método geral por trás de qualquer diagrama de arquitetura, veja como desenhar um diagrama de arquitetura de software.
Perguntas frequentes
Cache-aside é o mesmo que lazy loading? Sim. Os dois nomes descrevem uma aplicação que só carrega dados no cache depois de um miss. A documentação da AWS usa "lazy loading"; muitas outras fontes dizem "cache-aside".
Devo atualizar ou apagar a chave do cache quando os dados mudam? No cache-aside, apague. Dois escritores que gravam cada um o seu novo valor no cache podem terminar na ordem errada e deixar um valor antigo para trás. Apagar e depois carregar de novo evita isso, e o TTL limita a condição de corrida mais rara descrita acima.
O Redis consegue fazer read-through ou write-through sozinho? Não. Um servidor Redis guarda o que os clientes enviam. Read-through e write-through precisam de uma camada que conheça o seu banco: uma biblioteca de cache, um proxy ou um cache gerenciado feito para um banco específico.
Qual TTL devo usar? O maior tempo de desatualização que o produto aceita para aquele tipo de dado. Depois adicione uma variação aleatória para que chaves carregadas juntas não expirem juntas.
Write-back é seguro? Só se perder ou reprocessar escritas não descarregadas for aceitável, ou se o buffer de escrita for replicado ou apoiado por um log durável. Evite para dados como pagamentos ou pedidos.