Blog

Estratégias de cache e invalidação em sistemas web

Como escolher entre cache-aside, read-through e write-behind, decidir entre TTL e invalidação por evento, conter o thundering herd com stale-while-revalidate e coalescing em Redis, e usar as camadas de cache do Next.js com revalidateTag.

Por Alexandre Martins · · 7 min de leitura

Camadas de cache entre o navegador e o banco. Fluxo de uma requisição atravessando o cache HTTP do navegador, a CDN, o cache da aplicação em Redis e o buffer do banco, com o ponto de invalidação de cada camada.

Todo cache é uma cópia, e toda cópia pode divergir do original. Guardar o resultado de uma consulta em memória ou em uma CDN troca carga na origem por um risco: servir um dado que já mudou. A pergunta de projeto não é se o cache vai ficar desatualizado, e sim por quanto tempo isso é aceitável e como o sistema volta a convergir.

Há ainda o momento em que o cache falta. Quando uma chave muito acessada expira, todas as requisições daquele instante vão à origem, e o banco recebe de uma vez a carga que o cache absorvia.

Como estruturar as camadas de cache de um sistema web de modo que a desatualização seja limitada, a invalidação seja previsível e a expiração de uma chave não derrube a origem?

Taxonomia: quem escreve no cache e quando

As estratégias se distinguem por quem preenche o cache e por quando a escrita na origem acontece.

No cache-aside, a aplicação consulta o cache e, em caso de falta, busca na origem e grava o resultado. A origem continua sendo a fonte da verdade, e o cache pode ser esvaziado sem perda de dados. É o padrão mais comum com Redis e Memcached. No read-through, o provedor de cache faz a busca em nome da aplicação.

No write-through, toda escrita passa pelo cache e é propagada de forma síncrona à origem, ao custo de latência em cada escrita. No write-behind, a escrita é confirmada no cache e propagada em lote. É o padrão mais rápido para escrita e o de maior risco: uma falha do cache antes da propagação perde dados.

Expiração por tempo e invalidação por evento

Um valor em cache deixa de ser válido por duas razões: passou tempo demais ou o original mudou. A expiração por TTL (time to live) atribui a cada entrada um prazo e limita o pior caso ao valor do TTL. Em troca, expira valores ainda válidos e mantém valores que já mudaram.

A invalidação por evento apaga a entrada quando a origem muda: a escrita no banco publica um evento, e o consumidor remove a chave. Ela é precisa, mas um evento perdido deixa o dado antigo até o próximo deploy. A prática consolidada combina as duas: evento para a convergência rápida e TTL longo como rede de segurança.

A chave versionada dispensa a invalidação. Em vez de sobrescrever produto:42, a aplicação grava produto:42:v7 e guarda a versão atual no próprio registro. Uma mudança incrementa a versão, e a chave antiga deixa de ser lida até expirar.

A RFC 5861 define a extensão stale-while-revalidate para o cabeçalho Cache-Control. Um valor vencido pode ser servido por um período adicional enquanto o cache busca a versão nova em segundo plano, e o cliente nunca espera pela origem.

Camadas: do navegador ao banco

O cache HTTP, especificado na RFC 9111, é a primeira camada. Cache-Control define por quanto tempo uma resposta é fresca (max-age), se pode ficar em caches compartilhados (public ou private) e se exige revalidação antes do reuso (no-cache). O ETag identifica a versão da representação: o cliente envia If-None-Match, e o servidor responde 304 Not Modified quando nada mudou.

Seguem a CDN, que aceita purga por URL ou por tag, o cache da aplicação em Redis, que aceita DEL, e o buffer do banco. O navegador não pode ser purgado pelo servidor: só expira ou é contornado por uma URL nova. Para cada dado, é preciso saber em quais camadas ele pode estar.

Problemas clássicos

O thundering herd, também chamado de cache stampede, ocorre quando uma chave popular expira e N requisições simultâneas encontram o cache vazio. Todas vão à origem e calculam o mesmo valor, o que basta para esgotar o pool de conexões do banco. A resposta é o request coalescing: uma requisição busca a origem, e as demais esperam pelo resultado ou recebem o valor antigo. O lock pode ser um Map de promessas pendentes no processo ou um SET NX no Redis. Um deploy que reinicia todas as instâncias produz o mesmo efeito em todas as chaves, e o aquecimento prévio das mais acessadas reduz o impacto.

Instâncias com cache em memória divergem quando uma invalidação não chega a todas. Um cache compartilhado resolve, ao custo de uma viagem de rede.

