DevGuideTK
← Trilha Backend

Cache

Estratégias para guardar respostas caras, e o problema difícil de saber quando descartá-las.

Os exemplos guardam no Redis os dados de um produto, com o cliente de Redis mais usado em cada linguagem. A função buscarProdutoNoBanco representa a consulta ao banco.

Cache-aside com Redis

O roteiro é o mesmo nas cinco linguagens: procurar no cache; se não achar, buscar no banco e guardar com um TTL que tem uma pequena variação aleatória.

import { createClient } from "redis";

const redis = await createClient({ url: process.env.REDIS_URL }).connect();

const TTL_SEGUNDOS = 300;

export async function buscarProduto(id: number) {
  const chave = `produto:${id}`;

  const emCache = await redis.get(chave);
  if (emCache) {
    return JSON.parse(emCache) as Produto;
  }

  const produto = await buscarProdutoNoBanco(id);
  if (produto) {
    const ttl = TTL_SEGUNDOS + Math.floor(Math.random() * 30);
    await redis.set(chave, JSON.stringify(produto), { EX: ttl });
  }
  return produto;
}

O que muda de uma linguagem para outra

Cliente de RedisComo sabe que a chave não existe
Noderedis (node-redis)get devolve null
Pythonredis-pyget devolve None
Gogo-redis v9O erro é redis.Nil, que precisa ser diferenciado de uma falha real
JavaJedisget devolve null
C#StackExchange.RedisRedisValue.HasValue é falso

O ponto que mais varia é o tratamento de falhas do Redis. Se o Redis cair, o exemplo em Go segue para o banco. Nos outros, uma exceção de conexão sobe e a requisição falha, mesmo com o banco funcionando. Em produção, envolva a leitura e a gravação no cache num tratamento de erro que registre a falha e continue pelo banco: o cache deve deixar o sistema mais rápido, e não mais frágil.

Para cache em memória do processo, cada linguagem tem uma biblioteca de referência: lru-cache no Node, cachetools ou functools.lru_cache em Python, ristretto em Go, Caffeine em Java e IMemoryCache ou HybridCache no .NET. O HybridCache, do .NET 9, combina memória e cache distribuído e já protege contra a manada.

Invalidar depois de alterar

Quando o produto muda, a cópia em cache é apagada depois que a alteração foi confirmada no banco:

export async function alterarPreco(id: number, novoPreco: number) {
  await db.transaction(async (tx) => {
    await tx.query("UPDATE produtos SET preco = $1 WHERE id = $2", [novoPreco, id]);
  });

  // Depois do COMMIT: a próxima leitura busca o valor novo no banco
  await redis.del(`produto:${id}`);
}
OrdemO que pode dar errado
Apagar o cache antes do COMMITUma leitura no meio do caminho busca o valor antigo no banco e o grava de volta no cache
Apagar o cache depois do COMMITSe o del falhar, o valor antigo fica até o TTL vencer. Por isso o TTL existe
Gravar o novo valor no cache em vez de apagarDuas alterações simultâneas podem deixar no cache o valor mais antigo

Quando há muitos caminhos que alteram o mesmo dado, centralize a invalidação numa função única, ou publique um evento de alteração que um único consumidor transforma em invalidações.

Proteger as chaves mais lidas da manada

Para chaves que concentram muito tráfego, só uma requisição deve buscar na origem quando o cache estiver vazio. Uma forma simples, entre várias instâncias, é uma trava curta no próprio Redis com SET ... NX:

import { randomUUID } from "node:crypto";

export async function buscarComTrava(id: number) {
  const chave = `produto:${id}`;
  const chaveTrava = `trava:${chave}`;

  for (let tentativa = 0; tentativa < 50; tentativa++) {
    const emCache = await redis.get(chave);
    if (emCache) return JSON.parse(emCache) as Produto;

    // Só uma requisição consegue a trava; ela expira sozinha em 5 segundos
    const dono = randomUUID();
    const obteve = await redis.set(chaveTrava, dono, { NX: true, PX: 5000 });

    if (obteve) {
      try {
        const produto = await buscarProdutoNoBanco(id);
        if (produto) {
          await redis.set(chave, JSON.stringify(produto), {
            EX: 300 + Math.floor(Math.random() * 30),
          });
        }
        return produto;
      } finally {
        // Só apaga a trava se ainda for a dona dela
        if ((await redis.get(chaveTrava)) === dono) await redis.del(chaveTrava);
      }
    }

    // Outra requisição está buscando: espera um pouco e confere o cache de novo
    await new Promise((r) => setTimeout(r, 50));
  }

  return buscarProdutoNoBanco(id); // não esperar para sempre
}

