Blog

Cache stampede: quando a chave mais quente expira e mil requisições vão ao banco ao mesmo tempo

O cache estava funcionando: 99,8% de acerto, banco folgado, latência baixa. Então, a cada sessenta segundos, o gráfico de CPU do banco mostrava um pico estreito e alto, e uma vez por dia, no horário de maior tráfego, esse pico virava um incidente de três minutos com timeouts em cascata. Nenhuma consulta nova, nenhum deploy, nenhum bug no código do cache. Era a chave mais quente do sistema expirando, e todas as requisições que chegaram nos 400 ms seguintes descobrindo, ao mesmo tempo, que precisavam ir ao banco. Este artigo mostra a conta que explica por que isso acontece, por que o jitter não resolve uma chave única, como single-flight no processo e lock no Redis reduzem centenas de consultas a uma, como servir o valor velho enquanto a recomputação acontece, como a expiração antecipada probabilística renova a chave antes do vencimento, como sobreviver ao cache frio depois de um restart e como provar em teste que mil leituras simultâneas viram uma consulta.

2026-10-15 / Arquitetura / 14 min

01

Anatomia do stampede: o que acontece no milissegundo em que a chave expira

O padrão mais comum de cache é o cache-aside: lê do Redis, e se não estiver lá, consulta o banco e grava o resultado com TTL. O código abaixo é exatamente isso, e ele está correto para qualquer chave com pouco tráfego. O problema aparece quando a mesma chave recebe milhares de leituras por segundo e a consulta que a produz não é trivial, como a vitrine da página inicial de um e-commerce, que agrega estoque, preço promocional e ranking de vendas.

// vitrine.js (versao com problema)
export async function obterVitrine({ redis, carregarVitrine }) {
  const emCache = await redis.get('vitrine:home');
  if (emCache) return JSON.parse(emCache);

  // 400 ms de consulta: toda requisicao que chegar nessa janela tambem erra o cache
  const vitrine = await carregarVitrine();
  await redis.set('vitrine:home', JSON.stringify(vitrine), 'EX', 60);
  return vitrine;
}

Entre o instante em que a chave expira e o instante em que a primeira requisição grava o novo valor, todas as outras requisições também erram o cache e também consultam o banco. A quantidade de consultas idênticas em voo é simples de calcular: taxa de requisições vezes tempo de recomputação. Com 2.000 requisições por segundo e uma consulta de 400 ms, são cerca de 800 consultas iguais disparadas em menos de meio segundo, contra um pool de 20 conexões.

Vitrine da home: 2.000 req/s, consulta de 400 ms, TTL de 60 s, pool de 20 conexões.

t = 60,000 s   vitrine:home expira no Redis
t = 60,001 s   req 1 erra o cache -> consulta ao banco (400 ms em condição normal)
t = 60,002 s   req 2, 3, 4 erram o cache -> mais 3 consultas idênticas
   ...         2.000 req/s x 0,4 s = ~800 consultas idênticas em voo
t = 60,010 s   pool de 20 conexões esgotado, o resto espera na fila do pool
t = 60,400 s   a consulta passa a levar 3 s por disputa de CPU e I/O
               -> a janela sem cache cresce de 0,4 s para 3 s
               -> ~6.000 consultas idênticas, timeouts e retries do cliente
t = 63,000 s   a primeira consulta termina e grava o cache
               o banco ainda leva segundos para drenar o que ficou na fila

Misses simultâneos = taxa de requisições x tempo de recomputação.
O tempo de recomputação cresce com a própria carga: realimentação positiva.

O detalhe que transforma um pico em incidente é a realimentação. As 800 consultas disputam CPU, buffer e locks no banco, então cada uma demora mais; como cada uma demora mais, a janela sem cache se alonga; como a janela se alonga, mais requisições erram o cache. Uma consulta de 400 ms vira uma de 3 s, e 800 consultas viram 6.000. O banco que suportava a carga normal com folga cai por uma consulta que ele já tinha respondido um minuto antes, e os retries dos clientes empurram o sistema para o mesmo lugar depois que o cache volta.

