Por que usar cache?
Um cache é uma cópia de dados mantida em algum lugar de acesso mais rápido do que a fonte original. Toda decisão de cache é, na verdade, uma aposta: a de que o mesmo valor será solicitado novamente antes de ser alterado, e que servir uma cópia ligeiramente desatualizada é aceitável. Quando ambas as condições são verdadeiras, o ganho é enorme.
Três efeitos resultam de um único hit:
- Latência. Uma consulta no Redis responde em bem menos de um milissegundo; a mesma linha no PostgreSQL custa vários milissegundos de rede, planejamento e I/O. Em uma página que lê vinte itens, essa diferença define toda a experiência.
- Carga. Se 95% das leituras forem servidas pelo cache, o banco de dados processará uma única query onde antes processava vinte. Você consegue sobreviver a um pico de tráfego, ou rodar uma instância menor, sem alterar uma única linha da lógica de query.
- Custo. Menos leituras no banco de dados significam instâncias menores, menos réplicas de leitura e menos tráfego entre regiões. Em escala, o caching é uma das poucas otimizações que se paga financeiramente.
A contrapartida é a complexidade. Um cache é uma segunda cópia da verdade, e cada cópia pode divergir. A maior parte deste guia é sobre manter essa divergência pequena e temporária.
Onde um cache pode residir
O caching não é um sistema único, mas sim uma pilha de camadas, cada uma mais próxima do usuário e cada uma com uma lógica de invalidação diferente.
- O navegador. Respostas HTTP marcadas como
Cache-Control: max-agesão reutilizadas sem a necessidade de uma requisição. Isso é gratuito, privado e está amplamente sob o controle do cliente. - A CDN ou edge. Um cache compartilhado à frente da sua origin absorve o tráfego de respostas públicas. É rápido e global, mas um erro aqui é visível para todos.
- A aplicação. Um cache in-process (um
Map, um LRU) é a camada mais rápida porque evita completamente a rede. Ele também é por instância, portanto, deve ser pequeno e descartável. - Um store compartilhado. Redis ou Memcached ficam ao lado da aplicação e servem todas as instâncias a partir de uma cópia consistente. É aqui que a maior parte do caching de aplicação reside.
- O banco de dados. Buffer pools, materialized views, prepared plans e caches de resultados de query mantêm a fonte rápida por conta própria. Eles são a última linha antes do disco.
Uma requisição pode ser respondida em qualquer uma dessas camadas. Quanto mais próxima a resposta, mais rápida ela é e mais difícil é invalidá-la, o que é a tensão central de todo este assunto.
Cache hit rate: a métrica decisiva
A saúde de um cache é medida pelo seu hit rate: hits divididos por hits mais misses. Este é o único número que indica se o cache está sendo útil.
hit rate = hits / (hits + misses)
A aritmética é drástica. Com um hit rate de 90%, o banco de dados recebe uma em cada dez requisições, uma redução de 10x. Com 50%, ele recebe uma em cada duas, apenas uma redução de 2x, e o cache adicionou um salto de rede (network hop) a metade do tráfego. Um cache com hit rate baixo pode ser mais lento do que não ter cache nenhum.
Meça isso a partir do próprio store. O Redis reporta keyspace_hits e keyspace_misses em INFO stats, e as bibliotecas de cliente expõem os mesmos contadores. Monitore o hit rate após cada alteração em uma chave, em um TTL ou em uma query; uma pequena mudança na chave pode derrubar silenciosamente um cache de 95% para 40%.
O corolário é que você deve cachear coisas que sejam realmente repetidas. Uma chave única por requisição tem um hit rate de 0% por construção e apenas desperdiça memória.
Os quatro padrões principais
Quase toda implementação de cache segue um de quatro modelos. Eles diferem em quem preenche o cache e quando o cache é atualizado.
Cache-aside (lazy loading)
A aplicação detém a lógica. Em uma leitura, ela verifica o cache e, em caso de miss, carrega do banco de dados (ou fonte) e grava o resultado de volta. Este é o padrão padrão por ser simples, funcionar com qualquer store e fazer o cache apenas de dados que são realmente solicitados.
async function getUser(id: string) {
const key = `user:${id}`;
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const user = await db.user.findUniqueOrThrow({ where: { id } });
await redis.set(key, JSON.stringify(user), "EX", 600);
return user;
}
O custo é que o primeiro leitor sempre paga o preço total (latência), e o cache pode conter dados obsoletos até que seu TTL expire ou que uma gravação os remova.
Read-through
O Read-through move o fallback para dentro da própria camada de cache: a aplicação sempre solicita ao cache, e o cache é configurado com um loader que é executado em caso de miss. Algumas bibliotecas e proxies implementam isso, de modo que o código da aplicação não possui um caminho explícito de miss. O comportamento é o mesmo do cache-aside; apenas a localização da lógica muda.
Write-through
O Write-through atualiza o cache e a fonte na mesma operação. As leituras estão sempre “quentes” e nunca veem dados mais antigos do que a última gravação bem-sucedida.
async function updateUser(id: string, patch: Partial<User>) {
const user = await db.user.update({ where: { id }, data: patch });
await redis.set(`user:${id}`, JSON.stringify(user), "EX", 600);
return user;
}
O custo é a latência de gravação e o fato de fazer o cache de dados que podem nunca ser lidos, mas ele mantém o cache consistente com o fluxo de gravação, que é exatamente o que atualizações voltadas para o usuário precisam.
Write-behind (write-back)
O Write-behind grava no cache imediatamente e descarrega (flush) para a fonte de forma assíncrona. As gravações são muito rápidas e podem ser agrupadas em lotes, o que é valioso para contadores e telemetria. O risco é grave: se o cache cair antes do flush, a gravação é perdida. Use-o apenas onde a perda das últimas gravações seja aceitável, e nunca para transações financeiras ou registros oficiais.
TTLs e freshness
Todo valor em cache deve ter um time to live. O TTL é um orçamento de obsolescência: é o tempo máximo que um leitor pode visualizar um valor que não é mais verdadeiro na fonte. Escolhê-lo é tanto uma decisão de produto quanto técnica.
- Preços e inventário: segundos. Um preço errado gera um ticket de suporte.
- Perfis, feeds e listagens: minutos. Pequenas divergências são invisíveis.
- Dados de referência, feature flags e configuração: de minutos a horas.
- Assets estáticos e conteúdo que raramente muda: dias, versionados pelo nome do arquivo.
Adicione jitter ao TTL. Se mil chaves forem gravadas no mesmo instante com o mesmo TTL, elas expirarão juntas e causarão um pico de carga. Randomizar em alguns poucos por cento distribui os recarregamentos.
const ttl = 600 + Math.floor(Math.random() * 60);
await redis.set(key, JSON.stringify(value), "EX", ttl);
Mesmo com a invalidação explícita, mantenha um TTL como rede de segurança. Um bug que esqueça de deletar uma chave deve custar a você alguns minutos de obsolescência, e não uma mentira permanente.
Invalidação: a parte difícil
Existe um motivo para a invalidação de cache ser a piada mais antiga da ciência da computação. O valor é trivial de armazenar, mas genuinamente difícil de remover no momento certo, de todas as camadas e sem condições de corrida (races).
Três técnicas cobrem quase todos os casos.
Versionamento de chaves. Em vez de deletar, altere a chave. Adicione um prefixo de versão às chaves que você incrementa quando os dados subjacentes mudam; assim, as entradas antigas tornam-se inacessíveis e expiram por conta própria.
const version = (await redis.get(`user:${id}:v`)) ?? "1";
const key = `user:${id}:v${version}`;
Isso é livre de races e funciona entre instâncias, por isso é a abordagem preferida para qualquer recurso compartilhado.
Bust explícito. Delete a chave no momento da escrita. É a abordagem mais óbvia e a mais fácil de errar, pois cada caminho de escrita deve lembrar de executá-la.
await db.user.update({ where: { id }, data: patch });
await redis.del(`user:${id}`);
Invalidação orientada a eventos. Publique um evento de alteração e deixe que cada instância e camada reaja. É assim que um purge de CDN ou um cache de múltiplos serviços é mantido atualizado, e isso escala para sistemas que você não controla.
Independentemente da escolha, seja consistente com a ordenação e prefira deletar a sobrescrever em atualizações. Deletar é idempotente; sobrescrever pode ressuscitar um valor que um escritor concorrente já havia superado.
Cache stampede e thundering herd
Quando uma chave popular expira, todas as requisições em andamento falham no mesmo instante. Se chegarem mil requisições por segundo e a chave levar 200 ms para ser reconstruída, centenas de consultas idênticas atingirão o banco de dados simultaneamente. Isso é um cache stampede, também chamado de thundering herd, e pode derrubar um serviço que estava saudável momentos antes.
Quatro formas de mitigação, em ordem aproximada de preferência:
- Stale-while-revalidate. Serve o valor obsoleto (stale) imediatamente e o atualiza em segundo plano. Quem lê nunca espera e a fonte de dados recebe apenas uma atualização. O HTTP possui um header exatamente para isso.
- Locking ou single-flight. Apenas o primeiro chamador recomputa o valor; todos os outros esperam brevemente ou recebem os dados obsoletos. O Redis
SET key value NX EXé um lock distribuído simples. - TTLs com jitter. Distribua os prazos de expiração para que todas as chaves não expirem ao mesmo tempo.
- Expiração precoce probabilística. Cada leitura tem uma chance pequena e crescente de atualizar o valor antes que a chave expire de fato, distribuindo a atualização entre várias requisições.
O lock deve sempre ter um tempo de expiração, caso contrário, um worker que trave deixará a chave bloqueada para sempre.
O que colocar em cache e o que nunca colocar
Faça o cache de dados que sejam caros para produzir, lidos com muito mais frequência do que mudam, tolerantes a pequenas defasagens (staleness) e compartilhados entre muitos leitores. Listagens de produtos, perfis públicos, configurações, fragmentos renderizados e agregados complexos são todos bons candidatos.
Nunca faça o cache de:
- Decisões de autorização em um cache compartilhado. Uma mudança de cargo ou logout deve surtir efeito imediatamente, e um CDN nunca deve servir a resposta privada de um usuário para outro.
- Dados sensíveis por usuário sob uma chave compartilhada. Cada valor específico de usuário precisa de uma chave que inclua o usuário, e uma diretiva de cache
privatecaso chegue a um edge. - Secrets e credenciais. O cache é mais um lugar de onde eles podem vazar.
- Dados que você não consegue invalidar. Se um valor não possui uma chave natural nem um evento, o cache eventualmente servirá algo errado sem haver forma de corrigir.
Um teste útil: se você não consegue descrever como um valor sai do cache, não o coloque lá.
Cache HTTP na edge
O cache mais barato é aquele que nunca chega ao seu servidor. O HTTP oferece controle preciso sobre isso.
Cache-Control: public, max-age=60, s-maxage=600, stale-while-revalidate=30
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"
Vary: Accept-Encoding
max-age define por quanto tempo o navegador pode reutilizar a resposta. s-maxage sobrescreve isso para caches compartilhados, como um CDN. stale-while-revalidate permite que a edge sirva uma cópia obsoleta enquanto busca uma nova em segundo plano, o que evita stampedes em conteúdos públicos. private e no-store mantêm respostas sensíveis totalmente fora de caches compartilhados.
Um ETag permite requisições condicionais. O cliente envia If-None-Match e, se a tag ainda coincidir, você responde 304 Not Modified sem corpo, economizando largura de banda e garantindo a atualização dos dados.
app.get("/posts", async (req, res) => {
const body = JSON.stringify(await listPosts());
const tag = `"${createHash("sha256").update(body).digest("hex")}"`;
res.set("Cache-Control", "public, max-age=30, stale-while-revalidate=60");
res.set("ETag", tag);
if (req.headers["if-none-match"] === tag) return res.status(304).end();
res.type("application/json").send(body);
});
Vary é fácil de esquecer e é importante: se uma resposta depende de Accept-Encoding ou Accept-Language, especifique isso, caso contrário, um cache entregará a variante errada para o cliente errado.
In-memory vs Redis vs CDN
As três camadas de compartilhamento não são concorrentes; elas formam uma hierarquia.
Um cache in-process é a busca mais rápida possível porque nunca atravessa a rede. É ideal para dados pequenos, “quentes” e predominantemente de leitura, como configurações ou um mapa de permissões. Suas limitações são que cada instância possui sua própria cópia — portanto, a memória é multiplicada e os valores podem divergir — e ele desaparece a cada deploy.
O Redis é o cache compartilhado da aplicação. Uma única cópia lógica serve a todas as instâncias, sobrevive a reinicializações e suporta TTLs, operações atômicas e estruturas de dados além de simples strings. O custo é um round-trip de rede, geralmente bem abaixo de um milissegundo na mesma rede.
Uma CDN é a camada mais externa. Ela armazena em cache respostas públicas próximas aos usuários ao redor do mundo e pode absorver um tráfego enorme antes que ele chegue até você. Ela funciona apenas para respostas que são idênticas para muitos usuários, e a limpeza (purging) do cache é uma operação deliberada e, às vezes, lenta.
Uma estrutura comum de produção é ter um pequeno cache in-process na frente do Redis para as chaves mais acessadas, com uma CDN na frente de toda a API para requisições GET públicas.
Negative caching
Geralmente, descreve-se os caches como armazenadores de valores, mas armazenar a ausência de um valor é igualmente útil. Se a consulta por um registro inexistente for custosa e repetitiva, faça o cache desse “miss”.
const hit = await redis.get(key);
if (hit === "__miss__") return null;
const user = await db.user.findUnique({ where: { id } });
if (!user) {
await redis.set(key, "__miss__", "EX", 60);
return null;
}
Mantenha os TTLs negativos curtos, pois um registro que não existia há um minuto pode existir agora. Esse padrão protege contra o cache penetration, onde uma enxurrada de requisições por chaves inexistentes ignora o cache e atinge o banco de dados todas as vezes. Proteja-se de um atacante que gere infinitas chaves inexistentes e únicas validando a entrada e aplicando rate limiting antes do cache.
Consistência e os dois problemas difíceis
Um cache torna um sistema eventualmente consistente: por um curto período, leitores diferentes podem ver valores diferentes. Geralmente isso não é um problema, mas há algumas situações em que é.
- Read-your-writes. Um usuário que acabou de atualizar seu perfil espera ver a alteração. Invalide no momento da escrita ou leia da fonte original por um curto período após uma escrita feita pelo mesmo usuário.
- Replication lag. Se as leituras forem direcionadas a uma réplica, um cache preenchido a partir dessa réplica pode estar defasado em relação ao primário. Invalide a partir do caminho de escrita do primário.
- Caches cross-region. Uma purga em uma região não atinge instantaneamente outra. Utilize versionamento de chaves ou aceite o atraso de propagação.
A abordagem honesta é que o caching troca consistência por velocidade, e a única maneira de fazer isso com segurança é decidir explicitamente qual nível de defasagem é aceitável e integrar a invalidação ao caminho de escrita, em vez de tentar adicioná-la posteriormente.
Chaves de cache são uma API
Uma chave de cache parece um detalhe de implementação, mas se comporta como uma interface pública. É aquilo com que todo leitor e escritor devem concordar, e fica visível no Redis, em slow logs e em dashboards. Projete as chaves deliberadamente.
Três regras as mantêm organizadas:
- Namespace por versão e ambiente.
v1:user:42permite que você altere a estrutura posteriormente e evita que o staging compartilhe chaves com a produção. - Seja determinístico. A mesma busca lógica deve sempre produzir a mesma string. Normalize o case, faça o trim da entrada e nunca inclua um valor que mude a cada chamada.
- Nunca coloque segredos ou dados pessoais em uma chave. Chaves são registradas em logs, exportadas e exibidas para operadores.
export const cacheKeys = {
user: (id: string) => `v1:user:${id}`,
userPosts: (id: string, cursor: string) => `v1:user:${id}:posts:${cursor}`,
productBySku: (sku: string) => `v1:product:sku:${sku.trim().toLowerCase()}`,
};
Centralizar a construção de chaves em um único módulo é o que torna a invalidação possível. Quando uma escrita precisa remover uma chave, ela chama a mesma função que a leitura utilizou; quando a estrutura muda, há um único lugar para atualizar a versão.
Políticas de despejo (Eviction policies)
Um cache que pode crescer sem limites é basicamente um memory leak com etapas extras. Tanto o Redis quanto caches in-process precisam de um teto e de uma política sobre o que remover quando esse limite é atingido.
O Redis expõe maxmemory e maxmemory-policy. A escolha comum para um cache puro é allkeys-lru, que remove a chave menos recentemente utilizada, ou allkeys-lfu, que favorece as chaves usadas com frequência quando os padrões de acesso são desequilibrados.
redis-cli CONFIG SET maxmemory 2gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
Nunca use noeviction para um cache: assim que a memória estiver cheia, as escritas falharão e sua aplicação começará a lançar erros em vez de apenas registrar um cache miss. Para caches in-process, use uma biblioteca LRU limitada em vez de um Map simples, e defina tanto um limite máximo de entradas quanto um tamanho máximo.
Despejos (evictions) não são falhas, mas um aumento na contagem de evictions é um sinal. Isso significa que o working set não cabe mais na memória e a hit rate está prestes a cair. Ou você fornece mais memória ao cache, ou armazena menos dados.
Cache warming
O cache está no seu estado mais “frio” justamente no momento mais perigoso: logo após um deploy, um restart ou um scale-out. Cada instância começa vazia, o tráfego chega com volume total e a fonte de dados recebe a carga completa até que o cache seja preenchido. Isso é um stampede causado pelo seu próprio deployment.
Duas abordagens funcionam. O Lazy warming aceita a janela de cache frio e conta com locking e stale-while-revalidate para sobreviver a ela. É simples e não exige infraestrutura extra. O Proactive warming executa um job que carrega as hot keys conhecidas antes que a nova versão comece a receber tráfego.
async function warmCache() {
const popular = await db.product.findMany({
orderBy: { views: "desc" },
take: 500,
select: { id: true },
});
for (const { id } of popular) {
await cached(`product:${id}`, 300, () =>
db.product.findUniqueOrThrow({ where: { id } }),
);
}
}
Aqueça apenas o que você sabe que é “hot”. Tentar aquecer tudo é apenas criar uma cópia lenta e cara do banco de dados que, na maioria das vezes, será descartada sem nunca ter sido usada.
Observabilidade para caches
Um cache que você não mede é um cache no qual você não pode confiar. Quatro números contam a história.
- Hit rate — as leituras estão realmente sendo servidas pelo cache?
- Latency — um hit é significativamente mais barato que a fonte, incluindo o salto de rede?
- Evictions — o working set está superando a memória disponível?
- Errors and timeouts — o cache está se tornando uma fonte de falhas?
redis-cli INFO stats | grep -E 'keyspace_(hits|misses)|evicted_keys'
redis-cli INFO memory | grep used_memory_human
Emita também um counter para hits e misses a partir da aplicação, tagueado pelo nome do cache. É isso que permite correlacionar uma queda no hit-rate com um deploy, e é a primeira coisa a se verificar quando a carga do banco de dados aumenta sem um motivo óbvio.
Degradação suave quando o cache está fora do ar
Se a perda do cache derrubar a API, o cache tornou-se um ponto único de falha para um sistema cujo propósito principal é ser opcional. A fonte da verdade ainda existe; a aplicação deve ser capaz de alcançá-la.
Envolva cada operação de cache para que uma falha se torne um miss em vez de um erro, mantenha os timeouts curtos e considere um circuit breaker que interrompa as chamadas a um cache com falhas por um período de resfriamento.
async function safeGet(key: string) {
try {
return await redis.get(key);
} catch (err) {
logger.warn({ err, key }, "cache_read_failed");
return null;
}
}
O mesmo se aplica às escritas no cache: um set com falha nunca deve causar a falha da requisição. A única exceção é o write-behind, onde o cache faz parte do caminho de escrita; esse é mais um motivo para reservá-lo para dados que você pode se dar ao luxo de perder.
Cacheando queries e agregados custosos
Nem tudo que vale a pena cachear é uma única linha. Joins custosos, agregados de dashboards e resultados de busca costumam ser os melhores candidatos, pois são lentos para computar e mudam com pouca frequência.
Uma materialized view é um cache que reside no banco de dados: ela armazena o resultado de uma query e é atualizada periodicamente ou sob demanda. Ela oferece o frescor de um batch job com a velocidade de leitura de uma tabela.
CREATE MATERIALIZED VIEW daily_revenue AS
SELECT date_trunc('day', created_at) AS day,
sum(total_cents) AS revenue_cents
FROM orders
WHERE status = 'paid'
GROUP BY 1;
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_revenue;
Para resultados que são dinâmicos demais para uma view, cacheie a resposta serializada no Redis sob uma chave que inclua cada entrada que a afete — filtros, ordem de classificação e página. Duas requisições para páginas diferentes resultam em valores diferentes, portanto, devem ter chaves diferentes.
Testando um cache
Bugs de cache são do tipo que passam nos testes e falham em produção, portanto, teste explicitamente os comportamentos que importam: o caminho de miss, o caminho de hit, a invalidação e a falha.
test("loads once and serves the second read from cache", async () => {
let calls = 0;
const load = async () => {
calls += 1;
return { id: "1" };
};
await cached("user:1", 60, load);
await cached("user:1", 60, load);
expect(calls).toBe(1);
});
test("falls back to the source when the cache is unavailable", async () => {
redis.get = async () => {
throw new Error("connection_refused");
};
await expect(cached("user:1", 60, load)).resolves.toEqual({ id: "1" });
});
Use um Redis real em um container para testes de integração, ou um fake em memória que implemente get, set e del. Teste também os casos negativos: uma atualização que deveria invalidar, um TTL que deveria expirar e uma queda do cache que deveria causar uma degradação em vez de uma falha total.
Melhores práticas
- Faça cache apenas após medir; adicione cache a leituras lentas e repetitivas, não por padrão.
- Atribua um TTL a cada chave, mesmo quando você também realizar a invalidação explicitamente.
- Use chaves determinísticas e com namespaces, como
user:42:profile, e versione-as para dados compartilhados. - Prefira deletar ou versionar chaves em vez de sobrescrevê-las durante a atualização.
- Adicione jitter aos TTLs e use stale-while-revalidate para sobreviver a stampedes.
- Mantenha dados de autorização e por usuário fora de caches compartilhados; marque as respostas como
private. - Trate o cache como opcional no código, para que uma queda cause degradação em vez de falha total.
- Monitore a taxa de acerto (hit rate) e a contagem de evicções, não apenas a latência.
- Faça cache de misses, assim como de hits, quando a busca por dados ausentes for custosa.
Erros comuns
- Fazer cache sem um TTL e depender de uma limpeza manual que nunca acontece.
- Compartilhar a mesma chave entre usuários e vazar dados de uma pessoa para outra.
- Invalidar o cache em alguns caminhos de escrita, mas não em outros.
- Definir o mesmo TTL para todas as chaves, fazendo com que todas expirem ao mesmo tempo.
- Fazer cache de uma decisão de autorização que deveria ter sido revogada após a alteração de um cargo.
- Usar um cache como banco de dados e perder gravações quando ele reinicia.
- Esquecer
Varye servir a codificação de conteúdo errada a partir de um CDN. - Não medir nada, fazendo com que a alteração de uma chave derrube a taxa de acerto (hit rate) silenciosamente.
- Fazer cache de algo único por requisição, o que garante uma taxa de acerto de 0%.
Próximos passos
O cache é apenas uma das ferramentas para controlar a carga; seus companheiros naturais são as outras técnicas que limitam o volume de trabalho. O guia de Redis aprofunda-se no armazenamento onde a maioria dos caches é construída, e a Paginação é a versão desse mesmo conceito aplicada ao nível da requisição: nunca faça mais trabalho do que o necessário. Se o banco de dados por trás do cache for o gargalo, o Connection Pooling reduz o custo de cada conexão, e o guia de PostgreSQL cobre a fonte da verdade que você está protegendo.