Cache stampede acontece quando uma entrada popular expira e muitas requisições tentam reconstruí-la ao mesmo tempo. O banco ou serviço de origem recebe uma rajada inesperada, a latência aumenta e a falha pode se espalhar por toda a aplicação.
O problema também é chamado de dogpile effect ou thundering herd. Em Node.js, ele aparece em caches locais, Redis, CDNs e funções que memorizam Promises sem controlar expiração.
Exemplo vulnerável
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;
}Se mil requisições chegam logo após o TTL, todas leem miss e consultam o banco.
Request coalescing local
Dentro de um processo, armazene a Promise em andamento:
const inFlight = new Map();
async function coalesce(key, loader) {
if (inFlight.has(key)) return inFlight.get(key);
const promise = Promise.resolve()
.then(loader)
.finally(() => inFlight.delete(key));
inFlight.set(key, promise);
return promise;
}Uso:
async function getProduct(id) {
const key = `product:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
return coalesce(key, async () => {
const secondCheck = await redis.get(key);
if (secondCheck) return JSON.parse(secondCheck);
const product = await database.findProduct(id);
await redis.set(key, JSON.stringify(product), { EX: 300 });
return product;
});
}O segundo check evita reconstruir se outro fluxo preencheu o cache.
Limite do coalescing local
Cada pod possui seu próprio Map. Dez réplicas ainda podem executar dez reconstruções. Para operações caras, use coordenação distribuída.
Lock distribuído
Um lock no Redis pode permitir um único regenerador:
const lockKey = `lock:${key}`;
const token = crypto.randomUUID();
const acquired = await redis.set(lockKey, token, {
NX: true,
PX: 5000,
});Se adquiriu, carrega e preenche. Se não, espera curto período, lê novamente ou usa stale.
Liberando o lock com segurança
Não execute DEL sem verificar o token, pois o lock pode ter expirado e sido adquirido por outro processo. Use script atômico:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0TTL do lock
O lock deve expirar para evitar deadlock após crash. Mas um TTL curto pode vencer antes da reconstrução, permitindo outro worker. Defina prazo realista e, se necessário, renovação controlada.
Espera limitada
Quem não adquiriu o lock não deve aguardar indefinidamente:
for (let attempt = 0; attempt < 5; attempt += 1) {
await sleep(50 + Math.random() * 100);
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
}
throw new Error('Cache em reconstrução');Uma alternativa melhor é servir stale.
Stale-while-revalidate
Armazene valor, freshUntil e staleUntil:
{
"value": { "id": 42 },
"freshUntil": 1790841600000,
"staleUntil": 1790841900000
}Se fresh, devolva. Se stale, devolva imediatamente e inicie atualização em background. Se expirou completamente, aguarde reconstrução.
Implementação conceitual
async function getWithStale(key, loader) {
const entry = await cache.get(key);
const now = Date.now();
if (entry && entry.freshUntil > now) {
return entry.value;
}
if (entry && entry.staleUntil > now) {
void coalesce(key, () => refresh(key, loader));
return entry.value;
}
return coalesce(key, () => refresh(key, loader));
}A atualização em background precisa de logging, timeout e tratamento de rejeição.
stale-if-error
Se a origem falha, uma resposta antiga pode preservar disponibilidade:
try {
return await refresh(key, loader);
} catch (error) {
if (entry && entry.errorStaleUntil > Date.now()) {
return entry.value;
}
throw error;
}Não use stale para saldo, autorização ou dados que exigem consistência forte.
Jitter de TTL
Muitas chaves podem expirar juntas após deploy ou importação. Adicione variação:
function ttlWithJitter(baseSeconds, ratio = 0.2) {
const variation = baseSeconds * ratio;
return Math.round(baseSeconds - variation + Math.random() * variation * 2);
}Probabilistic early expiration
Uma entrada pode ser atualizada antes de expirar com probabilidade crescente. Isso distribui regenerações sem esperar o instante exato do TTL.
Uma abordagem simples define refreshAfter menor que expiresAt e permite que uma requisição assuma atualização por lock.
Refresh ahead
Um job atualiza chaves populares antes do vencimento. É útil para catálogo e configurações conhecidas, mas pode desperdiçar recursos em itens não acessados.
Hot keys
Uma chave extremamente popular pode sobrecarregar o Redis mesmo com hit. Estratégias:
- cache local curto;
- replicação;
- CDN;
- sharding de leitura quando semântica permitir;
- resposta pré-computada;
- limite por cliente.
Cache local em camadas
L1 no processo e L2 no Redis:
requisição → L1 → L2 → bancoL1 reduz tráfego, mas aumenta janela de inconsistência. Use TTL curto e invalidação quando necessária.
Negative caching
Ausência também pode causar stampede. Cacheie “não encontrado” por TTL curto:
await cache.set(key, { found: false }, { ttl: 30 });Depois de criar o recurso, invalide a chave negativa.
Erros em cache
Não cacheie indiscriminadamente toda exceção. Um erro transitório de banco não deve virar resposta persistente. Cache negativo é para resultado válido de ausência.
Timeout do loader
Uma Promise em andamento pode nunca concluir. Use AbortSignal e remova o mapa em finally.
Fila limitada
Se a origem está lenta, milhares de chaves diferentes podem criar milhares de loaders. Use semáforo global para limitar reconstruções simultâneas.
Load shedding
Quando a fila excede capacidade, rejeite operações menos importantes ou sirva stale. Aguardar indefinidamente aumenta p99 e memória.
Cache warming
Após deploy ou flush, pré-carregue apenas chaves críticas. Aquecer todo o catálogo pode ser tão pesado quanto um stampede.
Flush perigoso
Evite FLUSHALL em produção. Invalide por namespace ou versão. Um flush global remove proteção de todas as chaves ao mesmo tempo.
Versionamento de namespace
v3:product:42Ao mudar formato, use nova versão. Chaves antigas expiram naturalmente, evitando limpeza massiva.
Invalidação por evento
Quando um produto muda, publique evento para remover L1 e L2. Ainda mantenha TTL como segurança.
Consistência
Defina tolerância por recurso:
- conteúdo editorial: minutos;
- catálogo: segundos ou minutos;
- estoque: curto e validado na compra;
- saldo: não usar stale;
- feature flags: conforme risco.
Observabilidade
Meça:
- hit, miss e stale hit;
- reconstruções;
- requisições coalescidas;
- locks adquiridos e disputados;
- tempo de espera;
- loader duration;
- fallback stale-if-error;
- fila de regeneração;
- hot keys agregadas;
- erros do cache.
Não use chave completa de usuário como label.
Teste de carga
Expire uma chave popular durante carga e observe quantas chamadas chegam à origem. Repita com coalescing, lock e stale. Meça p99, banco e Redis.
Testes de falha
Simule crash do lock holder, TTL do lock, Redis indisponível, origem lenta, stale expirado, múltiplos pods e invalidação simultânea.
Erros comuns
- usar TTL igual para todas as chaves;
- lock sem token;
- lock sem expiração;
- esperar indefinidamente;
- coalescing apenas local em operação cara;
- não fazer segundo check;
- servir stale em dado crítico;
- cachear exceção;
- flush global;
- não limitar loaders.
Fluxo recomendado
Comece com request coalescing, adicione jitter e use stale-while-revalidate. Para múltiplas réplicas e loaders caros, coordene com lock seguro. Combine com Cache HTTP, Redis com Node.js, Circuit Breaker e Rate Limiting.
Consulte a referência de Cache-Control e a documentação oficial do armazenamento usado para locks atômicos.



