Blog

Cache invalidado errado: quando o dado velho custa mais caro que a consulta

O cache entrou no sistema para resolver um problema de latência e resolveu, mas trouxe junto um problema que ninguém mediu: agora existe uma segunda cópia da verdade, e ela envelhece sozinha. O sintoma clássico chega pelo suporte e não pelo alerta: um cliente vê o saldo antigo depois de pagar, um vendedor vê o estoque que não existe mais, um usuário some da lista de bloqueados e volta a mandar mensagem. Este artigo mostra por que TTL é uma aposta e não uma estratégia, por que invalidar no lugar errado transforma um cache em uma fonte de erro consistente, qual é a diferença prática entre invalidar e sobrescrever, por que o cache de dois níveis multiplica o problema em vez de dividi-lo, como calcular o custo do dado velho antes de escolher a política, e qual é o padrão que resolve estampida sem trocar um incidente por outro.

2026-08-26 / Arquitetura / 16 min

01

Cache não é otimização, é uma segunda fonte de verdade

A forma como o cache costuma entrar no projeto esconde o que ele realmente é. Alguém mede uma consulta lenta, coloca uma leitura de Redis na frente dela, o gráfico de latência melhora e o pull request é aprovado como otimização de performance. O que acabou de acontecer, porém, é que o sistema passou a ter duas cópias do mesmo dado, mantidas por caminhos diferentes, com garantias diferentes e sem nenhum mecanismo que force as duas a concordarem. Isso não é uma otimização, é uma decisão de arquitetura de dados, e ela deveria ser revisada com o mesmo rigor de uma replicação.

A consequência prática é que a pergunta certa nunca é se o cache está rápido, e sim quanto tempo o sistema tolera servir uma resposta errada. Essa tolerância varia de forma brutal dentro da mesma aplicação. Uma listagem de produtos aguenta trinta segundos de defasagem sem que ninguém perceba. Um saldo disponível não aguenta dois segundos, porque o usuário acabou de fazer a transferência e está olhando para a tela. Uma lista de números bloqueados não aguenta defasagem nenhuma, porque a consequência de errar é mandar mensagem para quem pediu para não receber, o que é problema jurídico e não problema de UX.

Dado em cacheTolerância a defasagemCusto de servir o valor velhoPolítica adequada
Catálogo de produtosMinutosBaixo: usuário vê preço antigo por pouco tempoTTL simples, revalidação em segundo plano
Estoque disponívelSegundosMédio: venda de item inexistente, estorno manualTTL curto mais invalidação na escrita
Saldo de contaZero em leitura do próprio donoAlto: perda de confiança imediataLeitura consistente para o autor da escrita, cache para terceiros
Permissões e papéisSegundos, mas só para concederCrítico: acesso após revogaçãoInvalidação na escrita, TTL curto como rede de segurança
Lista de bloqueio (opt-out)ZeroCrítico: risco jurídico e multaNunca cachear negativa, ou invalidar de forma síncrona

A tabela não é um detalhe de documentação, ela é o artefato que decide o desenho. Quando essa classificação não existe, todos os dados acabam recebendo a mesma política, e a política escolhida é sempre a mais conveniente para o caso mais comum. É assim que uma lista de opt-out termina com o mesmo TTL de cinco minutos que o catálogo, e ninguém percebe até o dia em que uma campanha dispara para dez mil números que haviam saído na hora anterior.

02

TTL é uma aposta sobre uma frequência que você não controla

O TTL é a política mais usada porque é a mais fácil de implementar e a que menos exige acoplamento. Ele também é a política que menos garante alguma coisa. Um TTL de sessenta segundos não significa que o dado tem no máximo sessenta segundos de idade, significa que o dado tem no máximo sessenta segundos de idade contados a partir do momento em que ele entrou no cache, o que é diferente. Se a escrita aconteceu um milissegundo depois da leitura que populou a entrada, a janela de erro é o TTL inteiro. O tempo médio de defasagem é metade do TTL, mas ninguém sofre com a média, sofre com o pior caso, e o pior caso é o TTL completo.

