Home Comunidade Eventos YouTube Networks Desafios // produtos Academy News CTI | CVE Contato

03 - Invalidação de Cache

#cache-busting#invalidacao#mutex#stampede#ttl
video explicativo
Video explicativoEspaco reservado para o video desta anotação — adicione 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.