Este é um post-mortem hipotético, montado a partir de um padrão que aparece em muitas lojas e plataformas de assinatura: o preço de um plano muda no banco, a tela continua mostrando o valor antigo e ninguém entende por quê. Nenhum sistema real é descrito aqui. O cenário serve para mostrar onde a invalidação de cache costuma quebrar e o que corrigir primeiro.
O cenário e a linha do tempo
Uma API de catálogo usa cache-aside em Redis. A leitura consulta o Redis; em caso de miss, vai ao Postgres e grava o resultado com TTL de 6 horas. A escrita atualiza o Postgres e, na linha seguinte do código, apaga a chave do cache. Parece correto, e na maior parte do tempo é.
Numa terça-feira, o time comercial reajusta o plano Pro durante uma campanha. A sequência, reconstruída pelos logs, foi esta:
- 10:02:11 — o endpoint de admin abre a transação e executa o UPDATE do preço.
- 10:02:11 — uma requisição de leitura chega, encontra a chave ainda populada e devolve o valor antigo. Até aqui, normal.
- 10:02:12 — o admin executa o DEL da chave, antes do COMMIT, porque o ORM só confirma a transação ao final do handler.
- 10:02:12 — outra leitura chega entre o DEL e o COMMIT, dá miss, lê do Postgres o valor ainda antigo (a transação não foi confirmada) e grava esse valor no cache com TTL cheio.
- 10:02:13 — o COMMIT acontece. O banco tem o preço novo; o cache tem o preço velho, e nada vai tocá-lo até o TTL expirar.
O resultado visível: parte dos usuários via o preço novo (as que caíam em outro nó de cache, após miss natural) e parte via o antigo. A discrepância durou até o suporte abrir chamado, depois de algumas dezenas de minutos.
Causa raiz: invalidar antes de confirmar
O defeito não está no Redis nem no TTL. Está na ordem: a invalidação ocorreu dentro da janela em que o banco ainda não expunha a mudança. Qualquer leitor que preencha o cache nessa janela grava um dado que já nasce obsoleto. O padrão aparece com mais frequência em handlers que misturam persistência e efeitos colaterais no mesmo bloco, com o commit implícito no fim.
redis-cli GET plano:pro em loop; no segundo, execute o UPDATE dentro de um BEGIN, o DEL manual, e só então o COMMIT, com uma leitura da aplicação no meio. Se o valor antigo reaparecer no cache depois do commit, a janela existe no seu código também.Por que os testes não pegaram
Os testes de integração rodavam em sequência: escrevem, leem, comparam. Uma corrida entre leitor e escritor exige concorrência real e, mais importante, uma transação aberta por tempo suficiente para o leitor passar no meio. Em ambiente de teste a transação durava milissegundos, e a janela era estreita demais para aparecer. Em produção, com o handler de admin fazendo validações e chamadas a serviços externos antes do commit, a janela era de centenas de milissegundos.
Um segundo fator: o monitoramento acompanhava taxa de acerto do cache e latência. Ambas ficaram saudáveis durante todo o incidente. Cache obsoleto bem servido tem hit rate excelente.
Um teste que reproduz a corrida de forma determinística evita depender de sorte. A ideia é pausar o escritor no meio da transação com um evento e deixar o leitor passar:
def test_cache_nao_fica_obsoleto(db, cache):
pausa, retomar = threading.Event(), threading.Event()
def escritor():
with db.begin():
db.execute("UPDATE planos SET preco = 199 WHERE id = 1")
pausa.set() # leitor entra aqui
retomar.wait() # commit só depois da leitura
t = threading.Thread(target=escritor); t.start()
pausa.wait()
ler_plano(1) # popula o cache com o valor ainda antigo
retomar.set(); t.join()
assert ler_plano(1).preco == 199
Com a invalidação antes do commit, esse teste falha de forma consistente; com a invalidação após o commit, passa. Vale mantê-lo como teste de regressão para qualquer entidade nova que ganhe cache.
Opções de correção, lado a lado
Há quatro caminhos comuns. Eles não são equivalentes em custo nem em garantia:
| Abordagem | Garantia | Custo | Quando usar |
|---|---|---|---|
| Invalidar após o commit | Fecha a janela principal; falha se o processo cair entre commit e DEL | Baixo | Catálogos e configurações com TTL como rede de segurança |
| Outbox transacional + consumidor que invalida | Invalidação garantida, com atraso pequeno | Médio: tabela, worker, monitoramento | Dados em que erro custa dinheiro ou compliance |
Chave versionada (plano:pro:v42) |
Leitor nunca mistura versões; a chave velha só deixa de ser lida | Médio: precisa de contador de versão confiável | Objetos lidos em muitos lugares |
| TTL curto, sem invalidação ativa | Obsolescência limitada ao TTL | Baixo na escrita, alto na carga do banco | Dados em que alguns minutos de atraso são aceitáveis |
Para este cenário, a escolha mais defensável é invalidar após o commit com TTL reduzido para 15 minutos. Preço visível ao cliente tolera segundos de atraso, mas não horas, e o outbox seria engenharia demais para um dado que muda poucas vezes por mês. Se o mesmo valor alimentasse faturamento, a resposta mudaria para outbox.
A chave versionada merece uma observação à parte, porque costuma ser a melhor resposta quando o dado é lido em muitos pontos. Em vez de apagar plano:pro, a escrita incrementa um contador (INCR plano:pro:versao) dentro do mesmo fluxo e os leitores montam a chave com a versão atual. A chave antiga fica órfã e expira sozinha por TTL. O ponto de atenção é o contador: se ele ficar em cache com TTL próprio, o problema só muda de lugar. Ele precisa ser a fonte de verdade ou ser lido diretamente do banco.
A correção em código
A mudança mínima é mover a invalidação para depois da confirmação. Com SQLAlchemy, por exemplo, o hook de pós-commit resolve sem reestruturar o handler:
from sqlalchemy import event
def agendar_invalidacao(session, chave):
chaves = session.info.setdefault("invalidar", set())
chaves.add(chave)
@event.listens_for(Session, "after_commit")
def invalidar_apos_commit(session):
for chave in session.info.pop("invalidar", set()):
redis.delete(chave)
@event.listens_for(Session, "after_rollback")
def descartar(session):
session.info.pop("invalidar", None)
O handler passa a chamar agendar_invalidacao(session, "plano:pro") no lugar do DEL direto. Se a transação falhar, nada é apagado; se confirmar, a chave some logo depois. A janela residual é a de um leitor que dê miss entre o commit e o DEL, e ele lerá o valor novo, que é o que se quer.
Resta o caso do processo morrer entre o commit e o DEL. O TTL de 15 minutos limita o estrago, e é por isso que ele não deve ser removido só porque existe invalidação ativa.
O que mudar fora do código
Corrigir a ordem resolve esta instância do problema, mas não impede a próxima.
Duas medidas pagam o custo desde o primeiro incidente. A primeira é um verificador de divergência: um job que, a cada poucos minutos, compara uma amostra de chaves de cache com a fonte e emite métrica de itens divergentes. Hit rate não detecta esse tipo de falha; divergência detecta. A segunda é registrar, em cada resposta de depuração, a idade do valor em cache, para que o suporte consiga dizer em segundos se o problema é dado velho ou bug de apresentação.
Por fim, vale revisar todos os handlers que fazem DEL no cache dentro de transação aberta. É uma busca de cinco minutos no repositório e costuma achar mais de uma ocorrência.
Um último cuidado diz respeito a réplicas de leitura. Se a aplicação lê do Postgres por uma réplica com atraso de replicação, um miss logo após o commit pode buscar o valor antigo na réplica e repopular o cache com ele, reabrindo a mesma falha por outro caminho. Para chaves recém-invalidadas, leia da primária ou aceite o TTL curto como compensação. Esse detalhe costuma passar despercebido porque o atraso de réplica, em operação normal, é de milissegundos, e só aparece sob carga ou durante manutenção, justamente quando ninguém está olhando o cache.
Deixe um comentário