Existe ainda um efeito de segunda ordem que costuma passar despercebido. O TTL faz uma aposta implícita sobre a frequência de escrita: ele funciona bem quando o dado muda com frequência muito menor que o TTL, e desperdiça quando o dado muda com frequência muito maior. Se um registro muda a cada dez minutos e o TTL é de um minuto, nove entre dez expirações recarregam exatamente o mesmo valor, gastando consulta ao banco sem reduzir defasagem alguma. Se o mesmo registro passa a mudar a cada dois segundos, o TTL de um minuto entrega dado velho em praticamente toda leitura. O parâmetro não se adapta, o comportamento do dado é que mudou.

JANELA DE ERRO DO TTL (TTL = 60s)

  t=0    leitura popula o cache com valor A
  t=1s   escrita muda o valor no banco para B
  |
  |      cache serve A --------------------------> 59 segundos errado
  |
  t=60s  entrada expira, proxima leitura busca B

  ^ a janela de erro nao depende do TTL medio,
    depende de quando a escrita cai dentro da janela


INVALIDACAO NA ESCRITA (mesmo cenario)

  t=0    leitura popula o cache com valor A
  t=1s   escrita muda para B  ->  DELETE da chave
  t=1s   proxima leitura encontra falha e busca B

  ^ janela de erro = duracao da propria escrita
    custo = acoplamento entre quem escreve e quem cacheia

Daí vem a conclusão que orienta o resto do desenho: TTL sozinho serve para dado cuja defasagem é tolerável por definição, não para dado cuja defasagem é tolerável na maior parte do tempo. Para todo o resto, o TTL continua útil, mas com outro papel. Ele deixa de ser a política e passa a ser a rede de segurança que limita o estrago quando a invalidação falha, e a invalidação vai falhar, porque ela depende de uma operação de rede que não participa da transação do banco.

03

Invalidar no lugar errado é pior que não invalidar

O erro mais comum na invalidação não é esquecer de invalidar, é invalidar no lugar errado da sequência. A ordem entre commit no banco e remoção da chave no cache decide se o sistema tem uma janela de erro de milissegundos ou uma entrada envenenada que sobrevive até o TTL expirar. Quando a invalidação acontece antes do commit, existe um intervalo em que a chave já foi removida e o banco ainda não confirmou a mudança, e qualquer leitura concorrente que cair nesse intervalo vai buscar o valor antigo no banco e gravá-lo de volta no cache como se fosse novo. O resultado é um cache que contém o valor velho com o carimbo de recém-carregado, e nenhuma escrita futura vai corrigi-lo, porque a escrita que deveria invalidar já aconteceu.

// ERRADO: invalida antes do commit.
// Uma leitura concorrente reintroduz o valor antigo e ele fica ate o TTL.
async function atualizarPrecoErrado(produtoId, novoPreco) {
  await cache.del(`produto:${produtoId}`);   // (1) chave removida
  await db.transaction(async (tx) => {         // (2) commit so termina aqui
    await tx.produtos.update(produtoId, { preco: novoPreco });
  });
}

// Entre (1) e (2), outra requisicao executa:
//   miss -> SELECT (le o preco ANTIGO) -> SET no cache
// e o cache passa a servir o preco antigo ate o TTL expirar.


