Home > Blog > Desenvolvimento Web
Desenvolvimento Web
JavaScript
Programação

Cache Stampede no Node.js

Atualizado em: 16 de setembro de 2026

Rack de servidores processando fluxos de dados no Node.js

O Cache Stampede no Node.js acontece quando uma chave popular expira e muitas requisições tentam recomputar o mesmo valor ao mesmo tempo. Em vez de proteger o banco ou a API externa, o cache provoca um pico repentino de carga justamente no momento da expiração.

Esse problema também é chamado de thundering herd ou dogpile effect. Ele aparece em aplicações com várias réplicas, tráfego concentrado e dados caros de gerar. A solução não depende de uma única técnica: locks por chave, stale-while-revalidate, jitter no TTL, revalidação antecipada e limites de concorrência podem ser combinados.

Neste guia, você aprenderá a detectar cache stampede, implementar request coalescing local e distribuído, servir conteúdo stale, aplicar TTL com jitter, fazer revalidação probabilística e evitar que o mecanismo de proteção crie uma nova dependência crítica.

Como o problema acontece?

Imagine uma página de produto que recebe mil requisições por segundo. O resultado fica no Redis por cinco minutos. Quando a chave expira, dezenas de réplicas consultam o banco simultaneamente:

  1. todas recebem cache miss;
  2. todas executam a mesma consulta;
  3. o banco fica sobrecarregado;
  4. a latência aumenta;
  5. timeouts causam retries;
  6. o pico se amplifica.

A análise da Cloudflare sobre cache stampede mostra como locks e revalidação probabilística reduzem requisições simultâneas à origem.

Cache-aside simples

async function getProduct(id) {
  const key = `product:${id}`;
  const cached = await redis.get(key);

  if (cached) {
    return JSON.parse(cached);
  }

  const product = await database.findProduct(id);
  await redis.set(key, JSON.stringify(product), {
    EX: 300
  });

  return product;
}

Esse padrão funciona em baixa concorrência, mas não coordena misses simultâneos.

Request coalescing em uma instância

Dentro de um único processo, compartilhe a Promise em andamento:

const inFlight = new Map();

async function coalesce(key, loader) {
  const existing = inFlight.get(key);
  if (existing) return existing;

  const promise = loader().finally(() => {
    inFlight.delete(key);
  });

  inFlight.set(key, promise);
  return promise;
}

Uso:

const product = await coalesce(key, async () => {
  const value = await database.findProduct(id);
  await redis.set(key, JSON.stringify(value), { EX: 300 });
  return value;
});

Isso resolve concorrência local, mas cada réplica ainda cria sua própria Promise.

Lock distribuído por chave

async function getWithLock(key, loader) {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const lock = await acquireLock(`cache:${key}`, 10_000);

  if (lock) {
    try {
      const secondCheck = await redis.get(key);
      if (secondCheck) return JSON.parse(secondCheck);

      const value = await loader();
      await redis.set(key, JSON.stringify(value), { EX: 300 });
      return value;
    } finally {
      await releaseLock(lock);
    }
  }

  return waitForCache(key);
}

O segundo check é obrigatório. Enquanto a instância aguardava o lock, outra pode ter preenchido a chave.

Veja Locks Distribuídos com Redis.

Esperando o vencedor

async function waitForCache(key) {
  for (let attempt = 0; attempt < 10; attempt++) {
    await sleep(50 + Math.random() * 100);
    const value = await redis.get(key);
    if (value) return JSON.parse(value);
  }

  throw new Error('Cache ainda indisponível');
}

Use limite e jitter. Polling infinito apenas troca sobrecarga do banco por sobrecarga do Redis.

Servindo valor stale

Uma estratégia melhor é manter o valor além do TTL lógico:

{
  "value": { "id": 42, "name": "Produto" },
  "freshUntil": 1789462800000,
  "staleUntil": 1789463100000
}

Durante o período stale, a aplicação entrega o valor antigo e apenas uma instância atualiza em segundo plano.

if (Date.now() < entry.freshUntil) {
  return entry.value;
}

if (Date.now() < entry.staleUntil) {
  triggerRefreshInBackground(key);
  return entry.value;
}

return refreshSynchronously(key);

Stale-while-revalidate

No HTTP, use stale-while-revalidate quando CDN e clientes suportam:

Cache-Control: public, max-age=60, stale-while-revalidate=300

Veja ETag e Cache HTTP no Node.js.

TTL com jitter

Se milhares de chaves são criadas juntas com o mesmo TTL, todas expiram juntas. Adicione variação:

function ttlWithJitter(baseSeconds, percent = 0.2) {
  const variation = baseSeconds * percent;
  const jitter = Math.random() * variation * 2 - variation;
  return Math.max(1, Math.round(baseSeconds + jitter));
}

await redis.set(key, value, {
  EX: ttlWithJitter(300)
});

O jitter espalha expirações, mas não resolve sozinho uma chave extremamente popular.

Revalidação antecipada

Atualize antes da expiração:

if (remainingTtlSeconds < 30) {
  refreshInBackground(key);
}

Sem coordenação, todas as requisições próximas do fim podem revalidar. Combine com lock, sampling ou probabilidade.

Revalidação probabilística

function shouldRefresh(remainingSeconds, windowSeconds = 300) {
  if (remainingSeconds > windowSeconds) return false;
  if (remainingSeconds <= 0) return true;

  const steepness = 1 / windowSeconds;
  return Math.random() < Math.exp(-steepness * remainingSeconds);
}

A probabilidade cresce à medida que a expiração se aproxima. Essa estratégia evita um serviço externo de lock, mas não garante exatamente uma revalidação.

Escolhendo a estratégia

  • Promise local: simples para uma instância.
  • Lock distribuído: controle determinístico entre réplicas.
  • Stale-while-revalidate: baixa latência e alta disponibilidade.
  • TTL com jitter: evita expiração sincronizada de muitas chaves.
  • Probabilística: reduz coordenação externa.
  • Pré-aquecimento: útil para catálogo conhecido.

Negative caching

Ausências também podem causar stampede. Armazene um marcador por pouco tempo:

const NOT_FOUND = JSON.stringify({ found: false });
await redis.set(key, NOT_FOUND, { EX: 30 });

Use TTL curto para não esconder dados recém-criados.

Falhas da origem

Quando o banco está indisponível, não substitua um valor stale válido por erro. Mantenha a última versão até o limite:

try {
  return await refresh(key);
} catch (error) {
  if (staleEntry) return staleEntry.value;
  throw error;
}

Registre que o valor está stale e alerte quando a idade ultrapassar o SLO.

Timeouts

Todo loader precisa de timeout:

const value = await fetchFromOrigin({
  signal: AbortSignal.timeout(2000)
});

Sem timeout, o lock pode expirar enquanto a operação continua.

Limite de concorrência

Mesmo para chaves diferentes, limite recomputações:

const limit = pLimit(20);
const value = await limit(() => loader());

Isso protege o banco durante aquecimento ou perda total do Redis.

Cache warming

Pré-carregue itens mais populares após deploy ou limpeza:

for (const id of popularProductIds) {
  await limit(() => refreshProduct(id));
}

Não tente aquecer todo o catálogo sem medir custo.

Invalidação

Quando o dado muda, invalide ou atualize a chave. Evite apagar milhares de chaves no mesmo instante. Uma alternativa é versionar namespace:

product:v3:42

O namespace antigo expira naturalmente.

Redis indisponível

Defina comportamento:

  • fallback direto com concorrência limitada;
  • circuit breaker;
  • valor local stale;
  • erro rápido para rotas não críticas;
  • fila para processamento posterior.

Consulte Circuit Breaker no Node.js.

Observabilidade

Meça:

  • hit, miss e stale hit;
  • revalidações iniciadas;
  • requisições agrupadas;
  • contenção do lock;
  • tempo do loader;
  • idade do valor stale;
  • falhas da origem;
  • chaves mais recomputadas.

Evite IDs individuais como labels Prometheus.

Teste de carga

Simule uma chave popular expirando:

npx autocannon \
  --connections 100 \
  --duration 30 \
  http://localhost:3000/products/42

Conte quantas consultas realmente chegam ao banco. Veja Autocannon no Node.js.

Erros comuns

  • TTL idêntico: milhares de chaves expiram juntas.
  • Lock sem segundo check: recomputação duplicada.
  • Polling infinito: Redis fica sobrecarregado.
  • Sem stale: qualquer falha vira indisponibilidade.
  • Lock global: chaves independentes ficam bloqueadas.
  • Loader sem timeout: lease expira no meio.
  • Revalidação em todas as requisições: stampede antecipado.
  • Cache como única fonte: perda do Redis derruba o sistema.

Conclusão

O Cache Stampede no Node.js ocorre quando misses simultâneos atingem a mesma origem. A correção mais comum é agrupar requisições e permitir que apenas uma recompute cada chave.

Combine lock por chave, segundo check, stale-while-revalidate, jitter e limites de concorrência. Para tráfego muito alto, considere revalidação probabilística. O cache deve suavizar picos e falhas, não sincronizar milhares de requisições contra o banco.

10 melhores cursos de programação em 2026

Descubra os melhores cursos de programação. Aprenda a escolher o curso ideal para iniciar ou avançar na carreira de desenvolvedor

POSTS RELACIONADOS

Ver todos

Seta para a direita