Dentro de um único processo, a mesma ideia é mais simples: guardar a promessa ou a tarefa em andamento num mapa e fazer as outras requisições esperarem por ela. Go tem isso pronto no pacote golang.org/x/sync/singleflight.

Configurar o Redis como cache

# redis.conf, ou parâmetros do serviço gerenciado
maxmemory 2gb
maxmemory-policy allkeys-lfu

E acompanhe três números:

MétricaOnde verSinal de alerta
Taxa de acertokeyspace_hits e keyspace_misses em INFO statsAbaixo do esperado: TTL curto demais, chaves mal desenhadas ou dado que não deveria estar em cache
Itens descartadosevicted_keys em INFO statsCrescendo rápido: memória pequena para o volume
Memória usadaused_memory em INFO memoryPerto de maxmemory o tempo todo

Lista de revisão

Leitura

  • Todo item gravado tem TTL, com variação aleatória?
  • Uma falha do Redis faz o código seguir pelo banco, em vez de falhar a requisição?
  • A chave inclui tudo o que muda a resposta, inclusive o usuário?
  • As chaves mais lidas têm proteção contra a manada?

Escrita

  • Todo caminho que altera o dado invalida o cache?
  • A invalidação acontece depois do COMMIT?
  • O código apaga a chave, em vez de gravar o novo valor?

Operação

  • O Redis tem maxmemory e uma política de descarte configurados?
  • Dados que não podem ser descartados estão fora do Redis de cache?
  • A taxa de acerto e os itens descartados são monitorados?
  • Nenhuma decisão de transação, como saldo ou estoque na compra, depende de um valor em cache?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Implementar cache-aside passo a passo

    Quero implementar cache-aside com Redis do jeito certo, na minha linguagem.
    
    Pergunte qual linguagem, framework e cliente de Redis eu uso.
    
    Vamos em etapas. Em cada uma, deixe que eu tente e depois mostre a sua versão, comentando as diferenças:
    1. Subir um Redis com Docker e conectar a aplicação.
    2. A leitura com cache-aside, com TTL e variação aleatória.
    3. O tratamento de falhas do Redis, para que o sistema continue funcionando pelo banco.
    4. A invalidação depois da alteração, na ordem certa em relação ao COMMIT.
    5. A proteção contra a manada para a chave mais lida.
    6. Testes automatizados para acerto, falha, invalidação e Redis fora do ar.
    
    No final, me ajude a medir a taxa de acerto e o ganho de latência com uma carga simulada.
  • Cache em duas camadas

    Quero montar um cache em duas camadas: memória do processo na frente de um Redis.
    
    Pergunte qual linguagem eu uso e quantas instâncias da aplicação existem.
    
    Explique, com código:
    1. Qual biblioteca de cache em memória usar na minha linguagem e como configurá-la com tamanho máximo e TTL.
    2. A leitura que confere a memória, depois o Redis, depois o banco, e grava nas camadas que faltaram.
    3. Como escolher os TTLs de cada camada.
    4. Como invalidar a camada em memória de todas as instâncias quando o dado muda, usando publicação e assinatura no Redis.
    5. O que fazer quando a mensagem de invalidação se perde.
    
    Se a minha plataforma já oferece algo pronto para isso, como o HybridCache no .NET, mostre essa opção também.
  • Revisar o uso de cache

    Vou colar abaixo código que usa cache. Revise com foco em correção e segurança, e não só em desempenho.
    
    Verifique, nesta ordem, e para cada item diga se está correto, incorreto ou ausente, citando a linha:
    1. Todo item gravado tem TTL? Há variação aleatória?
    2. A chave inclui tudo o que muda a resposta, inclusive o usuário quando o dado é pessoal?
    3. Todo caminho que altera o dado invalida o cache, depois do COMMIT?
    4. O código apaga a chave ou grava o novo valor? Há risco de condição de corrida?
    5. Uma falha do cache derruba a requisição ou o código segue pela origem?
    6. As chaves mais lidas têm proteção contra a manada?
    7. Algum dado usado para decidir uma transação vem do cache?
    8. Algum dado só existe no cache?
    
    Para cada problema, descreva o cenário concreto em que ele causa um erro ou um vazamento, e mostre a correção. Não reescreva o que já está correto.
    
    Código:
    [cole aqui]