O vazamento de dado entre usuários é a falha mais grave. Uma resposta personalizada gravada com uma chave sem o identificador do usuário, ou com Cache-Control: public indevido, é servida ao próximo usuário que pedir a mesma URL.

Exemplo em TypeScript com Redis

A função a seguir implementa cache-aside com stale-while-revalidate e coalescing distribuído. Cada entrada guarda o valor e o instante em que deixa de ser fresca, e o TTL físico da chave é a soma das janelas fresca e velha.

Leitura com stale-while-revalidate e coalescing. Fluxograma da função comCache: entrada fresca devolve direto, entrada velha devolve e revalida em segundo plano sob lock, falta total faz uma réplica buscar na origem enquanto as outras esperam.
Leitura com stale-while-revalidate e coalescing. Fluxograma da função comCache: entrada fresca devolve direto, entrada velha devolve e revalida em segundo plano sob lock, falta total faz uma réplica buscar na origem enquanto as outras esperam.
Ver o fluxograma em texto (Mermaid)
flowchart TD
  req["Requisição pede a chave"] --> existe{"Entrada no Redis?"}
  existe -- "sim" --> fresca{"Ainda fresca?"}
  fresca -- "sim" --> devolve["Devolve o valor"]
  fresca -- "não" --> lock1{"Ganhou o SET NX\ndo lock?"}
  lock1 -- "sim" --> fundo["Revalida em segundo plano"]
  lock1 -- "não" --> velho["Devolve o valor velho"]
  fundo --> velho
  fundo -.-> grava["Grava valor novo com TTL fresco + stale"]
  existe -- "não" --> lock2{"Ganhou o SET NX\ndo lock?"}
  lock2 -- "sim" --> origem["Busca na origem e grava"]
  origem --> devolve
  lock2 -- "não" --> espera["Espera 50 ms e relê a chave"]
  espera --> existe
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

interface Entrada<T> { valor: T; frescoAte: number }   // frescoAte em epoch ms

interface OpcoesCache { ttlFresco: number; ttlStale: number; ttlLock: number }   // em segundos

export async function comCache<T>(
  chave: string, carregar: () => Promise<T>, opcoes: OpcoesCache,
): Promise<T> {
  const bruto = await redis.get(chave);

  if (bruto !== null) {
    const entrada = JSON.parse(bruto) as Entrada<T>;
    if (Date.now() < entrada.frescoAte) return entrada.valor;     // fresco: devolve
    void revalidar(chave, carregar, opcoes).catch(console.error); // velho: revalida ao fundo
    return entrada.valor;
  }

  return buscarComCoalescing(chave, carregar, opcoes);            // falta total
}

async function revalidar<T>(
  chave: string, carregar: () => Promise<T>, o: OpcoesCache,
): Promise<{ carregou: true; valor: T } | { carregou: false }> {
  // SET NX: só uma réplica vai à origem
  const ganhou = await redis.set(`${chave}:lock`, "1", "EX", o.ttlLock, "NX");
  if (ganhou !== "OK") return { carregou: false };
  try {
    const valor = await carregar();
    const entrada: Entrada<T> = { valor, frescoAte: Date.now() + o.ttlFresco * 1000 };
    await redis.set(chave, JSON.stringify(entrada), "EX", o.ttlFresco + o.ttlStale);
    return { carregou: true, valor };
  } finally {
    await redis.del(`${chave}:lock`);
  }
}

Quando a chave não existe, nenhuma réplica tem um valor velho para servir. As requisições que perderam o lock esperam pelo resultado da vencedora, com um limite de tentativas.

const ESPERA_MS = 50;
const MAX_TENTATIVAS = 40;   // 2 s no total

async function buscarComCoalescing<T>(
  chave: string, carregar: () => Promise<T>, o: OpcoesCache,
): Promise<T> {
  for (let tentativa = 0; tentativa < MAX_TENTATIVAS; tentativa++) {
    const resultado = await revalidar(chave, carregar, o);
    if (resultado.carregou) return resultado.valor;        // esta réplica foi à origem

    await new Promise((r) => setTimeout(r, ESPERA_MS));     // outra réplica está carregando
    const bruto = await redis.get(chave);
    if (bruto !== null) return (JSON.parse(bruto) as Entrada<T>).valor;
  }
  return carregar();   // a origem demorou mais que a espera máxima: busca direto
}

// Uso: catálogo fresco por 60 s e servível velho por mais 300 s
const categorias = () => comCache("catalogo:categorias", repositorio.listarCategorias,
  { ttlFresco: 60, ttlStale: 300, ttlLock: 10 });

