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
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.
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
- Inclua o escopo (tenant, usuário, idioma) na chave e marque respostas personalizadas com Cache-Control: private ou no-store.
- Combine invalidação por evento com um TTL de segurança, e não dependa de um só mecanismo.
- Aplique stale-while-revalidate a dados que toleram segundos de atraso e coalescing a toda chave cara de recomputar.
- Versione as chaves quando a forma do valor serializado mudar.
- Meça hit ratio, latência de origem e tamanho das entradas antes de ampliar o cache.
- 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
- IETF, RFC 9111: HTTP Caching. https://www.rfc-editor.org/rfc/rfc9111.html
- IETF, RFC 5861: HTTP Cache-Control Extensions for Stale Content. https://www.rfc-editor.org/rfc/rfc5861.html
- MDN Web Docs, HTTP caching. https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Caching
- Vercel, Caching in Next.js (documentação da versão 14). https://nextjs.org/docs/14/app/building-your-application/caching
- Vercel, revalidateTag (referência da API). https://nextjs.org/docs/app/api-reference/functions/revalidateTag
Assuntos: Caching, Next.js, Redis, Web Performance, Software Architecture
Continue lendo
- Entrega progressiva: feature flags, canary e blue-green — Como separar deploy de release com flags avaliadas por hash determinístico, escolher entre blue-green e canary, e deixar métricas de guarda decidirem a promoção ou o rollback com Argo Rollouts.
- Semântica de entrega em sistemas de mensageria: at-least-once, idempotência e dead-letter queues — Por que o broker entrega mais de uma vez, como o consumidor converte repetição em efeito único com uma tabela de mensagens processadas na mesma transação, e o que fazer com a mensagem que falha sempre.
- Rate limiting distribuído: algoritmos, implementação atômica e políticas de falha — Como escolher entre janela fixa, janela deslizante, token bucket e leaky bucket, manter o contador correto com várias réplicas usando um script Lua no Redis, responder 429 com os cabeçalhos RateLimit e decidir o que fazer quando o Redis cai.
Quer aplicar isto no seu sistema? Fale com a MFactor: contato ou WhatsApp +55 (19) 99778-9831.