Phil Karlton brincava que existem dois problemas difíceis em computação: naming, cache invalidation e off-by-one errors. A invalidação virou desculpa para não cachear, e o código de cache continua ocupando dez linhas em qualquer serviço. O resto do trabalho é escolher a chave, o TTL e o ponto onde a cópia morre.
Três camadas, três formas de invalidar
Uma aplicação web guarda cópias do mesmo dado em até três lugares:
- CDN (edge): imagens, CSS, JS bundle e páginas pré-renderizadas. TTL medido em dias, purga por URL.
- Cache de aplicação: Redis ou memória para sessões, configurações e queries repetidas. TTL medido em minutos, chave apagada pelo código.
- Banco de dados: prepared statements, índices e materialized views. O banco invalida junto com a escrita.
Você purga a CDN quando publica, executa um DEL na chave de Redis quando o registro muda e deixa o banco cuidar do próprio cache. Reaproveitar a estratégia de uma camada na outra é o caminho mais curto para servir dado velho com latência de cache.
Cache-aside com Redis
O padrão cache-aside faz o código perguntar ao cache antes do banco e preencher o cache no miss. O artigo cache-aside do Redis descreve o fluxo com um detalhe que importa em produção: quando uma chave popular expira sob carga, todos os leitores percebem o miss no mesmo instante e batem no banco ao mesmo tempo. Proteção contra stampede entra depois, com lock em Lua ou refresh probabilístico.
// Cache-aside pattern
async function getUser(id: string): Promise<User> {
const cacheKey = `user:${id}`
// 1. Pergunta ao cache
const cached = await redis.get(cacheKey)
if (cached) return JSON.parse(cached)
// 2. Miss: busca no banco
const user = await db.user.findUnique({ where: { id } })
if (!user) throw new AppError('NOT_FOUND', 404, 'Usuário não encontrado')
// 3. Grava no cache com TTL de 1 hora
await redis.setex(cacheKey, 3600, JSON.stringify(user))
return user
}
Meça taxa de hit antes de qualquer otimização. Cache com hit rate de 30% só transfere parte da carga do banco para o Redis e dobra o caminho de dados que você precisa observar.
TTL como rede de segurança
TTL é a invalidação que não precisa de código. Todo key ganha um tempo de vida e o EXPIRE do Redis descarta a chave quando o tempo vence. Dado que muda pouco recebe TTL longo; feed e ranking recebem TTL curto.
// Configuração muda por deploy: TTL de 24h
await redis.setex('config:app', 86400, JSON.stringify(config))
// Ranking recalculado a cada request: TTL de 5min
await redis.setex('feed:popular', 300, JSON.stringify(feed))
TTL curto também cobre o bug de invalidação esquecida: a cópia velha sobrevive no máximo o TTL. O guia do Amazon ElastiCache recomenda TTL em toda chave, inclusive nas que você atualiza por write-through, justamente para capturar essas falhas.
Invalidação no ponto de escrita
Quando o registro muda, você apaga as chaves afetadas junto com o update. O cache do objeto e o cache da listagem que o referencia são chaves diferentes e morrem no mesmo momento:
async function updateUser(id: string, data: UpdateUserDto): Promise<User> {
const user = await db.user.update({ where: { id }, data: data })
// Apaga o objeto e o índice que o referencia
await redis.del(`user:${id}`)
await redis.del('users:active')
return user
}
Write-through grava banco e cache na mesma operação. O custo aparece na latência de escrita, porque cada update vira duas viagens. Use write-through em dado que é lido logo depois de gravado, como carrinho e sessão; para o resto, delete a chave e deixe a próxima leitura repopular.
Headers que a CDN entende
// Assets com hash no nome: um ano
Cache-Control: public, max-age=31536000, immutable
// API autenticada: sempre revalida
Cache-Control: private, max-age=0, must-revalidate
ETag: "abc123"
// Página de conteúdo: serve stale e revalida em background
Cache-Control: public, max-age=3600, stale-while-revalidate=86400
stale-while-revalidate vem do RFC 5861 (Nottingham, 2010): o cache devolve a resposta vencida e revalida sem bloquear o cliente. O RFC 9111 (HTTP Caching, 2022) define o resto da negociação, incluindo o ETag usado no If-None-Match. Assets com hash no nome de arquivo ganham immutable e TTL de um ano sem risco, porque trocar o arquivo gera outro nome.
Cache warming depois do deploy
Deploy novo deixa os caches vazios e a primeira leitura de cada item paga o cold start. Precarregar os itens mais lidos no startup devolve a latência esperada no primeiro request do usuário:
async function warmCache() {
const popular = await db.product.findMany({
orderBy: { views: 'desc' },
take: 100
})
await Promise.all(popular.map(p =>
redis.setex(`product:${p.id}`, 7200, JSON.stringify(p))
))
}
A AWS descreve o mesmo procedimento como pre-population no whitepaper Performance at Scale with Amazon ElastiCache. Para dados de referência que não dependem de tráfego, o prefetch cache documenta a versão com sync por CDC.
O que fica fora do cache
Saldo em tempo real, preço promocional calculado por request e query simples que o banco responde em menos de 10ms ficam sem cache. Ler, gravar e invalidar custa mais do que a consulta original. Critério de decisão: repete a leitura do mesmo dado, a origem é lenta e você aceita o dado desatualizado dentro do TTL.
Referências
- Redis docs: cache-aside, EXPIRE e prefetch cache
- Amazon ElastiCache User Guide: Caching strategies (AWS)
- Performance at Scale with Amazon ElastiCache (AWS whitepaper)
- RFC 5861, HTTP Cache-Control Extensions for Stale Content (Nottingham, 2010)
- RFC 9111, HTTP Caching (Fielding, Nottingham e Reschke, 2022)
Curtiu o conteúdo?
Construo produtos web e soluções com IA do jeito certo — arquitetura sólida, código sustentável e entrega real.
Vamos conversar