// CERTO: commit primeiro, invalidacao depois, com retentativa durável.
// A janela de erro passa a ser a duracao da propria escrita.
async function atualizarPreco(produtoId, novoPreco) {
  await db.transaction(async (tx) => {
    await tx.produtos.update(produtoId, { preco: novoPreco });
    // Enfileirar na MESMA transacao: se o commit falhar, a invalidacao
    // tambem some. Se o commit passar, a invalidacao esta garantida.
    await tx.outbox.insert({
      tipo: 'cache.invalidate',
      chave: `produto:${produtoId}`,
      criadoEm: new Date(),
    });
  });

  // Caminho rapido: tenta invalidar imediatamente. Se falhar, tudo bem,
  // o worker do outbox faz de novo em no maximo alguns segundos.
  try {
    await cache.del(`produto:${produtoId}`);
  } catch (erro) {
    logger.warn({ erro, produtoId }, 'invalidacao imediata falhou, outbox assume');
  }
}

A tabela outbox no exemplo não é excesso de engenharia, ela resolve a única falha que o TTL não cobre bem. Sem ela, uma indisponibilidade momentânea do Redis no instante exato da escrita deixa a entrada velha viva pelo TTL inteiro, e se o TTL for longo porque o dado era considerado estável, o estrago dura minutos. Com ela, a invalidação passa a ter a mesma durabilidade do commit: ou as duas coisas aconteceram, ou nenhuma. O custo é um worker a mais e algumas dezenas de linhas, e o benefício é que a política de TTL volta a ser uma escolha de performance em vez de um limite superior de erro.

Vale registrar a variação que troca invalidação por sobrescrita, porque ela parece melhor e quase nunca é. Escrever o novo valor direto no cache junto com o commit elimina o miss seguinte e parece mais eficiente. O problema é que a sobrescrita não é comutativa: se duas escritas concorrentes acontecem, a ordem em que elas chegam ao cache pode ser diferente da ordem em que elas chegaram ao banco, e o cache termina com o valor da escrita mais antiga. O delete não tem esse problema porque duas remoções concorrentes produzem o mesmo estado, e o estado depois delas é sempre buscar do banco. A regra prática é simples: invalidar por padrão, sobrescrever apenas quando existe uma versão monotônica para comparar e descartar a escrita fora de ordem.

04

Estampida: o incidente que a invalidação correta provoca

Existe uma ironia previsível no caminho: quanto melhor a invalidação, mais concentrado fica o miss. Uma chave popular que é invalidada às dez da manhã tem centenas de requisições simultâneas descobrindo a falha no mesmo milissegundo, e todas elas vão ao banco fazer exatamente a mesma consulta. Isso é a estampida de cache, e ela é pior do que a lentidão que o cache resolvia, porque agora o banco recebe um pico coordenado em vez de uma carga distribuída. O caso extremo acontece quando várias chaves compartilham o mesmo instante de expiração, o que é comum quando um deploy popula tudo ao mesmo tempo.

A solução tem três peças que resolvem problemas diferentes e costumam ser confundidas. A primeira é o jitter no TTL, que espalha as expirações no tempo e evita que um grupo inteiro de chaves caia junto. A segunda é o bloqueio de recarga, que garante que apenas uma requisição vá ao banco enquanto as outras esperam ou servem o valor antigo. A terceira é a revalidação em segundo plano, que recarrega a entrada antes de ela expirar de fato, para que nenhuma requisição de usuário pague o custo do miss. Nenhuma das três substitui as outras, e implementar só a primeira é o erro mais comum porque é a mais fácil.

// Leitura com protecao contra estampida:
// 1) jitter no TTL, 2) lock de recarga, 3) valor antigo servido enquanto recarrega.
const TTL_BASE = 300;          // 5 minutos
const JITTER_MAX = 60;         // ate 1 minuto de dispersao
const JANELA_STALE = 30;       // servir valor antigo por ate 30s durante recarga

function ttlComJitter(sementeChave) {
  // Jitter deterministico por chave: mesma chave recebe sempre o mesmo
  // deslocamento, entao entradas diferentes expiram em momentos diferentes
  // sem que uma unica chave fique com TTL instavel entre recargas.
  let hash = 0;
  for (let i = 0; i < sementeChave.length; i += 1) {
    hash = (hash * 31 + sementeChave.charCodeAt(i)) | 0;
  }
  return TTL_BASE + (Math.abs(hash) % JITTER_MAX);
}