02

Por que o jitter sozinho não resolve, e o que cada técnica realmente cobre

A primeira recomendação que aparece é adicionar jitter ao TTL: em vez de 60 s fixos, algo entre 54 s e 66 s. Isso é correto e necessário, mas resolve outro problema. O jitter espalha a expiração de chaves diferentes que foram gravadas juntas, como as dez mil páginas de produto populadas pelo mesmo job. Uma chave única continua tendo um único instante de expiração, com ou sem sorteio, e todas as requisições daquela chave continuam chegando juntas nele.

O stampede de uma chave quente tem três partes, e cada técnica ataca uma delas: quantas requisições recomputam ao mesmo tempo, o que as outras fazem enquanto esperam e quando a recomputação acontece. Nenhuma técnica sozinha cobre as três, e por isso a solução em produção costuma ser uma combinação pequena e não uma biblioteca.

TécnicaO que resolveO que não resolveCusto
Jitter no TTLMuitas chaves expirando juntasUma única chave quenteQuase nenhum
Single-flight no processoRequisições repetidas dentro de uma instânciaN instâncias recomputando ao mesmo tempoUm Map de promessas
Lock no RedisRecomputação duplicada entre instânciasO que os perdedores fazem enquanto esperamUma ida extra ao Redis e um TTL de lock bem escolhido
TTL lógico com folga físicaEspera dos perdedores: servem o valor velhoChave ausente, que não tem valor velhoDado até um TTL lógico mais velho
Expiração antecipada (XFetch)Renova antes de a chave vencerChave fria, que nunca é antecipadaRecomputações ligeiramente mais frequentes
Aquecimento e limitadorCache inteiro vazio depois de restart ou deployChave quente isolada no dia a diaTempo de subida maior

03

Single-flight: uma instância, uma recomputação por chave

O primeiro passo é barato e elimina a maior parte do problema: dentro de um mesmo processo, a primeira requisição que erra o cache cria a promessa da recomputação, e todas as outras que chegam enquanto ela está em voo recebem a mesma promessa. Em Go, isso é o pacote singleflight; em Node.js, cabe em quinze linhas.

// single-flight.js
export function criarSingleFlight() {
  const emVoo = new Map();

  return function executar(chave, carregar) {
    const existente = emVoo.get(chave);
    if (existente) return existente;

    // Promise.resolve().then garante que carregar so roda depois do set,
    // mesmo que lance erro de forma sincrona.
    const promessa = Promise.resolve()
      .then(carregar)
      .finally(() => emVoo.delete(chave));
    emVoo.set(chave, promessa);
    return promessa;
  };
}
  • A remoção no finally é obrigatória: sem ela, um erro fica guardado para sempre e a chave nunca mais é recomputada naquela instância.
  • O Promise.resolve().then existe por um motivo concreto: se carregar lançasse erro de forma síncrona dentro de uma função async, o finally rodaria antes do set, e a promessa rejeitada ficaria presa no Map.
  • Todos os que compartilham a promessa recebem o mesmo erro. Isso é desejável: 800 requisições falhando juntas por uma consulta é melhor que 800 consultas falhando juntas.
  • Os chamadores recebem o mesmo objeto. Se algum deles mutar o resultado, todos veem a mutação; trate o valor retornado como imutável.

Com single-flight, 800 consultas por instância viram uma consulta por instância. Numa frota de 40 réplicas, isso ainda são 40 consultas idênticas no mesmo meio segundo, o que muitas vezes já é aceitável. Quando não é, porque a consulta é pesada ou a frota é grande, a coordenação precisa sair do processo.

04

Lock no Redis e TTL lógico: uma recomputação na frota e ninguém esperando

