Cenário: catálogo de produtos servido por uma API em Node, cache em Redis com TTL fixo de 300 segundos, algo em torno de 4 mil requisições por segundo no pico da tarde. Um deploy às 14h02 subiu pods novos e, junto, um script de invalidação limpou o namespace inteiro do cache. Doze minutos depois o banco primário estava com o pool de conexões saturado, o p99 do checkout passava de 20 segundos e o balanceador devolvia 502.
O relato abaixo é reconstruído para ilustrar o modo de falha — não descreve o incidente de nenhuma empresa específica. O padrão, porém, é dos mais frequentes em qualquer stack que coloque cache com TTL uniforme na frente de um banco relacional: o problema não foi o cache ter esvaziado, foi todas as chaves terem voltado a valer no mesmo instante.
A curva diz mais que a linha do tempo
Na leitura do gráfico, o que chama atenção não é o pico de latência — é o formato dele. Requisições ao banco saltam de uma base estável para dez, quinze vezes o volume normal em menos de um segundo, permanecem no teto por alguns minutos e caem de novo de forma abrupta. Não é a curva de um crescimento de tráfego: tráfego de usuário não dobra em 200 milissegundos. É a assinatura de um evento sincronizado do lado da aplicação.
Três indícios separam esse cenário de uma sobrecarga genuína de tráfego. Primeiro, o volume de requisições HTTP na borda continua estável enquanto as queries ao banco explodem — a razão query por request muda, e é isso que importa. Segundo, as queries que dominam o topo são idênticas entre si, variando só no parâmetro de ID. Terceiro, o pico se repete em intervalos que correspondem ao TTL configurado, mais amortecido a cada ciclo, até sumir. Esse terceiro sinal é o mais conclusivo: o sistema está oscilando na frequência do próprio cache.
Expiração sincronizada e o rebanho que atropela
O mecanismo é simples. Quando um lote grande de chaves é gravado no mesmo instante — depois de um flush, de um deploy que muda o prefixo das chaves, ou de um cold start de um cluster novo — todas recebem o mesmo TTL. Trezentos segundos depois, todas expiram juntas. Cada requisição que chega nessa janela encontra um miss, e cada uma dispara sua própria recomputação contra o banco.
O agravante é que a recomputação não é instantânea. Se a query que popula a chave leva 180 milissegundos, e chegam 4 mil requisições por segundo, aproximadamente 720 requisições entram na janela em que a primeira ainda não terminou de gravar o resultado. Nenhuma delas encontra o cache preenchido. Todas vão ao banco. O banco fica mais lento sob essa carga, a query passa a levar 900 milissegundos, e agora são 3.600 requisições concorrentes fazendo o mesmo trabalho. A degradação se realimenta — é por isso que o gráfico sobe em parede, e não em rampa.
Há dois nomes circulando para variações do mesmo problema, e vale separá-los porque a correção é diferente. Cache stampede descreve muitas chaves distintas expirando ao mesmo tempo. Thundering herd em chave quente descreve uma única chave muito requisitada expirando e sendo recomputada por N clientes simultâneos. O primeiro é um problema de distribuição de TTL; o segundo, de exclusão mútua na recomputação.
Jitter no TTL resolve a maioria dos casos
A correção para o primeiro caso cabe em uma linha: em vez de gravar todas as chaves com o mesmo TTL, sorteie um desvio dentro de uma faixa. Um TTL base de 300 segundos com jitter de 20% distribui as expirações ao longo de uma janela de 120 segundos em vez de concentrá-las num instante.
const TTL_BASE = 300;
const JITTER = 0.2;
function ttlComJitter() {
const desvio = TTL_BASE * JITTER;
return Math.round(TTL_BASE - desvio + Math.random() * desvio * 2);
}
await redis.set(chave, JSON.stringify(valor), 'EX', ttlComJitter());
A escolha da faixa é um trade-off entre suavidade e frescor. Jitter de 10% mal resolve quando o volume é alto — a janela ainda é estreita demais. Jitter de 50% distribui bem, mas significa que parte dos dados pode viver metade do tempo pretendido, o que encarece o custo de banco no regime normal. Para cache de leitura de catálogo, 20% a 30% costuma ser o ponto razoável. Para dados em que a idade máxima é um requisito de negócio — saldo, estoque reservado, limite de crédito —, jitter só para baixo: sorteie entre 70% e 100% do TTL nominal, nunca acima.
A chave quente exige lock, não jitter
Jitter distribui expirações entre chaves diferentes. Não faz nada por uma chave só, requisitada mil vezes por segundo, cujo recálculo custa caro. Aqui a ferramenta é exclusão mútua: o primeiro cliente que encontra o miss adquire um lock curto e recomputa; os demais aguardam ou servem o valor anterior.
async function buscarComLock(chave, recomputar) {
const cache = await redis.get(chave);
if (cache) return JSON.parse(cache);
const lock = await redis.set(`lock:${chave}`, '1', 'NX', 'PX', 5000);
if (!lock) {
await sleep(50);
return buscarComLock(chave, recomputar);
}
try {
const valor = await recomputar();
await redis.set(chave, JSON.stringify(valor), 'EX', ttlComJitter());
return valor;
} finally {
await redis.del(`lock:${chave}`);
}
}
Três detalhes decidem se essa implementação ajuda ou piora. O lock precisa de TTL próprio — sem o PX, um processo que morre no meio da recomputação trava a chave para sempre. O retry precisa de teto: espera indefinida transforma contenção de cache em esgotamento do pool de conexões HTTP. E o finally só libera o lock com segurança se o valor gravado for verificado antes da remoção; em cluster com failover, a versão correta usa um token aleatório e um script Lua para o delete condicional.
Servir dado velho de propósito
A alternativa que evita o problema em vez de administrá-lo é nunca deixar a chave ficar vazia. Grava-se o valor com dois prazos: um lógico, curto, que marca quando o dado passa a ser considerado velho; e o TTL físico do Redis, bem mais longo. Quando uma leitura encontra o dado velho mas ainda presente, devolve ele imediatamente e dispara a recomputação em background.
| Estratégia | Resolve | Custo | Quando escolher |
|---|---|---|---|
| Jitter no TTL | Expiração em lote de muitas chaves | Nenhum, uma linha | Sempre. É o piso, não a solução completa |
| Lock de recomputação | Chave quente com recálculo caro | Latência extra para quem espera; complexidade de lock distribuído | Poucas chaves muito requisitadas, recálculo acima de 200 ms |
| Stale-while-revalidate | Ambos, sem espera para o usuário | Serve dado desatualizado por alguns segundos | Leitura tolerante a atraso: catálogo, listagem, agregados |
| Refresh proativo por job | Ambos, com frescor previsível | Recomputa mesmo o que ninguém pediu | Conjunto pequeno e conhecido de chaves críticas |
Entre as quatro, a escolha padrão para leitura de catálogo é stale-while-revalidate somado a jitter. O argumento é direto: o usuário nunca paga a latência da recomputação, o banco recebe uma requisição por chave por ciclo em vez de centenas, e o preço — alguns segundos de dado desatualizado numa listagem de produtos — é irrelevante para o negócio. Lock só entra em cima disso, e apenas nas chaves em que a recomputação é pesada o suficiente para que várias em paralelo doam. Refresh proativo por job é o mais previsível, mas só se paga quando o conjunto de chaves críticas é pequeno e estável; em catálogo com cauda longa, recomputar o que ninguém pede é desperdício puro.
O sinal que aparece antes da queda
Nenhuma das correções acima adianta se o time só descobre o problema pelo alerta de 502. A métrica que antecipa o evento é a razão entre queries ao banco e requisições HTTP recebidas, medida em janela curta. No regime saudável ela é baixa e estável. Quando um lote de chaves expira junto, ela salta antes de a latência subir o suficiente para disparar qualquer alerta de disponibilidade — há uma janela de dezenas de segundos ali.
Duas outras séries valem o painel. A taxa de miss do cache segmentada por prefixo de chave mostra qual conjunto está expirando em bloco, o que aponta direto para o trecho de código que grava com TTL fixo. E o tempo de espera no pool de conexões do banco, que é o recurso que efetivamente esgota — a saturação aparece nele bem antes de aparecer na CPU do banco, e times que monitoram só CPU descobrem tarde.
Próximo passo concreto: procure no código toda chamada de escrita no cache que use uma constante de TTL e verifique quantas delas gravam em lote. Se houver mais de uma dezena de chaves nascendo no mesmo laço com o mesmo prazo, o jitter compensa antes de qualquer discussão sobre lock ou revalidação em background.
Deixe um comentário