async function lerComProtecao(chave, buscarNoBanco) {
  const bruto = await cache.get(chave);

  if (bruto) {
    const entrada = JSON.parse(bruto);
    const idadeSegundos = (Date.now() - entrada.gravadoEm) / 1000;

    // Ainda dentro da validade logica: resposta direta.
    if (idadeSegundos < entrada.ttl) return entrada.valor;

    // Expirado ha pouco: serve o antigo e dispara recarga em background.
    // Apenas quem conseguir o lock recarrega; os outros seguem com o antigo.
    if (idadeSegundos < entrada.ttl + JANELA_STALE) {
      const lock = await cache.set(`lock:${chave}`, '1', { nx: true, ex: 10 });
      if (lock) {
        recarregar(chave, buscarNoBanco).catch((erro) =>
          logger.error({ erro, chave }, 'recarga em background falhou'),
        );
      }
      return entrada.valor;
    }
  }

  // Miss real ou entrada velha demais: apenas um vai ao banco.
  const lock = await cache.set(`lock:${chave}`, '1', { nx: true, ex: 10 });
  if (!lock) {
    // Perdeu a corrida: espera curta e tenta ler o que o vencedor gravou.
    await new Promise((resolve) => setTimeout(resolve, 50));
    const segundaTentativa = await cache.get(chave);
    if (segundaTentativa) return JSON.parse(segundaTentativa).valor;
  }

  return recarregar(chave, buscarNoBanco);
}

async function recarregar(chave, buscarNoBanco) {
  try {
    const valor = await buscarNoBanco();
    const ttl = ttlComJitter(chave);
    await cache.set(
      chave,
      JSON.stringify({ valor, gravadoEm: Date.now(), ttl }),
      // TTL fisico maior que o logico: a entrada sobrevive para poder
      // ser servida como stale enquanto a recarga acontece.
      { ex: ttl + JANELA_STALE + 10 },
    );
    return valor;
  } finally {
    await cache.del(`lock:${chave}`);
  }
}

O detalhe que faz esse código funcionar é a separação entre TTL lógico e TTL físico. A entrada guarda o próprio prazo de validade dentro do valor e continua existindo no Redis por mais alguns segundos depois disso, e é essa folga que permite servir o valor antigo enquanto a recarga acontece. Se o TTL do Redis fosse o único prazo, a expiração apagaria a entrada e não haveria valor antigo para servir, o que devolveria o problema da estampida exatamente onde ele estava.

05

Dois níveis de cache, dois níveis de dado velho

Quando o Redis vira gargalo ou quando o custo de rede por leitura começa a pesar, a resposta natural é adicionar um cache local dentro do processo. É uma decisão correta em termos de latência e é também o momento em que o problema de invalidação muda de natureza. Com o cache distribuído, invalidar era remover uma chave em um lugar. Com o cache local, invalidar passa a ser remover a mesma chave em todos os processos vivos, e não existe operação de remoção que alcance todos eles de forma síncrona e confiável.

CAMINHO DA INVALIDACAO EM DOIS NIVEIS

  escrita
    |
    +--> commit no banco
    |
    +--> DELETE no Redis                     <- imediato, um lugar
    |
    +--> PUBLISH invalidate:produto:42       <- broadcast, sem garantia
           |
           +--> pod A  recebe -> limpa L1    (~1ms)
           +--> pod B  recebe -> limpa L1    (~1ms)
           +--> pod C  reiniciando, PERDE a mensagem
                  |
                  +--> serve valor velho ate o TTL do L1 expirar

  ^ por isso o TTL do L1 precisa ser curto:
    ele e o limite superior do erro quando o broadcast falha

