Performance

Estratégias de Caching

Caching é como os sistemas permanecem rápidos sob carga. Os padrões são simples; a parte difícil é saber o que manter, por quanto tempo e quando descartar.

intermediate15 min readUpdated 16 de set. de 2026
cache-aside.ts
ts
// cache-aside.ts
export async function getProduct(id: string): Promise<Product> {
  const key = `product:${id}`;

  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached) as Product;

  const product = await db.product.findUniqueOrThrow({ where: { id } });
  await redis.set(key, JSON.stringify(product), { EX: 300 });
  return product;
}
Objetivo principal
Menor latência e carga
Padrão padrão
Cache-aside
TTL típico
60s a 1h
Armazenamento comum
Redis ou in-process
Parte difícil
Invalidação
Camada de Edge
CDN mais headers HTTP

Por que importa

Por que o caching muda todo o sistema

Latência que você sente

Um cache hit retorna em bem menos de um milissegundo, enquanto a ida e volta ao banco de dados que ele substitui custa dezenas de milissegundos. Em uma página que lê vinte itens, essa diferença define toda a experiência.

Carga que o banco de dados nunca vê

Servir a maioria das leituras a partir do cache pode reduzir as queries ao banco de dados em uma ordem de magnitude, o que gera folga sem a necessidade de migrar para uma instância maior.

Uma ideia, muitas camadas

O mesmo raciocínio se aplica no browser, na CDN, dentro da aplicação e no banco de dados, cada um com sua própria história de frescor e invalidação.

O panorama completo

As três ideias por trás de todo cache

Armazene uma cópia mais próxima do leitor, sirva-a até que fique obsoleta e decida deliberadamente como ela sai.

Posicionamento

Localizar

Coloque a cópia o mais próximo possível do leitor, conforme a correção permita. Quanto mais perto, mais rápido, porém mais difícil de invalidar.

Armazenamento

Servir

Um armazenamento in-memory responde leituras sem tocar no disco, e um armazenamento compartilhado garante que cada instância veja os mesmos dados.

Frescor

Expirar

Cada valor em cache precisa de uma regra sobre por quanto tempo permanece válido e como é substituído quando deixa de ser.

HTML5 de uma olhada

Onde um cache pode residir

Cache do browser

Cache-Control e ETag permitem que o cliente reutilize uma resposta sem perguntar ao servidor.

CDN e edge

Caches compartilhados na edge absorvem tráfego para respostas públicas e cacheáveis.

Memória da aplicação

Um Map in-process ou cache LRU é a camada mais rápida, mas vive e morre com cada instância.

Redis ou Memcached

Um armazenamento in-memory compartilhado fica ao lado da app e serve cada instância de forma consistente.

Cache do banco de dados

Buffer pools, materialized views e prepared plans mantêm a própria fonte rápida.

Invalidação

TTL, versionamento de chaves ou busts explícitos decidem quando uma cópia obsoleta para de ser servida.

Fluxo

Como funciona a leitura cache-aside

O cache é consultado primeiro, e apenas um miss chega ao banco de dados.

  1. 1

    Verificar o cache

    Crie uma chave determinística e solicite-a ao Redis ou ao armazenamento in-process.

  2. 2

    Miss

    A chave está ausente, então o cache não pode responder. Este é o momento que custa trabalho real.

  3. 3

    Carregar da fonte

    Execute a query no banco de dados ou recompute o valor caro exatamente uma vez.

  4. 4

    Gravar de volta com um TTL

    Armazene o resultado sob a mesma chave com um tempo de expiração, para que a próxima leitura seja um hit.

  5. 5

    Retornar e servir hits

    Leituras posteriores encontram o valor em cache e pulam a fonte até que o TTL expire ou uma atualização invalide a chave.

O guia completo

Estratégias de Caching: Tudo que voce precisa saber

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-age sã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 private caso 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:42 permite 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 Vary e 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.

Na pratica

Quatro tarefas de caching que você escreverá primeiro

O helper, o caminho de escrita, a proteção contra stampede e a edge HTTP.

lib/cache.ts
import { Redis } from "ioredis";

const redis = new Redis(process.env.REDIS_URL!);

export async function cached<T>(
  key: string,
  ttlSeconds: number,
  load: () => Promise<T>,
): Promise<T> {
  const hit = await redis.get(key);
  if (hit !== null) return JSON.parse(hit) as T;

  const value = await load();
  await redis.set(key, JSON.stringify(value), "EX", ttlSeconds);
  return value;
}

// const product = await cached(`product:${id}`, 300, () =>
//   db.product.findUniqueOrThrow({ where: { id } }),
// );

Cache-aside vs write-through

O cache-aside preenche o cache apenas na leitura, então dados não utilizados nunca ocupam memória. O write-through mantém o cache quente, mas paga o custo em cada escrita.

Cache-aside
// On read: miss, load, then populate.
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);

const value = await load();
await redis.set(key, JSON.stringify(value), "EX", 300);
return value;
Write-through
// On write: update the store and the cache together.
const value = await db.product.update({ where: { id }, data: patch });
await redis.set(key, JSON.stringify(value), "EX", 300);
return value;

Apenas TTL vs invalidação explícita

Um TTL sozinho é simples e auto-regenerável, mas o leitor pode ver dados obsoletos durante toda a janela. A invalidação explícita é mais fresca e mais fácil de implementar errado.

Apenas TTL
// Stale for at most five minutes, then it heals itself.
await redis.set(key, JSON.stringify(value), "EX", 300);

// No update path to maintain, and a crashed
// writer cannot leave a permanently wrong value.
Bust explícito
// Fresher, but every write path must remember to do this.
await db.product.update({ where: { id }, data: patch });
await redis.del(`product:${id}`);

// Forget one call site and the cache lies forever.

Trade-offs

Caching vale a complexidade?

Um cache é um sistema extra com seus próprios modos de falha. Adicione-o quando as medições justificarem as partes móveis.

Strengths

  • Ganhos dramáticos de latência

    Mover uma leitura de um banco de dados baseado em disco para a memória pode transformar uma query de 20 ms em um lookup de 0.2 ms, e isso se acumula em toda a página.

  • Proteção sob carga

    Um cache absorve picos de tráfego que, de outra forma, ficariam na fila do banco de dados, o que costuma ser a diferença entre degradação e queda total.

  • Economia de custos real

    Menos leituras no banco de dados significam instâncias menores, menos réplicas e menos egress, o que reflete diretamente na fatura.

Trade-offs

  • Invalidação é genuinamente difícil

    Um cache pode servir dados que não existem mais ou esconder dados que existem. Cada caminho de escrita torna-se um lugar para cometer erros.

  • Um segundo domínio de falha

    O cache pode cair, lotar ou retornar valores corrompidos. O código deve ter fallback para a fonte e tratar o cache como opcional.

  • Leituras obsoletas surpreendem as pessoas

    Usuários esperam ver suas próprias escritas imediatamente. Sem uma invalidação cuidadosa, um perfil ou carrinho em cache parecerá um bug.

Perguntas frequentes

Perguntas frequentes

Keep learning

Related topics from the roadmap.

$ comecar a aprender

Pronto para aprender Caching Strategies?

Nosso tutorial interativo te guia por Caching Strategies passo a passo — com quizzes e codigo real que voce pode executar no navegador.