03 - Invalidação de Cache
video: https://youtu.be/... no frontmatter da nota.Invalidação de Cache
Invalidação de cache é o conjunto de mecanismos que decide quando um dado em memória deixa de ser confiável e precisa ser removido ou renovado, e é, com folga, o problema mais difícil da engenharia de cache. A frase clássica da área ressoa porque existe uma verdade estrutural no chiste: os dois problemas fundamentais da ciência da computação são invalidar cache e nomear coisas, e a invalidação é mais difícil porque o erro não aparece como exceção, aparece como dado errado silenciosamente servido ao usuário. Em produção, a invalidação mal desenhada produz três sintomas que arruínam a confiança no sistema: dado obsoleto que volta depois de corrigido, páginas que mostram informação diferente por alguns minutos e picos de carga no banco quando chaves quentes expiram em sincronia. Este arquivo organiza as formas de invalidação (por TTL, por evento e por versão), o problema do stampede e suas contra-medidas com mutex e expiração probabilística, a purga seletiva por tag e o cache busting em deploy, com foco em decisões operacionais e não em truques isolados.
Conceito e o problema do dado obsoleto
O dado no cache é uma cópia com validade finita, e a invalidação é o mecanismo que decide quando essa validade termina. Existem apenas três formas fundamentais de encerrar a validade de uma chave: o tempo passa (TTL), o dado de origem muda (invalidação por evento) ou o formato/semântica muda (invalidação por versão). Toda estratégia de produção combina as três, com papéis diferentes.
O problema central é a diferença entre o que o cache sabe (que tem uma cópia) e o que o banco sabe (que a cópia mudou). O cache não percebe a mudança por conta própria, e o canal entre banco e cache precisa ser construído: ou o tempo cobre a defasagem (TTL curto), ou a aplicação anuncia a mudança (invalidação por evento), ou a chave muda de identidade (versionamento).
O custo do erro é assimétrico: um miss custa latência, um stale custa verdade. Servir preço antigo, saldo antigo ou status antigo é defeito de negócio, não de performance, e a tolerância a obsolescência é decisão de produto que a arquitetura precisa traduzir em TTL e invalidação por chave, sem valores mágicos globais.
A pergunta de desenho que organiza tudo: para cada namespace de chaves, qual é a janela máxima de obsolescência aceitável e quem é responsável por encurtá-la além do TTL. Se a resposta é ninguém, o TTL é tudo, e o TTL precisa ser curto o bastante para caber na janela aceita.
Invalidação por TTL: a linha de base
O TTL é a forma mais barata e universal de invalidação: cada chave carrega seu prazo, e o dado nunca sobrevive além dele. A propriedade essencial do TTL é que ele é rede de segurança: mesmo que toda a lógica de invalidação por evento falhe, bug, mensagem perdida, serviço fora, o dado obsoleto expira sozinho em tempo limitado.
A escolha do valor é um contrato de obsolescência. Um catálogo que pode estar defasado por 15 minutos usa TTL de 15 minutos e vive sem invalidação por evento; um status de pedido que não pode defasar mais que 30 segundos exige TTL de 30 segundos ou invalidação por evento mais agressiva. O custo aparece em carga no banco, que cresce inversamente ao TTL.
TTL longo demais cria a janela de stale que ninguém assumiu; TTL curto demais cria stampede permanente e banco sob ataque do próprio cache. O ajuste fino combina TTL base com jitter aleatório (10 a 20%) para evitar expirações em bloco, e monitora o hit ratio por namespace para validar a escolha.
Invalidação por evento: precisão com engenharia
Na invalidação por evento, a escrita no banco de origem dispara a remoção (ou renovação) das chaves afetadas. O mecanismo de disparo tem três variantes de produção: código da aplicação invalidando após o commit, captura de dados de mudança (CDC, como Debezium lendo o WAL/binlog) publicando eventos de mudança e consumers limpando as chaves, e mensageria com pub/sub do próprio Redis para fã-out entre instâncias.
A variante por código é simples e local: depois do commit, a aplicação deleta as chaves derivadas. O risco é cobertura: cada nova escrita em novo caminho de código precisa lembrar de invalidar, e o esquecimento vira stale silencioso. Testes de unidade para invalidação e convenção centralizada (função única de escrita por agregado) reduzem o problema.
A variante por CDC desacopla a invalidação da aplicação: o log de transações é a fonte de verdade, e o consumidor de eventos calcula quais chaves morrem com cada mudança. A confiabilidade é maior (o banco não esquece nada), e o custo é a infraestrutura de captura e a latência do pipeline, tipicamente de centenas de milissegundos a segundos.
A variante por pub/sub do Redis resolve o caso específico de cache local em múltiplas instâncias: uma instância invalida a chave no cache distribuído e publica a mensagem, e as demais limpam seu cache local ao receber. É o mecanismo que torna viável a combinação de cache local e distribuído descrita no arquivo anterior.
# Invalidação por evento em código, após o commit
def atualizar_produto(pid: int, dados: dict):
with db.transacao():
db.executar("UPDATE produtos SET nome=%s, preco=%s WHERE id=%s",
dados["nome"], dados["preco"], pid)
# invalidação das chaves derivadas, dentro do mesmo escopo lógico
cache.delete(f"catalogo:produto:v2:{pid}")
cache.delete(f"catalogo:categoria:v2:{dados['categoria_id']}:lista")
# Quando o conjunto de chaves derivadas cresce, centralize o mapeamento
def chaves_de_produto(pid: int, categoria: int) -> list[str]:
return [
f"catalogo:produto:v2:{pid}",
f"catalogo:categoria:v2:{categoria}:lista",
f"catalogo:busca:v1:recentes",
]
Invalidação por versão: cortar o nó de gordura
A invalidação por versão muda a identidade da chave em vez de deletá-la: o namespace ganha um número de versão (catalogo:v3:), e a nova versão de dado ou de código lê e escreve chaves novas, deixando as antigas expirarem pelo TTL. A leitura nunca vê dado da versão antiga, porque nunca procura por ele.
A variante de produção mais comum usa um contador de versão guardado no próprio cache: a chave meta:catalogo:versao guarda o número atual, a leitura compõe a chave com esse número e cada invalidação incrementa o contador, abandonando o conjunto inteiro de chaves de uma vez. É o mecanismo que permite invalidar milhares de chaves com uma única escrita atômica.
O custo é duplo: memória duplicada durante a transição (velhas expirando, novas sendo populadas) e uma leitura extra do contador (barata, frequentemente em cache local com TTL de segundos). O ganho é a purga de conjunto de chaves derivadas sem varredura de padrão, que em clusters grandes é caro e bloqueante.
Cache stampede e locking com mutex
Stampede (ou thundering herd de cache) acontece quando uma chave quente expira ou é invalidada e dezenas ou milhares de requisições simultâneas sofrem miss juntas: todas disparam a mesma consulta cara no banco, todas pagam o caminho completo, e o banco recebe um pulso de carga concentrada que pode derrubá-lo exatamente no pico de tráfego.
A contra-medida clássica é o locking de população com mutex: no miss, a aplicação tenta adquirir um lock curto (SET NX com TTL de poucos segundos) sobre a chave; quem consegue busca no banco e popula; quem não consegue aguarda brevemente e relê a chave, que agora estará populada pelo vencedor. O banco recebe uma única consulta por chave expirada, em vez de mil.
# População com mutex: um só leitor paga o banco, os demais esperam
def obter_com_mutex(chave: str, ttl: int, carregar) -> str:
valor = cache.get(chave)
if valor is not None:
return valor
# tenta virar o "vencedor" da população, lock de 3 segundos
if cache.set(f"lock:{chave}", "1", nx=True, ex=3):
try:
valor = carregar() # consulta cara, uma única vez
cache.set(chave, valor, ex=ttl)
finally:
cache.delete(f"lock:{chave}")
return valor
# perdedores: espera curta e re-tentativa de leitura
time.sleep(0.05)
valor = cache.get(chave)
if valor is None: # vencedor falhou, cai no banco
return carregar()
return valor
A segunda contra-medida é a expiração probabilística (XFetch): em cada leitura, a aplicação calcula a probabilidade de a chave estar próxima do vencimento e perto do custo de re-popular, e decide renovar antes do TTL vencer. A fórmula usa o tempo decorrido, o TTL e o custo estimado de recomputação; a propriedade importante é que a renovação acontece de forma descentralizada e espalhada no tempo, sem lock, evitando tanto o stampede quanto a espera dos perdedores do mutex.
A terceira camada de defesa é operacional: nunca deixar chaves críticas sem valor no pico. Pré-aquecimento do cache antes de eventos (deploy, campanha, Black Friday) e valor de fallback servido quando o lock expira sem população mantêm o p99 sob controle enquanto o dado é reconstruído.
A fórmula de decisão da expiração probabilística, na forma original de XFetch, calcula a probabilidade de renovação antecipada com base em três insumos: o tempo decorrido desde a população (delta), o prazo total (TTL) e a frequência de acesso, que pondera o custo da renovação antecipada. Em implementação prática, uma aproximação suficiente é renovar quando delta > TTL * 0.8 em uma fração aleatória de leituras proporcional ao custo de re-população, o que preserva a propriedade de espalhar as renovações no tempo sem orquestração central.
O padrão complementar stale-while-revalidate serve imediatamente o valor envelhecido e dispara a revalidação assíncrona em paralelo: o usuário nunca espera pelo dado novo, e o cache se renova em segundo plano. HTTP padroniza a ideia no header Cache-Control com a diretiva de mesmo nome, e bibliotecas de cache de aplicação replicam o comportamento; a combinação com o mutex cobre o caso do dado expirado sob tráfego intenso, servindo stale por alguns segundos em vez de parar.
| Contra-medida | Como age | Custo adicional | Quando usar |
|---|---|---|---|
| Mutex com SET NX | Uma população por chave, demais esperam | Espera curta e complexidade de lock | Chaves quentes com carga cara |
| TTL com jitter | Espalha expirações no tempo | Nenhum praticamente | Sempre, como base |
| Expiração probabilística | Renovação antecipada descentralizada | Risco de renovação desnecessária | Chaves caras e muito acessadas |
| Pré-aquecimento | Popula antes do pico | Automação dedicada | Deploy, eventos e campanhas |
| Fallback servido | Serve dado envelhecido no miss | Dado obsoleto temporário | Disponibilidade acima de frescor |
Purga seletiva por tag
Namespace e chave bem formatados não resolvem a invalidação de conjuntos derivados: um produto pertence a categoria, a listagens, a buscas e a recomendações, e uma mudança nele afeta dezenas de chaves sem prefixo comum. A purga por tag resolve esse mapeamento com dois índices: cada chave registra as tags que a descrevem (SADD tags:produto:42 catalogo:produto:42) e cada tag lista suas chaves (SADD chaves:tag:categoria:7 catalogo:produto:42).
A invalidação vira duas operações: buscar as chaves da tag e deletá-las em lote (DEL com múltiplas chaves ou UNLINK para deleção assíncrona em Redis), e limpar o índice da tag. O custo é memória de índice e manutenção de consistência entre índice e chaves, o ganho é a invalidação precisa de conjuntos derivados sem SCAN sobre o keyspace, operação cara e bloqueante em clusters grandes.
O padrão de leitura derivada completa o ciclo: listagens de categoria leem as chaves de item e agregam, e cada item carrega as tags de tudo que o afeta. A disciplina de tag é decisão de tempo de escrita do cache, e adicioná-la depois do fato exige repopulação completa.
Tags precisam da mesma higiene de namespace: convenção de nome (dominio:entidade:valor), limite de chaves por tag e política de expiração dos índices. Uma tag com milhões de chaves degrada a deleção em lote, e a decomposição do conjunto ou a invalidação por versão de conjunto passa a ser preferível.
Cache busting em deploy
Deploy de código é invalidação de massa disfarçada: mudanças de schema de serialização, de algoritmo computado ou de formato de resposta exigem que o cache novo nunca leia payload do cache velho. A prática direta é o cache busting: cada deploy incrementa a versão no prefixo de chaves (catalogo:v{BUILD}:), ou simplesmente destrói o cache ao subir, aceitando o período frio.
A escolha entre busting e warm-up depende do perfil de tráfego. Busting puro é simples e correto, com o custo de um banco sob pressão nos primeiros minutos; warm-up pré-deploy (popular as chaves quentes conhecidas com a versão nova antes de trocar o tráfego) elimina o pulso e é prática padrão em sistemas de alto tráfego.
A armadilha específica é a incompatibilidade de serialização sem mudança de chave: código novo desserializando payload de código antigo produz erro intermitente difícil de isolar. A regra preventiva é incluir a versão do formato na chave sempre que o payload mudar, e testes de contrato sobre o formato serializado pegam o resto.
Erros comuns e boas práticas
- Confiar só no TTL longo como estratégia de invalidação; a janela de stale assume tamanho que ninguém aceitou, e o valor correto é contrato por namespace, documentado.
- Invalidar antes do commit da transação: a chave morre, o próximo leitor repopula com dado antigo, e a invalidação fica sem efeito; invalidar sempre depois do commit, com o próprio dado novo em mãos quando possível.
- Usar KEYS ou SCAN em produção para achar chaves e deletar; operações de varredura bloqueiam ou saturam o cluster, purga por tag com índice de sets é o caminho.
- Mutex sem TTL no lock: se o vencedor morre no meio da população, o lock eterno trava a chave para sempre; todo lock de cache precisa de expiração curta e caminho de recuperação.
- Esquecer a invalidação no logout e nas mudanças de permissão; sessão e autorização em cache exigem deleção explícita em eventos de segurança, não espera por expiração.
- Invalidar por evento sem alarme de falha no pipeline; CDC parado ou consumer morto significa stale crescente invisível, e a defasagem entre banco e cache precisa de métrica e alerta.
- Trocar formato de serialização sem trocar a chave; erro intermitente pós-deploy é o sintoma, versão de formato no prefixo é a prevenção.
- Deixar o pré-aquecimento fora do runbook de deploy; cache frio em pico é incidente previsível, e automação de warm-up das chaves críticas é parte do deploy, não extra.