Escalando APIs com Cache no Redis
Cache-Aside, Write-Through e Write-Around explicados com código real. Estratégias de invalidação, prefixos, TTL dinâmico e como evitar Cache Stampede em produção.
Sua rota de listagem de produtos é chamada 500 vezes por minuto. Cada chamada executa um JOIN entre 3 tabelas, aplica filtros, ordena por relevância e retorna 50 itens. O banco de dados executa essa query 500 vezes por minuto — todas idênticas, todas retornando o mesmo resultado porque ninguém atualizou nada. Isso é desperdício puro de I/O e CPU.
O Redis resolve isso armazenando o resultado na RAM — a camada de memória mais rápida que existe. Uma leitura do banco pode levar 50ms; a mesma leitura do Redis leva 0.5ms. Mas cache não é só set e get: você precisa entender os padrões de escrita, estratégias de invalidação e como evitar problemas como Cache Stampede e dados obsoletos.
Configuração do Cliente Redis (ioredis)
npm install ioredis
npm install -D @types/ioredisimport Redis from 'ioredis';
// Singleton: uma única conexão reutilizada em toda a aplicação
let client: Redis | null = null;
export function getRedisClient(): Redis {
if (!client) {
client = new Redis({
host: process.env.REDIS_HOST ?? 'localhost',
port: Number(process.env.REDIS_PORT ?? 6379),
password: process.env.REDIS_PASSWORD,
db: Number(process.env.REDIS_DB ?? 0),
// Reconexão automática em caso de queda
retryStrategy: (times) => {
if (times > 3) return null; // Desiste após 3 tentativas
return Math.min(times * 200, 2000); // Backoff: 200ms, 400ms, 600ms...
},
// Timeout para evitar requisições travadas
commandTimeout: 5000,
// Lazy connect: não conecta até o primeiro comando
lazyConnect: true,
});
client.on('error', (err) => {
// Log mas não derruba a aplicação — cache deve ser degradável
console.error('[Redis] Erro de conexão:', err.message);
});
client.on('connect', () => {
console.log('[Redis] Conectado com sucesso.');
});
}
return client;
}A Interface e o Provider: Abstraindo o ioredis
Nunca use o ioredis diretamente nos seus Use Cases ou Services. Abstraia atrás de uma interface — isso permite trocar Redis por Memcached, ou usar um provider em memória nos testes:
export interface ICacheProvider {
// Salva um valor com TTL em segundos (undefined = sem expiração)
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
// Recupera um valor tipado. Retorna null se não existe ou expirou
get<T>(key: string): Promise<T | null>;
// Invalida uma chave específica
del(key: string): Promise<void>;
// Invalida todas as chaves que começam com um prefixo
// Ex: delByPrefix('products:') remove 'products:list', 'products:featured', etc.
delByPrefix(prefix: string): Promise<void>;
// Verifica se uma chave existe
exists(key: string): Promise<boolean>;
// Define TTL de uma chave existente
expire(key: string, ttlSeconds: number): Promise<void>;
}import type { ICacheProvider } from '../../domain/providers/ICacheProvider';
import { getRedisClient } from './RedisClient';
export class RedisCacheProvider implements ICacheProvider {
private client = getRedisClient();
// Prefixo global para isolar este app em um Redis compartilhado
private prefix = process.env.REDIS_KEY_PREFIX ?? 'app:';
private buildKey(key: string): string {
return `${this.prefix}${key}`;
}
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
const serialized = JSON.stringify(value);
const fullKey = this.buildKey(key);
if (ttlSeconds) {
// EX: expiração em segundos
await this.client.set(fullKey, serialized, 'EX', ttlSeconds);
} else {
await this.client.set(fullKey, serialized);
}
}
async get<T>(key: string): Promise<T | null> {
const value = await this.client.get(this.buildKey(key));
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
// Valor corrompido — invalida e retorna null
await this.del(key);
return null;
}
}
async del(key: string): Promise<void> {
await this.client.del(this.buildKey(key));
}
async delByPrefix(prefix: string): Promise<void> {
// SCAN é não-bloqueante. KEYS é bloqueante — nunca use KEYS em produção!
const fullPrefix = this.buildKey(prefix);
let cursor = '0';
do {
const [nextCursor, keys] = await this.client.scan(
cursor,
'MATCH', `${fullPrefix}*`,
'COUNT', 100
);
cursor = nextCursor;
if (keys.length > 0) {
await this.client.del(...keys);
}
} while (cursor !== '0');
}
async exists(key: string): Promise<boolean> {
const result = await this.client.exists(this.buildKey(key));
return result === 1;
}
async expire(key: string, ttlSeconds: number): Promise<void> {
await this.client.expire(this.buildKey(key), ttlSeconds);
}
}Padrões de Cache: Cache-Aside, Write-Through e Write-Around
Existem três padrões principais de uso de cache, cada um com trade-offs diferentes:
- Cache-Aside (Lazy Loading) — a aplicação verifica o cache primeiro; se não existe, lê do banco e armazena no cache. É o padrão mais comum para reads. Dados só vão para o cache quando são requisitados.
- Write-Through — toda escrita no banco também escreve no cache simultaneamente. Cache sempre atualizado, mas writes ficam mais lentos.
- Write-Around — writes vão direto para o banco, sem popular o cache. O cache é populado apenas em reads subsequentes. Bom para dados que são raramente relidos após a escrita.
// Padrão Cache-Aside
export class ListProductsUseCase {
constructor(
private productsRepo: IProductsRepository,
private cache: ICacheProvider
) {}
async execute(categoryId: string): Promise<Array<Product>> {
// Chave com namespace para facilitar invalidação por prefixo
const cacheKey = `products:category:${categoryId}`;
// 1. Tenta recuperar do cache
const cached = await this.cache.get<Array<Product>>(cacheKey);
if (cached) return cached;
// 2. Cache miss: busca no banco
const products = await this.productsRepo.findByCategoryId(categoryId);
// 3. Armazena no cache com TTL de 5 minutos
await this.cache.set(cacheKey, products, 5 * 60);
return products;
}
}// Invalidação após escrita: evita dados obsoletos no cache
export class UpdateProductUseCase {
constructor(
private productsRepo: IProductsRepository,
private cache: ICacheProvider
) {}
async execute(id: string, data: UpdateProductDTO): Promise<Product> {
const product = await this.productsRepo.findById(id);
if (!product) throw new AppError('Produto não encontrado.', 404);
Object.assign(product, data);
await this.productsRepo.save(product);
// Invalida todas as chaves de produto:
// 'products:category:*', 'products:featured', etc.
await this.cache.delByPrefix('products:');
// Ou, se quiser ser cirúrgico, invalida apenas a categoria afetada:
// await this.cache.del(`products:category:${product.categoryId}`);
return product;
}
}Cache Stampede: O Problema de Alta Concorrência
Cache Stampede (ou Dog Pile Effect) é um problema clássico: quando o cache de uma chave popular expira, centenas de requisições simultâneas detectam o cache miss e todas vão ao banco ao mesmo tempo para recomputar o valor. O banco recebe uma explosão de queries idênticas.
A solução é Mutex/Lock: a primeira requisição que detecta o cache miss adquire um lock, busca do banco e popula o cache. As demais aguardam e leem do cache quando ele é populado:
// Adicione ao RedisCacheProvider:
async getWithLock<T>(
key: string,
computeFn: () => Promise<T>,
ttlSeconds: number,
lockTtlSeconds: number = 10
): Promise<T> {
// 1. Tenta recuperar do cache
const cached = await this.get<T>(key);
if (cached) return cached;
const lockKey = this.buildKey(`lock:${key}`);
// 2. Tenta adquirir o lock (SET NX EX: Set if Not eXists com Expiration)
const acquired = await this.client.set(
lockKey, '1', 'EX', lockTtlSeconds, 'NX'
);
if (acquired === 'OK') {
// 3. Adquiriu o lock: computa o valor e popula o cache
try {
const value = await computeFn();
await this.set(key, value, ttlSeconds);
return value;
} finally {
await this.client.del(lockKey); // Libera o lock
}
} else {
// 4. Não adquiriu: aguarda e tenta o cache novamente
await new Promise((resolve) => setTimeout(resolve, 100));
const retried = await this.get<T>(key);
if (retried) return retried;
// Se ainda não tem, computa sem lock (fallback)
return computeFn();
}
}
// Uso:
const products = await this.cache.getWithLock(
`products:category:${categoryId}`,
() => this.productsRepo.findByCategoryId(categoryId),
300 // TTL 5 minutos
);Estratégia de Chaves: Namespacing
Chaves bem nomeadas tornam invalidação cirúrgica possível. Use a convenção entidade:escopo:identificador:
Convenção de chaves:
products:list → lista geral de produtos
products:category:{categoryId} → produtos de uma categoria
products:featured → produtos em destaque
users:{userId}:profile → perfil de um usuário específico
users:{userId}:permissions → permissões (cache por 1 min)
orders:{orderId} → detalhes de um pedido
Invalidação por prefixo:
delByPrefix('products:') → invalida TODO o cache de produtos
delByPrefix(`users:${userId}:`) → invalida apenas o cache deste usuário
del('products:featured') → invalida apenas os destaquesConclusão
Cache bem implementado não é só adicionar get e set. É definir estratégias de invalidação precisas (por prefixo, por evento), usar TTLs adequados para cada tipo de dado (segundos para preços, minutos para listas, horas para dados estáticos), e ter degradação elegante quando o Redis cai (nunca deixe o cache indisponível derrubar a API — o banco é o fallback). Com essas bases, o Redis se torna o multiplicador de performance mais barato que existe.