Estratégias de cache explicadas: cache-aside, read-through, write-through e write-back

Quatro estratégias de cache lado a lado: quem preenche o cache, quem grava no banco, o que pode ficar desatualizado e como mostrar cada uma em um diagrama.

· Fundador e desenvolvedor

Publicado em

Estratégias de cache explicadas: cache-aside, read-through, write-through e write-back

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:

  1. 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.
  2. 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 5000 só 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.

Quatro linhas, cada uma com uma API de produtos, um cache e um banco Postgres. No cache-aside, a API lê o cache, depois o banco e então preenche o cache. No read-through e no write-through, a camada de cache fala com o banco. No write-back, o cache grava no banco depois.

20 componentes · 9 conexões

Abrir no Archboard
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
As quatro estratégias deste guia para uma API de produtos: quem lê e grava o banco de dados em cada uma, o que o app espera e o que dá errado quando o cache cai ou fica para trás.

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

  1. Entre com Google ou GitHub e crie um diagrama.
  2. 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".
  3. 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.
  4. Deixe tracejada a conexão de descarregamento do write-back.
  5. 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.

Comece grátis — um assento, sem cartão de crédito.

Artigos relacionados

← Voltar ao blog

Abra um canvas em um minuto

Entre com Google ou GitHub. Grátis para um assento. Faça upgrade quando a equipe entrar.