Cache é uma decisão de consistência disfarçada
Cache é uma cópia, e toda cópia te obriga a decidir o quão desatualizado o usuário pode ver o mundo.
Toda vez que alguém diz "vamos só colocar um cache" eu ouço outra frase: "vamos manter uma segunda cópia desse dado e torcer para ela concordar com a primeira." Cache não é recurso de performance. É uma decisão de consistência que, por acaso, deixa as coisas mais rápidas.
No momento em que você tem duas cópias de um valor, precisa responder uma pergunta que estava evitando: quando elas discordam, qual vence, e por quanto tempo a errada pode ser servida?
A pergunta que faço antes de qualquer cache
Qual é o pior valor desatualizado que um usuário pode ver, e por quanto tempo? Essa pergunta sozinha define a estratégia. Uma descrição de produto com dez minutos de atraso não custa nada. Um saldo de conta com dez minutos de atraso pode gerar um chargeback. Uma verificação de permissão com dez minutos de atraso é um incidente de segurança.
Escreva a resposta. Se o time não consegue concordar sobre quanto atraso é aceitável, vocês não estão prontos para cachear esse dado. Estão prontos para discutir isso, o que é mais barato do que discutir em produção.
TTL, invalidação, write-through
TTL é a opção honesta. Você está dizendo "esse valor pode estar errado por até N segundos, e eu aceito isso." É simples, se cura sozinho, e a falha tem limite. A maior parte dos dados com muita leitura e baixo custo de erro cabe aqui.
Invalidação é a opção ambiciosa. Você promete apagar a entrada do cache sempre que a origem mudar. A promessa é fácil de fazer e difícil de cumprir, porque escritas acontecem em lugares que você esqueceu: jobs em lote, painel administrativo, script de migração, um segundo serviço que compartilha a tabela. Cada caminho esquecido é um valor velho sem expiração.
Write-through atualiza cache e banco juntos. Dá as leituras mais frescas, mas transforma o cache em parte do caminho de escrita, então uma queda do cache vira uma queda de escrita se você não projetar em volta disso. É a ferramenta certa para dados pequenos, quentes e sensíveis a erro, e errada para todo o resto.
Muitos sistemas reais misturam tudo: invalidam nos caminhos que controlam e mantêm um TTL curto como rede de segurança para os que esqueceram. Essa combinação não é preguiça. É admitir que invalidação nunca é completa.
Stampede, negativos e chaves
Quando uma chave popular expira, todas as requisições que chegam nos milissegundos seguintes dão miss ao mesmo tempo e batem no banco juntas. Isso é cache stampede, e costuma aparecer como um pico no banco exatamente na hora de maior tráfego. A correção é coalescer requisições: uma única busca em andamento por chave, e todo o resto espera esse resultado. Stale-while-revalidate é a mesma ideia com experiência melhor, servindo o valor antigo enquanto um worker atualiza.
Cacheie os misses também. Se buscar um usuário inexistente é caro, e bots ficam pedindo usuários inexistentes, um cache negativo com TTL curto poupa seu banco de responder o mesmo "não" mil vezes.
E cuidado com o que entra na chave. Tenant, idioma, moeda, feature flags, papel do usuário. Se qualquer um deles afeta a resposta e está fora da chave, um dia você vai servir os dados de um tenant para outro. Já vi exatamente esse bug em mais de um sistema que auditei, e sempre começou com uma chave que parecia completa no dia em que foi escrita.
Trate o cache como um contrato
Escreva o orçamento de atraso ao lado do código. Deixe o formato da chave num lugar só. Adicione uma métrica de hit rate e outra para a idade do valor servido. Quando alguém perguntar por que a página mostra um preço antigo, você deve responder com um número, não com um dar de ombros.
Um cache que você não consegue explicar é um bug que você ainda não achou.