A regra que sai desse desenho é que o TTL do cache local não é um parâmetro de performance, é o tempo máximo de inconsistência que o sistema aceita quando a mensagem de invalidação se perde. Um processo que estava reiniciando, uma partição de rede de dois segundos ou um consumidor lento são suficientes para perder o broadcast, e nesse caso o único mecanismo que corrige o estado é a expiração. Por isso um cache local com TTL de cinco minutos e broadcast de invalidação é, no pior caso, um cache com cinco minutos de defasagem, e é assim que ele deve ser documentado, e não como um cache invalidado na escrita.

AspectoCache local (L1)Cache distribuído (L2)
Latência típicaNanossegundos a microssegundosSub-milissegundo mais rede
InvalidaçãoBroadcast sem entrega garantidaRemoção direta e confiável
Pior caso de defasagemO TTL local inteiroA duração da escrita
Comportamento no deployFrio a cada processo novoPreservado entre deploys
Consumo de memóriaMultiplicado pelo número de processosÚnico e compartilhado
Uso adequadoDado quente, pequeno e tolerante a segundosDado compartilhado e maior

A última linha resume o critério de escolha. O cache local rende quando o conjunto quente é pequeno, muito lido e tolerante a alguns segundos de defasagem, como configuração de tenant, tabela de feature flags ou catálogo de tipos. Ele é a escolha errada para qualquer dado cuja leitura errada tenha consequência externa, porque a inconsistência dele não é corrigível por invalidação, só por tempo.

06

Medir defasagem em vez de confiar na taxa de acerto

A métrica que todo painel de cache mostra é a taxa de acerto, e ela é praticamente inútil para o problema discutido aqui. Uma taxa de acerto de noventa e oito por cento pode significar um cache saudável ou um cache que serve dado velho com muita eficiência, e o gráfico é idêntico nos dois casos. Pior: quando a invalidação quebra, a taxa de acerto sobe, porque as entradas param de ser removidas. O indicador que deveria disparar o alerta se move na direção que parece boa.

O que precisa ser medido é a taxa de divergência, e ela se obtém por amostragem. Uma fração pequena das leituras que acertam o cache, algo entre um décimo e um por cento dependendo do volume, busca também o valor no banco, compara os dois e registra a diferença sem alterar a resposta enviada ao usuário. O custo é uma consulta extra em uma leitura entre mil, e o retorno é a única métrica que responde à pergunta que importa, que é com que frequência o sistema mente e por quanto tempo.

// Amostragem de divergencia: mede quanto o cache mente, sem afetar a resposta.
const TAXA_AMOSTRAGEM = 0.005; // 0,5% das leituras com acerto

async function lerComAuditoria(chave, buscarNoBanco, metadados) {
  const valorEmCache = await lerComProtecao(chave, buscarNoBanco);

  if (Math.random() < TAXA_AMOSTRAGEM) {
    // Fora do caminho da resposta: nunca aumenta a latencia do usuario.
    setImmediate(async () => {
      try {
        const valorReal = await buscarNoBanco();
        const divergente =
          JSON.stringify(valorEmCache) !== JSON.stringify(valorReal);

        metrics.increment('cache.amostra', {
          entidade: metadados.entidade,
          resultado: divergente ? 'divergente' : 'igual',
        });

        if (divergente) {
          // A idade da entrada e o dado mais util do alerta: ela diz se o
          // problema e TTL longo demais ou invalidacao que nao aconteceu.
          const entrada = JSON.parse((await cache.get(chave)) || '{}');
          logger.warn(
            {
              chave,
              entidade: metadados.entidade,
              idadeMs: entrada.gravadoEm ? Date.now() - entrada.gravadoEm : null,
            },
            'divergencia entre cache e banco',
          );
        }
      } catch (erro) {
        logger.debug({ erro, chave }, 'auditoria de amostra falhou');
      }
    });
  }

  return valorEmCache;
}

O campo de idade no log é o que transforma a métrica em diagnóstico. Se as divergências aparecem sempre em entradas com idade próxima do TTL, o problema é que o TTL está longo demais para a frequência de escrita daquela entidade, e a correção é ajustar o parâmetro. Se as divergências aparecem em entradas recém-gravadas, o problema é a invalidação: alguma escrita não está removendo a chave, ou está removendo antes do commit e sofrendo a reintrodução descrita antes. São duas causas distintas com duas correções distintas, e sem a idade o alerta apenas informa que algo está errado.

Vale definir o alerta por entidade e não de forma global, porque o limiar aceitável muda com a classificação feita na primeira seção. Divergência de meio por cento no catálogo é ruído esperado. A mesma divergência na lista de bloqueio é incidente, e o alerta precisa acordar alguém.

07

A leitura do próprio autor precisa ser consistente

Existe um caso que concentra quase todas as reclamações de suporte e que quase nenhuma política de TTL resolve: o usuário que acabou de escrever e imediatamente lê o resultado. Ele fez a transferência e abriu o extrato, mudou o endereço e voltou para o perfil, cancelou o pedido e atualizou a página. Nesse caso não existe tolerância a defasagem, porque a expectativa não é sobre o sistema em geral, é sobre a ação que a pessoa acabou de tomar. É o mesmo raciocínio de consistência de leitura da própria escrita que se aplica a réplicas de leitura, e a solução tem a mesma forma.

  1. Na conclusão de uma escrita, gravar uma marca de recência associada ao autor, tipicamente sessão ou identificador do usuário, com validade curta na casa de poucos segundos.
  2. Em toda leitura, verificar se existe marca ativa para aquele autor e entidade antes de consultar o cache.
  3. Se a marca existir, ignorar o cache e ler direto da fonte primária, aceitando a latência maior para aquela requisição específica.
  4. Se a marca não existir, seguir o caminho normal de cache, que atende a maioria esmagadora do tráfego.
  5. Dimensionar a validade da marca pelo maior atraso de replicação observado no percentil noventa e nove, e não pela média, porque é o pior caso que gera a reclamação.

A vantagem desse desenho é que ele paga o custo da consistência apenas onde ela é percebida. O volume de leituras que acontecem logo depois de uma escrita do mesmo autor costuma ser uma fração muito pequena do total, entre um e três por cento na maioria dos produtos, e é justamente essa fração que gera quase todos os tickets de suporte relacionados a cache. Trocar cache por consulta direta nesse recorte tem impacto desprezível na carga do banco e resolve a categoria de reclamação mais cara de investigar, porque ela nunca reproduz em ambiente de teste.

A mesma marca de recência serve para um segundo propósito que vale aproveitar. Quando ela existe, o sistema sabe que aquela entidade acabou de mudar, e isso é exatamente a informação necessária para decidir se vale ou não repopular o cache imediatamente. Repopular durante uma sequência de escritas do mesmo autor é desperdício, porque a próxima escrita vai invalidar de novo em segundos. Esperar a marca expirar antes de voltar a cachear evita esse ciclo e é uma linha a mais de condição.

FAQ

Perguntas frequentes

Como escolher o TTL quando o dado não tem uma frequência de mudança previsível?

A pergunta em si aponta para o erro: quando a frequência de mudança é imprevisível, o TTL não deve ser a política principal, e sim o limite de segurança de uma política baseada em invalidação. Ainda assim é preciso escolher um número, e a forma de fazer isso sem chutar é medir a distribuição real. Registre por uma semana o intervalo entre escritas consecutivas de cada entidade e olhe para o percentil dez, ou seja, o intervalo abaixo do qual estão os dez por cento de mudanças mais rápidas. Um TTL próximo desse valor faz com que a expiração raramente aconteça antes de uma mudança real, o que reduz recargas inúteis, ao mesmo tempo em que limita a janela de erro para o caso em que a invalidação falhou. Existe uma variação que funciona bem para entidades com comportamento heterogêneo, que é derivar o TTL da própria idade do dado: uma entidade que não muda há semanas provavelmente vai continuar assim e pode receber TTL longo, enquanto uma que mudou há dois minutos merece TTL curto. Isso se implementa comparando o timestamp de última atualização do registro com o momento da leitura e aplicando faixas, o que custa nada porque o timestamp já está no registro carregado. O que não funciona é escolher cinco minutos porque é o valor que apareceu no exemplo da biblioteca.

Vale a pena cachear resultado de consulta ou apenas entidades por identificador?

Cachear entidade por identificador é simples porque a invalidação é óbvia: quem escreve a entidade sabe exatamente qual chave remover. Cachear resultado de consulta, como uma listagem filtrada e paginada, é uma decisão diferente e muito mais cara, porque uma única escrita pode invalidar um número indeterminado de chaves e não existe forma barata de saber quais. Uma mudança de preço de um produto afeta toda listagem ordenada por preço que continha aquele produto, toda listagem filtrada por faixa de preço que passa a incluí-lo ou excluí-lo, e todas as páginas subsequentes de cada uma delas. Existem três saídas usadas na prática. A primeira é aceitar defasagem em consultas e usar TTL curto sem tentar invalidar, o que funciona quando a listagem é navegação e não decisão. A segunda é versionar o conjunto: manter um contador por entidade ou por tenant que entra na composição da chave da consulta, de modo que qualquer escrita incremente o contador e torne todas as chaves antigas inalcançáveis de uma vez, ficando a limpeza por conta do TTL. A terceira é cachear apenas os identificadores retornados pela consulta e resolver as entidades individualmente pelo cache por identificador, o que reduz a invalidação da consulta a mudanças que afetam o conjunto ou a ordem, e não a mudanças de conteúdo. A segunda opção é a que costuma dar melhor relação entre esforço e resultado, e a terceira é a que melhor aproveita o cache que já existe.

Quando é melhor remover o cache em vez de continuar corrigindo a política?

O sinal mais confiável é quando o esforço de manter a coerência passa a ser maior que o ganho de latência, e isso é mensurável em vez de subjetivo. Meça a latência real da consulta que o cache protege, no percentil noventa e cinco e com o volume atual, e não com o volume de quando o cache foi introduzido. É comum que a consulta tenha ficado rápida por outros motivos, um índice que entrou depois, uma normalização, uma redução de volume por arquivamento, e que o cache tenha continuado ali por inércia protegendo algo que já não é lento. Um segundo sinal é a proporção entre escrita e leitura: se a mesma entidade recebe escritas em ritmo comparável ao das leituras, o cache passa a gastar mais em invalidação do que economiza em consulta, e além disso mantém uma janela de erro sem entregar quase nada. Um terceiro sinal, mais qualitativo mas igualmente válido, é o número de casos especiais acumulados na camada de cache: quando existem mais condições para decidir se pode cachear do que lógica de negócio no mesmo arquivo, o cache virou o problema. Antes de remover, meça o impacto real desligando por trás de uma flag em uma fração do tráfego e comparando latência e carga do banco. É comum que a remoção não seja detectável nos gráficos, e é justamente esse resultado que autoriza apagar o código com segurança.

Cache é uma decisão de consistência disfarçada de performance

Todo cache introduz uma segunda fonte de verdade, e a única pergunta que importa é por quanto tempo o sistema pode servir a versão errada dela sem consequência. Responder isso por entidade, em vez de aplicar o mesmo TTL a tudo, é o que separa um cache que economiza banco de um que produz incidente de dados. Posso classificar as entidades do seu sistema por tolerância a defasagem, desenhar a invalidação durável que sobrevive a falha do Redis, implementar a proteção contra estampida com revalidação em segundo plano, instrumentar a amostragem de divergência que revela o problema antes do suporte e definir o caminho de leitura consistente para quem acabou de escrever.