Estratégias de Cache Redis Que Realmente Funcionam em Produção
Cache-aside, write-through, prevenção de cache stampede, estratégias de TTL e padrões de invalidação. Os padrões Redis que eu usei em aplicações Node.js em produção com exemplos de código reais.
Todo mundo te diz para "só adicionar Redis" quando sua API está lenta. Ninguém te conta o que acontece seis meses depois quando seu cache está servindo dados obsoletos, sua lógica de invalidação está espalhada por 40 arquivos, e um deploy causa um cache stampede que derruba seu banco de dados mais forte do que se você nunca tivesse cacheado nada.
Eu rodo Redis em produção há anos. Não como brinquedo, não em um tutorial — em sistemas lidando com tráfego real onde errar no caching significa alertas no pager às 3 da manhã. O que se segue é tudo que eu aprendi sobre fazer isso direito.
Por Que Cachear?#
Vamos começar pelo óbvio: bancos de dados são lentos em relação à memória. Uma consulta PostgreSQL que leva 15ms é rápida pelos padrões de banco de dados. Mas se essa consulta roda em cada requisição de API, e você está lidando com 1.000 requisições por segundo, são 15.000ms de tempo cumulativo de banco de dados por segundo. Seu connection pool está esgotado. Sua latência p99 está nas alturas. Usuários estão olhando para spinners.
O Redis serve a maioria das leituras em menos de 1ms. Esse mesmo dado, cacheado, transforma uma operação de 15ms em uma operação de 0,3ms. Isso não é uma micro-otimização. É a diferença entre precisar de 4 réplicas de banco de dados e não precisar de nenhuma.
Mas caching não é gratuito. Adiciona complexidade, introduz problemas de consistência e cria uma classe inteiramente nova de modos de falha. Antes de cachear qualquer coisa, pergunte-se:
Quando caching ajuda:
- Dados são lidos muito mais frequentemente do que escritos (proporção de 10:1 ou maior)
- A consulta subjacente é cara (joins, agregações, chamadas de API externa)
- Leve obsolescência é aceitável (catálogo de produtos, perfis de usuário, configuração)
- Você tem padrões de acesso previsíveis (mesmas chaves acessadas repetidamente)
Quando caching atrapalha:
- Dados mudam constantemente e devem estar frescos (preços de ações em tempo real, placares ao vivo)
- Cada requisição é única (consultas de busca com muitos parâmetros)
- Seu dataset é minúsculo (se tudo cabe na memória da sua aplicação, pule o Redis)
- Você não tem maturidade operacional para monitorar e depurar problemas de cache
Phil Karlton disse famosamente que existem apenas duas coisas difíceis em ciência da computação: invalidação de cache e nomeação de coisas. Ele estava certo sobre ambas, mas invalidação de cache é a que te acorda à noite.
Configurando ioredis#
Antes de mergulharmos nos padrões, vamos estabelecer a conexão. Eu uso ioredis em todo lugar — é o cliente Redis mais maduro para Node.js, com suporte adequado a TypeScript, modo cluster, suporte a Sentinel e scripting Lua.
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: Number(process.env.REDIS_DB) || 0,
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 200, 5000);
return delay;
},
lazyConnect: true,
enableReadyCheck: true,
connectTimeout: 10000,
});
redis.on("error", (err) => {
console.error("[Redis] Connection error:", err.message);
});
redis.on("connect", () => {
console.log("[Redis] Connected");
});
export default redis;Algumas coisas que vale notar. lazyConnect: true significa que a conexão não é estabelecida até você realmente executar um comando, o que é útil durante testes e inicialização. retryStrategy implementa backoff exponencial limitado a 5 segundos — sem isso, uma queda do Redis faz sua aplicação spammar tentativas de reconexão. E maxRetriesPerRequest: 3 garante que comandos individuais falhem rapidamente em vez de travar para sempre.
Padrão Cache-Aside#
Este é o padrão que você vai usar 80% do tempo. Também é chamado de "lazy loading" ou "look-aside." O fluxo é simples:
- A aplicação recebe uma requisição
- Verifica o Redis para o valor cacheado
- Se encontrado (cache hit), retorna
- Se não encontrado (cache miss), consulta o banco de dados
- Armazena o resultado no Redis
- Retorna o resultado
Aqui está uma implementação tipada:
import redis from "./redis";
interface CacheOptions {
ttl?: number; // segundos
prefix?: string;
}
async function cacheAside<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions = {}
): Promise<T> {
const { ttl = 3600, prefix = "cache" } = options;
const cacheKey = `${prefix}:${key}`;
// Passo 1: Tentar ler do cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
return JSON.parse(cached) as T;
} catch {
// Entrada de cache corrompida, deletar e continuar
await redis.del(cacheKey);
}
}
// Passo 2: Cache miss — buscar da fonte
const result = await fetcher();
// Passo 3: Armazenar no cache (não await — fire and forget)
redis
.set(cacheKey, JSON.stringify(result), "EX", ttl)
.catch((err) => {
console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
});
return result;
}O uso fica assim:
interface User {
id: string;
name: string;
email: string;
plan: "free" | "pro" | "enterprise";
}
async function getUser(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{ ttl: 1800 } // 30 minutos
);
}Perceba que eu faço fire-and-forget na chamada redis.set. Isso é intencional. Se o Redis estiver fora do ar ou lento, a requisição ainda completa. O cache é uma otimização, não um requisito. Se a escrita no cache falhar, a próxima requisição vai apenas consultar o banco de dados novamente. Sem problema.
Existe um bug sutil em muitas implementações de cache-aside que as pessoas esquecem: cachear valores nulos. Se um usuário não existe e você não cacheia esse fato, toda requisição para aquele usuário vai ao banco de dados. Um atacante pode explorar isso requisitando IDs de usuário aleatórios, transformando seu cache em um no-op. Sempre cacheie o resultado negativo também — apenas com um TTL mais curto.
async function getUserSafe(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{
// TTL mais curto para resultados nulos para limitar uso de memória
// mas longo o suficiente para absorver misses repetidos
ttl: row ? 1800 : 300,
}
);
}Na verdade, deixe-me reestruturar isso para fazer o TTL dinâmico funcionar corretamente:
async function getUserWithDynamicTTL(userId: string): Promise<User | null> {
const cacheKey = `cache:user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as User | null;
}
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
const user: User | null = row[0] ?? null;
// Cachear resultados existentes por 30 min, resultados nulos por 5 min
const ttl = user ? 1800 : 300;
await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
return user;
}Write-Through e Write-Behind#
Cache-aside funciona muito bem para cargas de leitura pesada, mas tem um problema de consistência: se outro serviço ou processo atualiza o banco de dados diretamente, seu cache fica obsoleto até o TTL expirar. Entram os padrões write-through e write-behind.
Write-Through#
No write-through, toda escrita passa pela camada de cache. O cache é atualizado primeiro, depois o banco de dados. Isso garante que o cache está sempre consistente com o banco de dados (assumindo que escritas sempre passem pela sua aplicação).
async function updateUser(
userId: string,
updates: Partial<User>
): Promise<User> {
// Passo 1: Atualizar o banco de dados
const updated = await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *",
[userId, updates.name, updates.email]
);
const user: User = updated[0];
// Passo 2: Atualizar o cache imediatamente
const cacheKey = `cache:user:${userId}`;
await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
return user;
}A diferença chave do cache-aside: nós escrevemos no cache em toda escrita, não apenas nas leituras. Isso significa que o cache está sempre quente para dados recentemente atualizados.
O tradeoff: a latência de escrita aumenta porque toda escrita agora toca tanto o banco de dados quanto o Redis. Se o Redis estiver lento, suas escritas ficam lentas. Na maioria das aplicações, leituras superam escritas em muito, então esse tradeoff vale a pena.
Write-Behind (Write-Back)#
Write-behind inverte o script: escritas vão para o Redis primeiro, e o banco de dados é atualizado assincronamente. Isso te dá escritas extremamente rápidas ao custo de potencial perda de dados se o Redis cair antes dos dados serem persistidos.
async function updateUserWriteBehind(
userId: string,
updates: Partial<User>
): Promise<User> {
const cacheKey = `cache:user:${userId}`;
// Ler estado atual
const current = await redis.get(cacheKey);
const user = current ? JSON.parse(current) as User : null;
if (!user) throw new Error("User not in cache");
// Atualizar cache imediatamente
const updated = { ...user, ...updates };
await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
// Enfileirar escrita no banco de dados para processamento assíncrono
await redis.rpush(
"write_behind:users",
JSON.stringify({ userId, updates, timestamp: Date.now() })
);
return updated;
}Você teria então um worker separado drenando essa fila:
async function processWriteBehindQueue(): Promise<void> {
while (true) {
const item = await redis.blpop("write_behind:users", 5);
if (item) {
const { userId, updates } = JSON.parse(item[1]);
try {
await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1",
[userId, updates.name, updates.email]
);
} catch (err) {
// Re-enfileirar em caso de falha com contagem de tentativas
console.error("[WriteBehind] Failed:", err);
await redis.rpush("write_behind:users:dlq", item[1]);
}
}
}
}Eu raramente uso write-behind na prática. O risco de perda de dados é real — se o Redis cair antes do worker processar a fila, essas escritas se foram. Use isso apenas para dados onde consistência eventual é genuinamente aceitável, como contagem de visualizações, eventos de analytics ou preferências não críticas de usuário.
Estratégia de TTL#
Acertar o TTL é mais nuançado do que parece. Um TTL fixo de 1 hora para tudo é fácil de implementar e quase sempre errado.
Camadas de Volatilidade de Dados#
Eu categorizo dados em três camadas e atribuo TTLs de acordo:
const TTL = {
// Camada 1: Raramente muda, caro de computar
// Exemplos: catálogo de produtos, config do site, feature flags
STATIC: 86400, // 24 horas
// Camada 2: Muda ocasionalmente, custo moderado
// Exemplos: perfis de usuário, configurações de time, permissões
MODERATE: 1800, // 30 minutos
// Camada 3: Muda frequentemente, barato de computar mas chamado frequentemente
// Exemplos: dados de feed, contagem de notificações, info de sessão
VOLATILE: 300, // 5 minutos
// Camada 4: Efêmero, usado para rate limiting e locks
EPHEMERAL: 60, // 1 minuto
// Resultados nulos: sempre de curta duração
NOT_FOUND: 120, // 2 minutos
} as const;Jitter de TTL: Prevenindo o Thundering Herd#
Aqui está um cenário que me mordeu: você faz deploy da sua aplicação, o cache está vazio, e 10.000 requisições todas cacheiam o mesmo dado com TTL de 1 hora. Uma hora depois, todas as 10.000 chaves expiram simultaneamente. Todas as 10.000 requisições batem no banco de dados de uma vez. O banco de dados engasga. Eu vi isso derrubar uma instância de Postgres de produção.
A correção é jitter — adicionar aleatoriedade aos valores de TTL:
function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
const jitter = baseTtl * jitterPercent;
const offset = Math.random() * jitter * 2 - jitter;
return Math.max(1, Math.round(baseTtl + offset));
}
// Em vez de: redis.set(key, value, "EX", 3600)
// Use: redis.set(key, value, "EX", ttlWithJitter(3600))
// 3600 ± 10% = valor aleatório entre 3240 e 3960Isso espalha a expiração em uma janela, então em vez de 10.000 chaves expirando no mesmo segundo, elas expiram em uma janela de 12 minutos. O banco de dados vê um aumento gradual no tráfego, não um penhasco.
Para caminhos críticos, eu vou além e uso 20% de jitter:
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 segundosExpiração Deslizante#
Para dados tipo sessão onde o TTL deve resetar a cada acesso, use GETEX (Redis 6.2+):
async function getWithSlidingExpiry<T>(
key: string,
ttl: number
): Promise<T | null> {
// GETEX atomicamente obtém o valor E reseta o TTL
const value = await redis.getex(key, "EX", ttl);
if (value === null) return null;
return JSON.parse(value) as T;
}Se você está em uma versão mais antiga do Redis, use um pipeline:
async function getWithSlidingExpiryCompat<T>(
key: string,
ttl: number
): Promise<T | null> {
const pipeline = redis.pipeline();
pipeline.get(key);
pipeline.expire(key, ttl);
const results = await pipeline.exec();
if (!results || !results[0] || results[0][1] === null) return null;
return JSON.parse(results[0][1] as string) as T;
}Cache Stampede (Thundering Herd)#
Jitter de TTL ajuda com expiração em massa, mas não resolve o stampede de chave única: quando uma chave popular expira e centenas de requisições concorrentes todas tentam regenerá-la simultaneamente.
Imagine que você cacheia o feed da sua homepage com TTL de 5 minutos. Ele expira. Cinquenta requisições concorrentes veem o cache miss. Todas as cinquenta batem no banco de dados com a mesma consulta cara. Você efetivamente fez DDoS em si mesmo.
Solução 1: Mutex Lock#
Apenas uma requisição regenera o cache. Todo mundo espera.
async function cacheAsideWithMutex<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const lockKey = `lock:${key}`;
// Tentar cache primeiro
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as T;
}
// Tentar adquirir lock (NX = apenas se não existir, EX = auto-expirar)
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (acquired) {
try {
// Conseguimos o lock — buscar e cachear
const result = await fetcher();
await redis.set(
cacheKey,
JSON.stringify(result),
"EX",
ttlWithJitter(ttl)
);
return result;
} finally {
// Liberar lock
await redis.del(lockKey);
}
}
// Outra requisição detém o lock — esperar e tentar novamente
await sleep(100);
const retried = await redis.get(cacheKey);
if (retried !== null) {
return JSON.parse(retried) as T;
}
// Ainda sem cache — ir direto ao banco de dados
// (isso lida com o caso onde o detentor do lock falhou)
return fetcher();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}Há uma condição de corrida sutil na liberação do lock acima. Se o detentor do lock levar mais de 10 segundos (o TTL do lock), outra requisição adquire o lock, e então a primeira requisição deleta o lock da segunda. A correção correta é usar um token único:
import { randomUUID } from "crypto";
async function acquireLock(
lockKey: string,
ttl: number
): Promise<string | null> {
const token = randomUUID();
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
return acquired ? token : null;
}
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
// Script Lua garante check-and-delete atômico
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, 1, lockKey, token);
return result === 1;
}Isso é essencialmente um Redlock simplificado. Para Redis de instância única, é suficiente. Para setups de Redis Cluster ou Sentinel, investigue o algoritmo Redlock completo — mas honestamente, para prevenção de cache stampede, essa versão simples funciona bem.
Solução 2: Expiração Antecipada Probabilística#
Esta é minha abordagem favorita. Em vez de esperar a chave expirar, regenera aleatoriamente um pouco antes da expiração. A ideia vem de um artigo de Vattani, Chierichetti e Lowenstein.
interface CachedValue<T> {
data: T;
cachedAt: number;
ttl: number;
}
async function cacheWithEarlyExpiration<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T> {
const cacheKey = `cache:${key}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
const entry = JSON.parse(cached) as CachedValue<T>;
const age = (Date.now() - entry.cachedAt) / 1000;
const remaining = entry.ttl - age;
// Algoritmo XFetch: regenerar probabilisticamente conforme a expiração se aproxima
// beta * Math.log(Math.random()) produz um número negativo
// que cresce (mais negativo) conforme a expiração se aproxima
const beta = 1; // parâmetro de ajuste, 1 funciona bem
const shouldRegenerate =
remaining - beta * Math.log(Math.random()) * -1 <= 0;
if (!shouldRegenerate) {
return entry.data;
}
// Continuar para regenerar
console.log(`[Cache] Early regeneration triggered for ${key}`);
}
const data = await fetcher();
const entry: CachedValue<T> = {
data,
cachedAt: Date.now(),
ttl,
};
// Definir com buffer extra para que o Redis não expire antes de podermos regenerar
await redis.set(
cacheKey,
JSON.stringify(entry),
"EX",
Math.round(ttl * 1.1)
);
return data;
}A beleza dessa abordagem: conforme o TTL restante da chave diminui, a probabilidade de regeneração aumenta. Com 1.000 requisições concorrentes, talvez uma ou duas vão disparar a regeneração enquanto o resto continua servindo dados cacheados. Sem locks, sem coordenação, sem espera.
Solução 3: Stale-While-Revalidate#
Servir o valor obsoleto enquanto regenera em segundo plano. Isso dá a melhor latência porque nenhuma requisição jamais espera pelo fetcher.
async function staleWhileRevalidate<T>(
key: string,
fetcher: () => Promise<T>,
options: {
freshTtl: number; // quanto tempo o dado é "fresco"
staleTtl: number; // quanto tempo dados obsoletos podem ser servidos
}
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const metaKey = `meta:${key}`;
const [cached, meta] = await redis.mget(cacheKey, metaKey);
if (cached !== null) {
const parsedMeta = meta ? JSON.parse(meta) : null;
const isFresh =
parsedMeta && Date.now() - parsedMeta.cachedAt < options.freshTtl * 1000;
if (!isFresh) {
// Dado está obsoleto — servir mas disparar refresh em segundo plano
revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
}
return JSON.parse(cached) as T;
}
// Cache miss completo — precisa buscar sincronamente
return fetchAndCache(key, cacheKey, metaKey, fetcher, options);
}
async function fetchAndCache<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): Promise<T> {
const data = await fetcher();
const totalTtl = options.freshTtl + options.staleTtl;
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(data), "EX", totalTtl);
pipeline.set(
metaKey,
JSON.stringify({ cachedAt: Date.now() }),
"EX",
totalTtl
);
await pipeline.exec();
return data;
}
function revalidateInBackground<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): void {
// Usar um lock para prevenir múltiplos refreshes em segundo plano
const lockKey = `revalidate_lock:${key}`;
redis
.set(lockKey, "1", "EX", 30, "NX")
.then((acquired) => {
if (!acquired) return;
return fetchAndCache(key, cacheKey, metaKey, fetcher, options)
.finally(() => redis.del(lockKey));
})
.catch((err) => {
console.error(`[SWR] Background revalidation failed for ${key}:`, err);
});
}Uso:
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
freshTtl: 300, // 5 minutos fresco
staleTtl: 3600, // servir obsoleto por até 1 hora enquanto revalida
});Eu uso esse padrão para qualquer coisa voltada ao usuário onde latência importa mais que frescor absoluto. Dados de dashboard, páginas de perfil, listagens de produtos — todos candidatos perfeitos.
Invalidação de Cache#
Phil Karlton não estava brincando. Invalidação é onde caching vai de "otimização fácil" para "problema de sistemas distribuídos."
Invalidação Simples por Chave#
O caso mais fácil: quando você atualiza um usuário, delete a chave de cache dele.
async function updateUserAndInvalidate(
userId: string,
updates: Partial<User>
): Promise<User> {
const user = await db.query(
"UPDATE users SET name = $2 WHERE id = $1 RETURNING *",
[userId, updates.name]
);
// Invalidar o cache
await redis.del(`cache:user:${userId}`);
return user[0];
}Isso funciona até os dados do usuário aparecerem em outros resultados cacheados. Talvez esteja embutido em uma lista de membros do time. Talvez esteja em um resultado de busca. Talvez esteja em 14 respostas de API cacheadas diferentes. Agora você precisa rastrear quais chaves de cache contêm quais entidades.
Invalidação Baseada em Tags#
Etiquete suas entradas de cache com as entidades que elas contêm, depois invalide por tag.
async function setWithTags<T>(
key: string,
value: T,
ttl: number,
tags: string[]
): Promise<void> {
const pipeline = redis.pipeline();
// Armazenar o valor
pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
// Adicionar a chave ao set de cada tag
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, `cache:${key}`);
pipeline.expire(`tag:${tag}`, ttl + 3600); // Sets de tags vivem mais que os valores
}
await pipeline.exec();
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}Uso:
// Ao cachear dados do time, etiquetar com todos os IDs de membros
const team = await fetchTeam(teamId);
await setWithTags(
`team:${teamId}`,
team,
1800,
[
`entity:team:${teamId}`,
...team.members.map((m) => `entity:user:${m.id}`),
]
);
// Quando usuário 42 atualiza seu perfil, invalidar tudo que o contém
await invalidateByTag("entity:user:42");Invalidação Orientada a Eventos#
Para sistemas maiores, use Redis Pub/Sub para transmitir eventos de invalidação:
// Publisher (no seu serviço de API)
async function publishInvalidation(
entityType: string,
entityId: string
): Promise<void> {
await redis.publish(
"cache:invalidate",
JSON.stringify({ entityType, entityId, timestamp: Date.now() })
);
}
// Subscriber (em cada instância da aplicação)
const subscriber = new Redis(/* mesma config */);
subscriber.subscribe("cache:invalidate", (err) => {
if (err) console.error("[PubSub] Subscribe error:", err);
});
subscriber.on("message", async (_channel, message) => {
const { entityType, entityId } = JSON.parse(message);
await invalidateByTag(`entity:${entityType}:${entityId}`);
console.log(`[Cache] Invalidated ${entityType}:${entityId}`);
});Isso é crítico em deploys multi-instância. Se você tem 4 servidores de aplicação atrás de um load balancer, uma invalidação no servidor 1 precisa propagar para todos os servidores. Pub/Sub lida com isso automaticamente.
Invalidação por Padrão (Com Cuidado)#
Às vezes você precisa invalidar todas as chaves que correspondem a um padrão. Nunca use KEYS em produção. Ele bloqueia o servidor Redis enquanto varre todo o keyspace. Com milhões de chaves, isso pode bloquear por segundos — uma eternidade em termos de Redis.
Use SCAN em vez disso:
async function invalidateByPattern(pattern: string): Promise<number> {
let cursor = "0";
let deletedCount = 0;
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
deletedCount += keys.length;
}
} while (cursor !== "0");
return deletedCount;
}
// Invalidar todos os dados cacheados para um time específico
await invalidateByPattern("cache:team:42:*");SCAN itera incrementalmente — nunca bloqueia o servidor. A dica COUNT sugere quantas chaves retornar por iteração (é uma dica, não uma garantia). Para keyspaces grandes, essa é a única abordagem segura.
Dito isso, invalidação por padrão é um code smell. Se você se pega fazendo scan frequentemente, redesenhe sua estrutura de chaves ou use tags. SCAN é O(N) sobre o keyspace e é destinado a operações de manutenção, não caminhos quentes.
Estruturas de Dados Além de Strings#
A maioria dos desenvolvedores trata o Redis como um armazenamento chave-valor para strings JSON. Isso é como comprar um canivete suíço e usar apenas o abridor de garrafas. O Redis tem estruturas de dados ricas, e escolher a correta pode eliminar categorias inteiras de complexidade.
Hashes para Objetos#
Em vez de serializar um objeto inteiro como JSON, armazene-o como um Hash do Redis. Isso permite ler e atualizar campos individuais sem deserializar a coisa toda.
// Armazenar usuário como hash
async function setUserHash(user: User): Promise<void> {
const key = `user:${user.id}`;
await redis.hset(key, {
name: user.name,
email: user.email,
plan: user.plan,
updatedAt: Date.now().toString(),
});
await redis.expire(key, 1800);
}
// Ler campos específicos
async function getUserPlan(userId: string): Promise<string | null> {
return redis.hget(`user:${userId}`, "plan");
}
// Atualizar um único campo
async function upgradeUserPlan(
userId: string,
plan: string
): Promise<void> {
await redis.hset(`user:${userId}`, "plan", plan);
}
// Ler hash inteiro como objeto
async function getUserHash(userId: string): Promise<User | null> {
const data = await redis.hgetall(`user:${userId}`);
if (!data || Object.keys(data).length === 0) return null;
return {
id: userId,
name: data.name,
email: data.email,
plan: data.plan as User["plan"],
};
}Hashes são eficientes em memória para objetos pequenos (o Redis usa uma codificação ziplist compacta internamente) e evitam o overhead de serialização/deserialização. O tradeoff: você perde a capacidade de armazenar objetos aninhados sem achatá-los primeiro.
Sorted Sets para Rankings e Rate Limiting#
Sorted Sets são a estrutura de dados mais subestimada do Redis. Cada membro tem um score, e o set está sempre ordenado por score. Isso os torna perfeitos para rankings, classificações e rate limiting com janela deslizante.
// Ranking
async function addScore(
leaderboard: string,
userId: string,
score: number
): Promise<void> {
await redis.zadd(leaderboard, score, userId);
}
async function getTopPlayers(
leaderboard: string,
count: number = 10
): Promise<Array<{ userId: string; score: number }>> {
const results = await redis.zrevrange(
leaderboard,
0,
count - 1,
"WITHSCORES"
);
const players: Array<{ userId: string; score: number }> = [];
for (let i = 0; i < results.length; i += 2) {
players.push({
userId: results[i],
score: parseFloat(results[i + 1]),
});
}
return players;
}
async function getUserRank(
leaderboard: string,
userId: string
): Promise<number | null> {
const rank = await redis.zrevrank(leaderboard, userId);
return rank !== null ? rank + 1 : null; // 0-indexed para 1-indexed
}Para rate limiting com janela deslizante:
async function slidingWindowRateLimit(
identifier: string,
windowMs: number,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number }> {
const key = `ratelimit:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
// Remover entradas fora da janela
pipeline.zremrangebyscore(key, 0, windowStart);
// Adicionar requisição atual
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// Contar requisições na janela
pipeline.zcard(key);
// Definir expiração na chave inteira
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results?.[2]?.[1] as number;
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
};
}Isso é mais preciso que a abordagem de contador de janela fixa e não sofre do problema de limite onde um burst no final de uma janela e no início da próxima efetivamente dobra seu rate limit.
Listas para Filas#
Listas do Redis com LPUSH/BRPOP fazem excelentes filas de trabalho leves:
interface Job {
id: string;
type: string;
payload: Record<string, unknown>;
createdAt: number;
}
// Produtor
async function enqueueJob(
queue: string,
type: string,
payload: Record<string, unknown>
): Promise<string> {
const job: Job = {
id: randomUUID(),
type,
payload,
createdAt: Date.now(),
};
await redis.lpush(`queue:${queue}`, JSON.stringify(job));
return job.id;
}
// Consumidor (bloqueia até um job estar disponível)
async function dequeueJob(
queue: string,
timeout: number = 5
): Promise<Job | null> {
const result = await redis.brpop(`queue:${queue}`, timeout);
if (!result) return null;
return JSON.parse(result[1]) as Job;
}Para qualquer coisa mais complexa que enfileiramento básico (retentativas, dead letter queues, prioridade, jobs atrasados), use BullMQ que constrói sobre Redis mas lida com todos os casos extremos.
Sets para Rastreamento Único#
Precisa rastrear visitantes únicos, deduplicar eventos ou verificar pertencimento? Sets são O(1) para adicionar, remover e verificar pertencimento.
// Rastrear visitantes únicos por dia
async function trackVisitor(
page: string,
visitorId: string
): Promise<boolean> {
const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
const isNew = await redis.sadd(key, visitorId);
// Auto-expirar após 48 horas
await redis.expire(key, 172800);
return isNew === 1; // 1 = novo membro, 0 = já existia
}
// Obter contagem de visitantes únicos
async function getUniqueVisitors(page: string, date: string): Promise<number> {
return redis.scard(`visitors:${page}:${date}`);
}
// Verificar se usuário já executou uma ação
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}Para sets muito grandes (milhões de membros), considere HyperLogLog em vez disso. Ele usa apenas 12KB de memória independentemente da cardinalidade, ao custo de ~0,81% de erro padrão:
// HyperLogLog para contagens únicas aproximadas
async function trackVisitorApprox(
page: string,
visitorId: string
): Promise<void> {
const key = `hll:visitors:${page}:${new Date().toISOString().split("T")[0]}`;
await redis.pfadd(key, visitorId);
await redis.expire(key, 172800);
}
async function getApproxUniqueVisitors(
page: string,
date: string
): Promise<number> {
return redis.pfcount(`hll:visitors:${page}:${date}`);
}Serialização: JSON vs MessagePack#
JSON é a escolha padrão para serialização Redis. É legível, universal e bom o suficiente para a maioria dos casos. Mas para sistemas de alto throughput, o overhead de serialização/deserialização se acumula.
O Problema com JSON#
const user = {
id: "usr_abc123",
name: "Ahmet Kousa",
email: "ahmet@example.com",
plan: "pro",
preferences: {
theme: "dark",
language: "tr",
notifications: true,
},
};
// JSON: 189 bytes
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
// JSON.parse em um caminho quente: ~0.02ms por chamada
// A 10.000 requisições/seg: 200ms de tempo total de CPU por segundoAlternativa MessagePack#
MessagePack é um formato de serialização binária que é menor e mais rápido que JSON:
npm install msgpackrimport { pack, unpack } from "msgpackr";
// MessagePack: ~140 bytes (25% menor)
const packed = pack(user);
console.log(packed.length); // ~140
// Armazenar como Buffer
await redis.set("user:123", packed);
// Ler como Buffer
const raw = await redis.getBuffer("user:123");
if (raw) {
const data = unpack(raw);
}Note o uso de getBuffer em vez de get — isso é crítico. get retorna uma string e corromperia dados binários.
Compressão para Valores Grandes#
Para valores cacheados grandes (respostas de API com centenas de itens, HTML renderizado), adicione compressão:
import { promisify } from "util";
import { gzip, gunzip } from "zlib";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
async function setCompressed<T>(
key: string,
value: T,
ttl: number
): Promise<void> {
const json = JSON.stringify(value);
// Só comprimir se maior que 1KB (overhead da compressão não vale para valores pequenos)
if (Buffer.byteLength(json) > 1024) {
const compressed = await gzipAsync(json);
await redis.set(key, compressed, "EX", ttl);
} else {
await redis.set(key, json, "EX", ttl);
}
}
async function getCompressed<T>(key: string): Promise<T | null> {
const raw = await redis.getBuffer(key);
if (!raw) return null;
try {
// Tentar descomprimir primeiro
const decompressed = await gunzipAsync(raw);
return JSON.parse(decompressed.toString()) as T;
} catch {
// Não comprimido, parsear como JSON normal
return JSON.parse(raw.toString()) as T;
}
}Nos meus testes, compressão gzip tipicamente reduz o tamanho do payload JSON em 70-85%. Uma resposta de API de 50KB se torna 8KB. Isso importa quando você está pagando por memória Redis — menos memória por chave significa mais chaves na mesma instância.
O tradeoff: compressão adiciona 1-3ms de tempo de CPU por operação. Para a maioria das aplicações, isso é negligível. Para caminhos de ultra-baixa latência, pule.
Minha Recomendação#
Use JSON a menos que profiling mostre que é um gargalo. A legibilidade e debugabilidade do JSON no Redis (você pode redis-cli GET key e realmente ler o valor) supera o ganho de performance do MessagePack para 95% das aplicações. Adicione compressão apenas para valores maiores que 1KB.
Redis no Next.js#
O Next.js tem sua própria história de cache (Data Cache, Full Route Cache, etc.), mas o Redis preenche lacunas que o cache embutido não consegue resolver — especialmente quando você precisa compartilhar cache entre múltiplas instâncias ou persistir cache entre deploys.
Cacheando Respostas de API Route#
// app/api/products/route.ts
import { NextResponse } from "next/server";
import redis from "@/lib/redis";
export async function GET(request: Request) {
const url = new URL(request.url);
const category = url.searchParams.get("category") || "all";
const cacheKey = `api:products:${category}`;
// Verificar cache
const cached = await redis.get(cacheKey);
if (cached) {
return NextResponse.json(JSON.parse(cached), {
headers: {
"X-Cache": "HIT",
"Cache-Control": "public, s-maxage=60",
},
});
}
// Buscar do banco de dados
const products = await db.products.findMany({
where: category !== "all" ? { category } : undefined,
orderBy: { createdAt: "desc" },
take: 50,
});
// Cachear por 5 minutos com jitter
await redis.set(
cacheKey,
JSON.stringify(products),
"EX",
ttlWithJitter(300)
);
return NextResponse.json(products, {
headers: {
"X-Cache": "MISS",
"Cache-Control": "public, s-maxage=60",
},
});
}O header X-Cache é inestimável para depuração. Quando a latência dispara, um rápido curl -I te diz se o cache está funcionando.
Armazenamento de Sessão#
Next.js com Redis para sessões supera JWT para aplicações stateful:
// lib/session.ts
import { randomUUID } from "crypto";
import redis from "./redis";
interface Session {
userId: string;
role: string;
createdAt: number;
data: Record<string, unknown>;
}
const SESSION_TTL = 86400; // 24 horas
const SESSION_PREFIX = "session:";
export async function createSession(
userId: string,
role: string
): Promise<string> {
const sessionId = randomUUID();
const session: Session = {
userId,
role,
createdAt: Date.now(),
data: {},
};
await redis.set(
`${SESSION_PREFIX}${sessionId}`,
JSON.stringify(session),
"EX",
SESSION_TTL
);
return sessionId;
}
export async function getSession(
sessionId: string
): Promise<Session | null> {
const key = `${SESSION_PREFIX}${sessionId}`;
// Usar GETEX para refrescar TTL a cada acesso (expiração deslizante)
const raw = await redis.getex(key, "EX", SESSION_TTL);
if (!raw) return null;
return JSON.parse(raw) as Session;
}
export async function destroySession(sessionId: string): Promise<void> {
await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
// Destruir todas as sessões de um usuário (útil para "deslogar de tudo")
export async function destroyAllUserSessions(
userId: string
): Promise<void> {
// Isso requer manter um índice usuário->sessões
const sessionIds = await redis.smembers(`user_sessions:${userId}`);
if (sessionIds.length > 0) {
const pipeline = redis.pipeline();
for (const sid of sessionIds) {
pipeline.del(`${SESSION_PREFIX}${sid}`);
}
pipeline.del(`user_sessions:${userId}`);
await pipeline.exec();
}
}Middleware de Rate Limiting#
// middleware.ts (ou um helper usado pelo middleware)
import redis from "@/lib/redis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
export async function rateLimit(
identifier: string,
limit: number = 60,
windowSeconds: number = 60
): Promise<RateLimitResult> {
const key = `rate:${identifier}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
// Script Lua para rate limiting atômico
const script = `
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
local count = redis.call('ZCARD', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return count
`;
const count = (await redis.eval(
script,
1,
key,
windowStart,
now,
`${now}:${Math.random()}`,
windowSeconds
)) as number;
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt: now + windowSeconds,
};
}O script Lua é importante aqui. Sem ele, a sequência ZREMRANGEBYSCORE + ZADD + ZCARD não é atômica, e sob alta concorrência, a contagem pode ser imprecisa. Scripts Lua executam atomicamente no Redis — não podem ser intercalados com outros comandos.
Locks Distribuídos para Next.js#
Quando você tem múltiplas instâncias Next.js e precisa garantir que apenas uma processe uma tarefa (como enviar um email agendado ou executar um job de limpeza):
// lib/distributed-lock.ts
import { randomUUID } from "crypto";
import redis from "./redis";
export async function withLock<T>(
lockName: string,
fn: () => Promise<T>,
options: { ttl?: number; retryDelay?: number; maxRetries?: number } = {}
): Promise<T | null> {
const { ttl = 30, retryDelay = 200, maxRetries = 10 } = options;
const token = randomUUID();
const lockKey = `dlock:${lockName}`;
// Tentar adquirir lock
for (let attempt = 0; attempt < maxRetries; attempt++) {
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
if (acquired) {
try {
// Estender lock automaticamente para tarefas de longa duração
const extender = setInterval(async () => {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else
return 0
end
`;
await redis.eval(script, 1, lockKey, token, ttl);
}, (ttl * 1000) / 3);
const result = await fn();
clearInterval(extender);
return result;
} finally {
// Liberar lock apenas se ainda somos donos
const releaseScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(releaseScript, 1, lockKey, token);
}
}
// Esperar antes de tentar novamente
await new Promise((r) => setTimeout(r, retryDelay));
}
// Não conseguiu adquirir lock após todas as tentativas
return null;
}Uso:
// Em uma API route disparada por cron
export async function POST() {
const result = await withLock("daily-report", async () => {
// Apenas uma instância executa isso
const report = await generateDailyReport();
await sendReportEmail(report);
return report;
});
if (result === null) {
return NextResponse.json(
{ message: "Another instance is already processing" },
{ status: 409 }
);
}
return NextResponse.json({ success: true });
}O intervalo de extensão do lock em ttl/3 é importante. Sem ele, se sua tarefa levar mais que o TTL do lock, o lock expira e outra instância o pega. O extensor mantém o lock vivo enquanto a tarefa está rodando.
Monitoramento e Depuração#
Redis é rápido até não ser. Quando problemas aparecem, você precisa de visibilidade.
Taxa de Acerto do Cache#
A métrica mais importante. Rastreie-a na sua aplicação:
// lib/cache-metrics.ts
import redis from "./redis";
const METRICS_KEY = "metrics:cache";
export async function recordCacheHit(): Promise<void> {
await redis.hincrby(METRICS_KEY, "hits", 1);
}
export async function recordCacheMiss(): Promise<void> {
await redis.hincrby(METRICS_KEY, "misses", 1);
}
export async function getCacheStats(): Promise<{
hits: number;
misses: number;
hitRate: number;
}> {
const stats = await redis.hgetall(METRICS_KEY);
const hits = parseInt(stats.hits || "0", 10);
const misses = parseInt(stats.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? hits / total : 0,
};
}
// Resetar métricas diariamente
export async function resetCacheStats(): Promise<void> {
await redis.del(METRICS_KEY);
}Uma taxa de acerto de cache saudável é acima de 90%. Se você está abaixo de 80%, ou seus TTLs são muito curtos, suas chaves de cache são muito específicas, ou seus padrões de acesso são mais aleatórios do que você pensava.
Comando INFO#
O comando INFO é o dashboard de saúde embutido do Redis:
redis-cli INFO memory# Memory
used_memory:1234567
used_memory_human:1.18M
used_memory_peak:2345678
used_memory_peak_human:2.24M
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23
Métricas chave para monitorar:
- used_memory vs maxmemory: Você está se aproximando do limite?
- mem_fragmentation_ratio: Acima de 1.5 significa que o Redis está usando significativamente mais RSS que memória lógica. Considere um restart.
- evicted_keys: Se não é zero e você não pretendia evicção, você está sem memória.
redis-cli INFO statsFique atento a:
- keyspace_hits / keyspace_misses: Taxa de acerto no nível do servidor
- total_commands_processed: Throughput
- instantaneous_ops_per_sec: Throughput atual
MONITOR (Use com Extrema Cautela)#
MONITOR transmite cada comando executado no servidor Redis em tempo real. É incrivelmente útil para depuração e incrivelmente perigoso em produção.
# NUNCA deixe isso rodando em produção
# Adiciona overhead significativo e pode registrar dados sensíveis
redis-cli MONITOR1614556800.123456 [0 127.0.0.1:52340] "SET" "cache:user:123" "{\"name\":\"Ahmet\"}" "EX" "1800"
1614556800.234567 [0 127.0.0.1:52340] "GET" "cache:user:456"
Eu uso MONITOR para exatamente duas coisas: depurar problemas de nomenclatura de chaves durante desenvolvimento, e verificar que um caminho de código específico está consultando o Redis como esperado. Nunca por mais de 30 segundos. Nunca em produção a menos que você já tenha esgotado outras opções de depuração.
Notificações de Keyspace#
Quer saber quando chaves expiram ou são deletadas? O Redis pode publicar eventos:
# Habilitar notificações de keyspace para eventos de expiração e evicção
redis-cli CONFIG SET notify-keyspace-events Exconst subscriber = new Redis(/* config */);
// Escutar eventos de expiração de chave
subscriber.subscribe("__keyevent@0__:expired", (err) => {
if (err) console.error("Subscribe error:", err);
});
subscriber.on("message", (_channel, expiredKey) => {
console.log(`Key expired: ${expiredKey}`);
// Regenerar proativamente chaves importantes
if (expiredKey.startsWith("cache:homepage")) {
regenerateHomepageCache().catch(console.error);
}
});Isso é útil para aquecimento proativo de cache — em vez de esperar um usuário disparar um cache miss, você regenera entradas críticas no momento que expiram.
Análise de Memória#
Quando a memória do Redis cresce inesperadamente, você precisa encontrar quais chaves estão consumindo mais:
# Amostrar as 10 maiores chaves
redis-cli --bigkeys# Scanning the entire keyspace to find biggest keys
[00.00%] Biggest string found so far '"cache:search:electronics"' with 524288 bytes
[25.00%] Biggest zset found so far '"leaderboard:global"' with 150000 members
[50.00%] Biggest hash found so far '"session:abc123"' with 45 fields
Para análise mais detalhada:
# Uso de memória de uma chave específica (em bytes)
redis-cli MEMORY USAGE "cache:search:electronics"// Análise de memória programática
async function analyzeMemory(pattern: string): Promise<void> {
let cursor = "0";
const stats: Array<{ key: string; bytes: number }> = [];
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
for (const key of keys) {
const bytes = await redis.memory("USAGE", key);
if (bytes) {
stats.push({ key, bytes: bytes as number });
}
}
} while (cursor !== "0");
// Ordenar por tamanho decrescente
stats.sort((a, b) => b.bytes - a.bytes);
console.log("Top 20 keys by memory usage:");
for (const { key, bytes } of stats.slice(0, 20)) {
const mb = (bytes / 1024 / 1024).toFixed(2);
console.log(` ${key}: ${mb} MB`);
}
}Políticas de Evicção#
Se sua instância Redis tem um limite maxmemory (deveria ter), configure uma política de evicção:
# Em redis.conf ou via CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lruPolíticas disponíveis:
- noeviction: Retorna erro quando memória está cheia (padrão, pior para caching)
- allkeys-lru: Evictar chave menos recentemente usada (melhor escolha geral para caching)
- allkeys-lfu: Evictar chave menos frequentemente usada (melhor se algumas chaves são acessadas em rajadas)
- volatile-lru: Só evictar chaves com TTL definido (útil se você mistura cache e dados persistentes)
- allkeys-random: Evicção aleatória (surpreendentemente decente, sem overhead)
Para cargas de trabalho puras de cache, allkeys-lfu geralmente é a melhor escolha. Ela mantém chaves frequentemente acessadas na memória mesmo que não tenham sido acessadas recentemente.
Juntando Tudo: Um Módulo de Cache para Produção#
Aqui está o módulo de cache completo que eu uso em produção, combinando tudo que discutimos:
// lib/cache.ts
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 200, 5000);
},
});
// Camadas de TTL
const TTL = {
STATIC: 86400,
MODERATE: 1800,
VOLATILE: 300,
EPHEMERAL: 60,
NOT_FOUND: 120,
} as const;
type TTLTier = keyof typeof TTL;
function ttlWithJitter(base: number, jitter = 0.1): number {
const offset = base * jitter * (Math.random() * 2 - 1);
return Math.max(1, Math.round(base + offset));
}
// Cache-aside core com proteção contra stampede
async function get<T>(
key: string,
fetcher: () => Promise<T>,
options: {
tier?: TTLTier;
ttl?: number;
tags?: string[];
swr?: { freshTtl: number; staleTtl: number };
} = {}
): Promise<T> {
const { tier = "MODERATE", tags } = options;
const baseTtl = options.ttl ?? TTL[tier];
const cacheKey = `c:${key}`;
// Verificar cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
const parsed = JSON.parse(cached);
recordHit();
return parsed as T;
} catch {
await redis.del(cacheKey);
}
}
recordMiss();
// Adquirir lock para prevenir stampede
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (!acquired) {
// Outro processo está buscando — esperar brevemente e tentar cache novamente
await new Promise((r) => setTimeout(r, 150));
const retried = await redis.get(cacheKey);
if (retried) return JSON.parse(retried) as T;
}
try {
const result = await fetcher();
const ttl = ttlWithJitter(baseTtl);
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
// Armazenar associações de tags
if (tags) {
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, cacheKey);
pipeline.expire(`tag:${tag}`, ttl + 3600);
}
}
await pipeline.exec();
return result;
} finally {
await redis.del(lockKey);
}
}
// Invalidação
async function invalidate(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
await redis.del(...keys.map((k) => `c:${k}`));
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}
// Métricas
function recordHit(): void {
redis.hincrby("metrics:cache", "hits", 1).catch(() => {});
}
function recordMiss(): void {
redis.hincrby("metrics:cache", "misses", 1).catch(() => {});
}
async function stats(): Promise<{
hits: number;
misses: number;
hitRate: string;
}> {
const raw = await redis.hgetall("metrics:cache");
const hits = parseInt(raw.hits || "0", 10);
const misses = parseInt(raw.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "N/A",
};
}
export const cache = {
get,
invalidate,
invalidateByTag,
stats,
redis,
TTL,
};Uso em toda a aplicação:
import { cache } from "@/lib/cache";
// Cache-aside simples
const products = await cache.get("products:featured", fetchFeaturedProducts, {
tier: "VOLATILE",
tags: ["entity:products"],
});
// Com TTL customizado
const config = await cache.get("app:config", fetchAppConfig, {
ttl: 43200, // 12 horas
});
// Após atualizar um produto
await cache.invalidateByTag("entity:products");
// Verificar saúde
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);Erros Comuns Que Eu Cometi (Para Que Você Não Cometa)#
1. Não configurar maxmemory. O Redis vai alegremente usar toda a memória disponível até o SO matá-lo. Sempre defina um limite.
2. Usar KEYS em produção. Ele bloqueia o servidor. Use SCAN. Eu aprendi isso quando uma chamada KEYS * de um script de monitoramento causou 3 segundos de downtime.
3. Cachear agressivamente demais. Nem tudo precisa ser cacheado. Se sua consulta ao banco de dados leva 2ms e é chamada 10 vezes por minuto, caching adiciona complexidade para benefício negligível.
4. Ignorar custos de serialização. Uma vez eu cacheei um blob JSON de 2MB e fiquei confuso porque leituras do cache estavam lentas. O overhead de serialização era maior que a consulta ao banco de dados que supostamente iria economizar.
5. Sem degradação graceful. Quando o Redis cai, sua aplicação deveria continuar funcionando — apenas mais lenta. Envolva toda chamada de cache em um try/catch que faz fallback para o banco de dados. Nunca deixe uma falha de cache se tornar um erro visível ao usuário.
async function resilientGet<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
try {
return await cache.get(key, fetcher);
} catch (err) {
console.error(`[Cache] Degraded mode for ${key}:`, err);
return fetcher(); // Contornar cache inteiramente
}
}6. Não monitorar evicções. Se o Redis está evictando chaves, você está sub-provisionado ou cacheando demais. De qualquer forma, você precisa saber.
7. Compartilhar uma instância Redis entre cache e dados persistentes. Use instâncias separadas (ou pelo menos bancos de dados separados). Uma política de evicção de cache que deleta entradas da sua fila de jobs é um dia ruim para todo mundo.
Conclusão#
Caching com Redis não é difícil, mas é fácil de errar. Comece com cache-aside, adicione jitter de TTL desde o primeiro dia, monitore sua taxa de acerto, e resista à tentação de cachear tudo.
A melhor estratégia de caching é aquela sobre a qual você consegue raciocinar às 3 da manhã quando algo quebra. Mantenha simples, mantenha observável, e lembre-se de que cada valor cacheado é uma mentira que você contou aos seus usuários sobre o estado dos seus dados — seu trabalho é manter essa mentira o menor e mais curta possível.