Dentro de uma instância, um Map de promessas em andamento poupa até a viagem ao Redis: o Map protege o processo, o SET NX protege a origem.

O caso do Next.js

O App Router empilha quatro mecanismos, descritos na documentação da versão 14. A request memoization deduplica chamadas idênticas a fetch durante uma renderização. O Data Cache persiste o resultado de fetch entre requisições e deploys. O Full Route Cache guarda o HTML e o payload de React Server Components das rotas estáticas. O Router Cache, no navegador, guarda o payload das rotas visitadas. A partir da versão 15, fetch deixou de ser cacheado por padrão, e a versão 16 trouxe a diretiva use cache com cacheLife e cacheTag.

A invalidação sob demanda usa tags. Uma chamada a fetch recebe next.tags, e revalidateTag marca as entradas com aquela tag como velhas. Com o segundo argumento max, a próxima leitura recebe o dado antigo e dispara a revalidação em segundo plano: stale-while-revalidate aplicado ao Data Cache. Quando o usuário precisa ver a própria escrita, updateTag, disponível só em Server Actions, expira a entrada de imediato.

// app/lib/catalogo.ts
export async function listarProdutos(): Promise<Produto[]> {
  const res = await fetch("https://api.exemplo.com/produtos", {
    next: { tags: ["produtos"], revalidate: 3600 },   // Data Cache com tag e TTL de 1 h
  });
  if (!res.ok) throw new Error(`Falha ao listar produtos: ${res.status}`);
  return res.json();
}

// app/api/revalidar/route.ts: webhook do sistema de catálogo
import type { NextRequest } from "next/server";
import { revalidateTag } from "next/cache";

export async function POST(req: NextRequest) {
  if (!webhookAutenticado(req)) return new Response(null, { status: 401 });
  const { tag } = (await req.json()) as { tag: string };
  revalidateTag(tag, "max");   // serve o antigo enquanto revalida ao fundo
  return Response.json({ revalidado: true, em: Date.now() });
}

Em uma Server Action, a invalidação também limpa o Router Cache do cliente que disparou a ação. Em um Route Handler não há rota associada, e o Router Cache continua servindo o payload antigo até expirar.

Desafios

A corrida entre leitura e escrita no cache-aside produz um valor antigo que parece válido. Uma requisição A lê o registro no banco, uma requisição B escreve o registro novo e apaga a chave, e só então A grava no cache o valor que leu. A mitigação é um TTL curto para dados voláteis ou a chave versionada, que torna a gravação atrasada inofensiva.

A serialização tem custo. JSON não preserva Date, BigInt nem Map, e um valor de centenas de kilobytes transforma cada hit em uma transferência comparável à consulta que ele evita. A regra prática é cachear o menor dado que ainda poupe a origem.

Um produto aparece na página dele, nas listas de categoria e na busca. Invalidar por chave exige conhecer todas essas derivações, e tags resolvem isso ao custo de um índice reverso.

Boas práticas

  1. Inclua o escopo (tenant, usuário, idioma) na chave e marque respostas personalizadas com Cache-Control: private ou no-store.
  2. Combine invalidação por evento com um TTL de segurança, e não dependa de um só mecanismo.
  3. Aplique stale-while-revalidate a dados que toleram segundos de atraso e coalescing a toda chave cara de recomputar.
  4. Versione as chaves quando a forma do valor serializado mudar.
  5. Meça hit ratio, latência de origem e tamanho das entradas antes de ampliar o cache.
  6. Trate a indisponibilidade do cache como degradação: a aplicação deve conseguir ir à origem, com limite de concorrência.

Conclusão

Cache é uma decisão sobre consistência disfarçada de decisão sobre desempenho. Cada camada compra latência menor com uma janela em que o dado servido pode não ser o real. O projeto consiste em tornar essa janela explícita: qual TTL, qual evento invalida, qual camada aceita purga.

Cache-aside com TTL e invalidação por evento cobre a maioria dos casos. Stale-while-revalidate elimina a espera pela origem, e o coalescing elimina a estampida quando a chave falta. Com esses elementos e uma chave que inclui o escopo, o cache deixa de ser fonte de incidentes.

Referências

Assuntos: Caching, Next.js, Redis, Web Performance, Software Architecture

Continue lendo

Quer aplicar isto no seu sistema? Fale com a MFactor: contato ou WhatsApp +55 (19) 99778-9831.