Coordenar entre instâncias resolve quem recomputa, mas cria a pergunta que costuma ser esquecida: o que as outras requisições fazem enquanto isso. Se elas esperam, o lock só transferiu a fila do banco para o Redis. A saída é separar duas validades: o TTL lógico, gravado dentro do valor, diz quando ele fica velho; o TTL físico do Redis é maior, com uma folga, e mantém o valor velho disponível. Quem encontra o valor velho serve na hora e dispara uma revalidação em segundo plano; só quem encontra a chave ausente precisa esperar.

// cache-protegido.js
import { randomUUID } from 'node:crypto';
import { criarSingleFlight } from './single-flight.js';

// Apaga o lock somente se ele ainda pertence a quem o criou.
const LIBERAR_LOCK = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0`;

const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export function criarCacheProtegido({
  redis, // cliente ioredis
  ttlLogicoMs, // depois disso o valor esta velho, mas ainda pode ser servido
  folgaFisicaMs, // quanto tempo o valor velho continua existindo no Redis
  lockMs = 5_000, // maior que o p99 da recomputacao
  esperaMaxMs = 1_000,
  metricas,
}) {
  const singleFlight = criarSingleFlight();

  async function ler(chave) {
    const bruto = await redis.get(chave);
    return bruto ? JSON.parse(bruto) : null; // { valor, venceEm, custoMs }
  }

  async function recomputar(chave, carregar) {
    const token = randomUUID();
    const obteve = await redis.set(`lock:${chave}`, token, 'PX', lockMs, 'NX');
    if (obteve !== 'OK') return null; // outra instancia ja esta recomputando

    try {
      const inicio = Date.now();
      const valor = await carregar();
      const custoMs = Date.now() - inicio;
      const entrada = { valor, venceEm: Date.now() + ttlLogicoMs, custoMs };
      await redis.set(chave, JSON.stringify(entrada), 'PX', ttlLogicoMs + folgaFisicaMs);
      metricas?.recomputacao(chave, custoMs);
      return entrada;
    } finally {
      await redis.eval(LIBERAR_LOCK, 1, `lock:${chave}`, token);
    }
  }

  async function aguardarOutraInstancia(chave) {
    const limite = Date.now() + esperaMaxMs;
    while (Date.now() < limite) {
      await esperar(50);
      const entrada = await ler(chave);
      if (entrada && entrada.venceEm > Date.now()) return entrada;
    }
    return null;
  }

  return async function obter(chave, carregar) {
    const entrada = await ler(chave);

    if (entrada && entrada.venceEm > Date.now()) return entrada.valor; // fresco

    if (entrada) {
      // velho, mas presente: serve agora e recomputa em segundo plano, uma vez so
      metricas?.velhoServido(chave);
      singleFlight(`revalidar:${chave}`, () => recomputar(chave, carregar)).catch((erro) =>
        metricas?.falhaRecomputacao(chave, erro),
      );
      return entrada.valor;
    }

    // ausente: nao ha o que servir, alguem precisa esperar
    return singleFlight(`carregar:${chave}`, async () => {
      const recomputada = await recomputar(chave, carregar);
      if (recomputada) return recomputada.valor;

      const daOutra = await aguardarOutraInstancia(chave);
      if (daOutra) return daOutra.valor;

      // ultimo recurso: no maximo uma carga por instancia, nunca uma por requisicao
      metricas?.esperaEsgotada(chave);
      return carregar();
    });
  };
}
  1. O lock é um SET NX PX com um token aleatório, e a liberação é um script Lua que só apaga a chave se o token ainda for o seu. Sem essa comparação, uma instância lenta, cujo lock já expirou, apagaria o lock de outra instância que o pegou depois.
  2. O lockMs precisa ser maior que o p99 da recomputação. Se for menor, o lock expira no meio da consulta, outra instância o pega e o stampede volta em câmera lenta.
  3. O custo medido da recomputação é gravado junto com o valor. Ele serve para a expiração antecipada da próxima seção e para uma métrica que avisa quando a consulta está ficando mais cara.
  4. Com a chave ausente, quem perde o lock consulta o Redis a cada 50 ms até um limite. Se o limite estoura, carrega sozinho: no máximo uma carga por instância, graças ao single-flight, e nunca uma por requisição.

A folga física também vira proteção contra falha do banco. Se a recomputação falhar, o valor velho continua sendo servido até a folga acabar, e a próxima requisição tenta de novo. Uma vitrine com dois minutos de atraso é um problema de negócio pequeno; uma vitrine que retorna erro é um problema grande. Para dados em que valor velho não é aceitável, como saldo, a chave simplesmente não deve usar esse caminho, e o próprio cache talvez não seja o lugar certo para ela.

05

Expiração antecipada probabilística: renovar antes de vencer

Mesmo servindo o valor velho, a chave passa algum tempo velha depois de cada vencimento. A técnica conhecida como XFetch, publicada por Vattani, Chierichetti e Lowenstein, faz com que cada leitura decida, com uma probabilidade que cresce perto do vencimento, recomputar antes da hora. A probabilidade depende do custo da recomputação: chaves caras começam a ser renovadas mais cedo.

// expiracao-antecipada.js
// XFetch (Vattani, Chierichetti e Lowenstein, 2015): quanto mais perto do vencimento
// e quanto mais cara a recomputacao, maior a chance de uma requisicao recomputar antes.
export function deveRecomputarAntes(
  entrada,
  { beta = 1, agora = Date.now(), aleatorio = Math.random } = {},
) {
  // 1 - aleatorio() fica em (0, 1], entao o logaritmo nunca vira -Infinity
  const antecipacaoMs = -entrada.custoMs * beta * Math.log(1 - aleatorio());
  return agora + antecipacaoMs >= entrada.venceEm;
}

// cache-protegido.js: o ramo "fresco" de obter() passa a ser este
if (entrada && entrada.venceEm > Date.now()) {
  if (deveRecomputarAntes(entrada)) {
    singleFlight(`revalidar:${chave}`, () => recomputar(chave, carregar)).catch((erro) =>
      metricas?.falhaRecomputacao(chave, erro),
    );
  }
  return entrada.valor; // fresco, e talvez ja sendo renovado
}

A conta mostra por que isso funciona sem coordenação. Com custo de 400 ms e beta 1, a chance de uma leitura antecipar quando faltam t milissegundos é e elevado a menos t sobre 400. Com 2.000 leituras por segundo, a primeira antecipação acontece, em média, cerca de 2,9 segundos antes do vencimento, e a chave praticamente nunca chega a ficar velha. Com uma leitura por segundo, a chance acumulada é tão baixa que a chave expira normalmente, e isso está certo: uma chave com pouco tráfego não tem stampede para evitar.

  • Beta maior que 1 antecipa mais cedo e recomputa com mais frequência; beta menor que 1 faz o contrário. Comece com 1 e ajuste só com métrica.
  • Várias leituras podem decidir antecipar quase ao mesmo tempo. O single-flight e o lock da seção anterior continuam sendo a rede de segurança que garante uma recomputação.
  • O custo gravado no valor precisa ser o custo real medido, não uma constante. Se a consulta ficar lenta, a antecipação se ajusta sozinha.

06

Cache frio: quando todas as chaves somem ao mesmo tempo

Existe uma versão do stampede que nenhuma das técnicas anteriores resolve sozinha: o cache inteiro vazio. Acontece quando o Redis reinicia sem persistência, quando um failover promove uma réplica atrasada, quando um flush é rodado no ambiente errado ou quando um deploy muda o prefixo das chaves de v1 para v2. Nesse momento, não existe valor velho para servir em chave nenhuma, e o single-flight por chave não limita nada, porque são milhares de chaves diferentes.

  • Limite a concorrência de recomputação por instância, independentemente da chave. Quatro recomputações simultâneas por réplica numa frota de 40 são 160 consultas no banco, um número que dá para dimensionar; sem limite, são milhares.
  • Aqueça as chaves mais lidas antes de a instância entrar no balanceador. A lista vem da própria métrica de acessos por chave, e cem chaves costumam cobrir a maior parte do tráfego.
  • Ao mudar o formato da chave, leia a versão nova e, se ela não existir, a antiga, convertendo na leitura. O prefixo novo é populado aos poucos pelo tráfego, e não de uma vez pela ausência.
  • Aplique jitter no TTL físico de tudo que é gravado em lote. Aqui o jitter é exatamente a ferramenta certa.
  • Guarde também a ausência. Uma chave de produto inexistente consultada em loop vai ao banco toda vez; um marcador de "não existe" com TTL curto resolve.
// limitador.js: no maximo N recomputacoes simultaneas por instancia
export function criarLimitador(maximo) {
  let ativos = 0;
  const fila = [];

  const liberar = () => {
    const proximo = fila.shift();
    if (proximo) proximo(); // passa a vaga direto para quem espera
    else ativos -= 1;
  };

  return async function limitar(fn) {
    if (ativos < maximo) ativos += 1;
    else await new Promise((resolve) => fila.push(resolve));

    try {
      return await fn();
    } finally {
      liberar();
    }
  };
}

// uso em cache-protegido.js, dentro de recomputar():
//   const valor = await limitarRecomputacao(() => carregar());
// com limitarRecomputacao = criarLimitador(4), criado uma vez por instancia

O limitador passa a vaga diretamente para quem está na fila, sem devolver ao contador. Parece um detalhe, mas a versão ingênua que decrementa e deixa o próximo incrementar permite que uma requisição nova passe na frente entre as duas operações e ultrapasse o máximo justamente no pico, que é o único momento em que o limite importa.

07

Como provar em teste e enxergar em produção

A propriedade que importa é verificável: para N leituras simultâneas de uma chave ausente, o carregador roda uma vez; para N leituras de uma chave velha, nenhuma requisição espera e o carregador roda uma vez em segundo plano. O teste abaixo usa um Redis em memória com o subconjunto de comandos do cache e duas instâncias compartilhando o mesmo Redis, para cobrir o lock e não só o single-flight.

// cache-protegido.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { criarCacheProtegido } from './cache-protegido.js';

// Redis em memoria com o subconjunto usado pelo cache: GET, SET PX [NX] e o script de liberacao.
function criarRedisFalso() {
  const dados = new Map();
  const vivo = (chave) => {
    const item = dados.get(chave);
    if (item && item.expiraEm <= Date.now()) dados.delete(chave);
    return dados.get(chave);
  };
  return {
    async get(chave) {
      return vivo(chave)?.valor ?? null;
    },
    async set(chave, valor, _px, ms, nx) {
      if (nx === 'NX' && vivo(chave)) return null;
      dados.set(chave, { valor, expiraEm: Date.now() + ms });
      return 'OK';
    },
    async eval(_script, _numChaves, chave, token) {
      if (vivo(chave)?.valor !== token) return 0;
      dados.delete(chave);
      return 1;
    },
  };
}

const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

test('mil leituras simultaneas de uma chave ausente consultam o banco uma vez', async () => {
  const redis = criarRedisFalso();
  let consultas = 0;
  const carregar = async () => {
    consultas += 1;
    await esperar(100);
    return { produtos: 42 };
  };
  // duas instancias do servico compartilhando o mesmo Redis
  const instancias = [1, 2].map(() =>
    criarCacheProtegido({ redis, ttlLogicoMs: 60_000, folgaFisicaMs: 30_000 }),
  );

  const resultados = await Promise.all(
    Array.from({ length: 1000 }, (_, i) => instancias[i % 2]('vitrine:home', carregar)),
  );

  assert.equal(consultas, 1);
  assert.ok(resultados.every((r) => r.produtos === 42));
});

test('valor velho e servido na hora e recomputado uma vez so', async () => {
  const redis = criarRedisFalso();
  let consultas = 0;
  const carregar = async () => {
    consultas += 1;
    await esperar(100);
    return consultas;
  };
  const cache = criarCacheProtegido({ redis, ttlLogicoMs: 50, folgaFisicaMs: 10_000 });

  assert.equal(await cache('vitrine:home', carregar), 1);
  await esperar(60); // passa do TTL logico, mas a entrada continua no Redis

  const inicio = Date.now();
  const resultados = await Promise.all(
    Array.from({ length: 500 }, () => cache('vitrine:home', carregar)),
  );
  assert.ok(Date.now() - inicio < 50, 'nenhuma requisicao esperou a recomputacao');
  assert.ok(resultados.every((r) => r === 1));

  await esperar(150); // a recomputacao em segundo plano termina
  assert.equal(consultas, 2);
});

Em produção, a taxa de acerto não mostra o problema, porque um stampede de meio segundo mal altera a média do minuto. As métricas que mostram são outras, e todas por chave ou pelo menos pelas chaves mais quentes.

MétricaO que indicaSinal de alerta
Recomputações por chave por janela de TTLSe a coordenação funcionaMais de uma por TTL lógico numa chave quente
Valor velho servido por segundoQuanto tráfego depende da folga físicaCrescimento contínuo: a recomputação está falhando
Esperas esgotadasQuantas instâncias caíram no último recursoQualquer valor diferente de zero recorrente
Custo da recomputação (p99)Se a consulta está ficando mais carap99 se aproximando do lockMs
Idade do valor servidoQuanto o usuário está vendo de atrasoIdade acima do TTL lógico mais a folga planejada

O teste de carga também precisa de um cenário específico: expirar a chave mais quente no meio do pico, de propósito, e medir conexões ativas no banco nos dois segundos seguintes. Um teste de carga que só mede a vazão em regime constante passa sem encontrar o stampede, porque o cache já está quente desde o primeiro minuto.

FAQ

Perguntas frequentes

Lock com Redis em um nó só não é inseguro? E o debate sobre o Redlock?

O debate é sobre exclusão mútua usada para correção, como garantir que duas instâncias nunca escrevam o mesmo registro. Aqui o lock é uma otimização: se ele falhar e duas instâncias recomputarem juntas, o custo é uma consulta a mais, e o valor gravado é o mesmo. Para esse uso, um SET NX PX com token num único Redis é suficiente. Se o seu lock protege correção, ele precisa de fencing token na escrita, e o problema deixa de ser de cache.

Como escolher o TTL lógico e a folga física?

O TTL lógico vem do negócio: quanto tempo um dado pode ficar desatualizado sem causar dano. A folga física vem da operação: precisa cobrir pelo menos o p99 da recomputação com margem e, se você quer que o cache segure o tráfego durante uma falha do banco, o tempo que você aceita servir dado velho nesse cenário. Uma vitrine com TTL lógico de 60 s e folga de 5 minutos é um ponto de partida comum. Se nenhum atraso é aceitável, a chave não deve servir valor velho.

Quando não vale a pena proteger uma chave contra stampede?

Quando a taxa de requisições vezes o tempo de recomputação fica perto de 1. Uma chave lida 5 vezes por segundo com consulta de 20 ms tem, em média, 0,1 miss simultâneo: não existe stampede. Uma chave lida 2.000 vezes por segundo com consulta de 400 ms tem 800. O single-flight é barato o bastante para usar em tudo; lock, folga física e XFetch fazem sentido para as poucas chaves em que essa conta passa de algumas unidades.

O cache protege o banco até o instante em que expira

Stampede não é falta de cache, é a expiração do cache acontecendo para todo mundo ao mesmo tempo. A conta que o explica é taxa vezes tempo de recomputação, e a solução é reduzir cada fator: single-flight e lock para que só uma requisição recompute, TTL lógico com folga física para que ninguém espere por ela, expiração antecipada para que a chave quente raramente chegue a vencer, e limitador com aquecimento para o dia em que o cache inteiro some. Sem o teste das mil leituras simultâneas, a proteção é só uma suposição.