Post-mortem hipotético: o cache que serviu preço antigo depois do commit

Escrito por

em

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.

Na prática: reproduza a corrida em dev com dois terminais. No primeiro, rode 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.

Comentários

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *