Sumário
- Por que Cache é Fundamental para Backends de Alta Performance
- Redis: Arquitetura e Tipos de Dados
- Instalação e Configuração Profissional no Node.js
- Padrões de Cache: Cache-Aside, Write-Through e Write-Behind
- Estratégias de Invalidação de Cache
- Caching de Queries de Banco de Dados
- Cache de Respostas HTTP com Redis
- Sessões Distribuídas com Redis
- Rate Limiting Distribuído e Avançado
- Pub/Sub: Comunicação em Tempo Real
- Filas de Tarefas com BullMQ
- Distributed Locks: Mutex com Redlock
- Redis Streams: Processamento de Eventos
- Monitoramento, Diagnóstico e Otimização
- Redis em Produção: Cluster, Sentinel e Configuração
- Conclusão e Boas Práticas
1. Por que Cache é Fundamental para Backends de Alta Performance {#introducao}
O cache é um dos mecanismos mais poderosos de otimização de performance em sistemas backend. Sem exagero, a diferença entre um sistema que aguenta 100 requisições por segundo e um que aguenta 100.000 pode ser simplesmente a presença de uma camada de cache bem projetada.
O Problema que o Cache Resolve
Considere o ciclo de vida de uma requisição típica em uma API sem cache:
Cliente → API → Banco de Dados → API → Cliente
Tempo médio: 50ms (network) + 200ms (DB query) + 50ms (processamento) = ~300ms
Com cache:
Cliente → API → Redis (cache hit) → API → Cliente
Tempo médio: 50ms (network) + 1ms (Redis) + 10ms (processamento) = ~61ms
Redução de 80% no tempo de resposta e zero carga adicional no banco de dados.
As Camadas de Cache em um Sistema Moderno
Um sistema backend moderno tipicamente tem múltiplas camadas de cache:
Nível 1 — In-Memory (no processo):
- Maps/Objects JavaScript para dados muito frequentes e imutáveis (enums, configs)
- LRU Cache em memória para dados de curta duração
- Custo de acesso: ~0.001ms
- Desvantagem: não compartilhado entre processos/instâncias
Nível 2 — Redis (distribuído):
- Dados de sessão, resultados de queries, computações caras
- Compartilhado entre todas as instâncias da aplicação
- Custo de acesso: ~0.5-1ms (rede local)
- Ideal para a maioria dos casos de uso
Nível 3 — CDN/Edge Cache:
- Respostas HTTP completas para APIs públicas
- Custo de acesso: ~10-20ms (edge node próximo ao usuário)
- Ideal para conteúdo público e semi-estático
Nível 4 — Banco de Dados:
- Cache interno do PostgreSQL (shared_buffers, OS page cache)
- Última linha de defesa
- Custo de acesso: ~20-200ms
Métricas Fundamentais de Cache
Antes de implementar qualquer estratégia de cache, é preciso entender duas métricas críticas:
Cache Hit Ratio: Percentual de requisições atendidas pelo cache
- Fórmula:
hits / (hits + misses) × 100 - Meta mínima: 80%+
- Ótimo: 95%+
Cache Eviction Rate: Taxa de entradas removidas por falta de memória
- Se muito alta: aumente a memória ou ajuste o TTL
- Monitorar com
redis-cli info stats | grep evicted_keys
Thundering Herd Problem: Quando um item popular expira, centenas de requisições simultâneas tentam recalculá-lo ao mesmo tempo, sobrecarregando o banco. Solução: probabilistic early expiration ou mutex.
2. Redis: Arquitetura e Tipos de Dados {#arquitetura}
Por que Redis é a Escolha Dominante
Redis (Remote Dictionary Server) é um store de estruturas de dados em memória, criado por Salvatore Sanfilippo em 2009. Características que o tornam único:
Single-threaded para operações de dados: Redis processa comandos em uma única thread, o que elimina a necessidade de locks e garante operações atômicas. A performance vem da arquitetura I/O multiplexada (similar ao Node.js).
Estruturas de dados nativas: Redis não é apenas um key-value store simples. Ele oferece tipos de dados ricos com operações especializadas para cada um.
Persistência opcional: Redis pode ser configurado para persistir dados em disco (RDB snapshots, AOF append-only file), tornando-o adequado como banco primário para alguns casos de uso.
Redis 7.x — Novidades em 2025:
- Redis Functions (substituindo Lua scripts)
- Sharded Pub/Sub
- Multi-part AOF
- Melhorias significativas de performance
Os Tipos de Dados do Redis e Seus Casos de Uso
String — O tipo mais básico, mas incrivelmente versátil:
# Casos de uso: cache simples, contadores, tokens, flags
SET user:1000:profile '{"name":"João","email":"joao@example.com"}' EX 3600
GET user:1000:profile
INCR page:views:homepage
INCRBY product:stock:1234 -5 # Decrementar estoque atomicamente
SETNX lock:resource:1234 1 # Set if Not Exists (mutex simples)
GETSET session:abc123 "new-value" # Trocar e retornar valor anterior
Hash — Mapas de campo-valor, ideal para objetos:
# Vantagem: permite acessar/atualizar campos individuais sem deserializar tudo
HSET user:1000 name "João" email "joao@example.com" role "USER"
HGET user:1000 email
HMGET user:1000 name email role
HGETALL user:1000
HINCRBY user:1000 login_count 1
HDEL user:1000 temporary_token
List — Listas duplamente encadeadas, ideal para filas e stacks:
# FIFO (fila): RPUSH + BLPOP
RPUSH queue:emails '{"to":"user@example.com","subject":"Welcome"}'
BLPOP queue:emails 30 # Blocking pop (espera até 30s)
# LIFO (stack): RPUSH + RPOP
# Histórico de atividades (manter últimos N)
LPUSH user:1000:activity "Logged in from São Paulo"
LTRIM user:1000:activity 0 49 # Manter apenas os últimos 50 itens
LRANGE user:1000:activity 0 -1 # Buscar todos
Set — Conjuntos sem duplicatas, ideal para relações many-to-many:
# Followers/Following no estilo redes sociais
SADD user:1000:following 2000 3000 4000
SADD user:2000:followers 1000 5000 6000
# Amigos em comum
SINTER user:1000:following user:2000:following
# Union de seguidores
SUNION user:1000:followers user:2000:followers
# Produtos visualizados (deduplication automática)
SADD user:1000:viewed products:abc products:def products:abc # Apenas 2 armazenados
# Verificar membership
SISMEMBER user:1000:following 2000 # → 1 (true)
SCARD user:1000:following # → contagem
Sorted Set (ZSet) — Conjuntos ordenados por score, ideal para rankings e leaderboards:
# Leaderboard de um jogo
ZADD leaderboard 95000 "player:1000"
ZADD leaderboard 87500 "player:2000"
ZADD leaderboard 102300 "player:3000"
# Top 10
ZREVRANGE leaderboard 0 9 WITHSCORES
# Rank de um jogador
ZREVRANK leaderboard "player:1000" # → posição (0-indexed)
# Adicionar pontos
ZINCRBY leaderboard 5000 "player:1000"
# Produtos mais vendidos da semana
ZINCRBY weekly:bestsellers 1 "product:abc"
# Range por score (filtrar)
ZRANGEBYSCORE weekly:bestsellers 100 +inf WITHSCORES
HyperLogLog — Estimativa probabilística de cardinalidade com uso fixo de memória (~12KB):
# Contar visitantes únicos sem armazenar todos os IDs
PFADD visitors:2025-06-01 "user:1000" "user:2000" "user:3000"
PFADD visitors:2025-06-02 "user:1000" "user:4000" "user:5000"
PFCOUNT visitors:2025-06-01 # → ~3 (pode ter erro de ~0.81%)
PFMERGE visitors:week visitors:2025-06-01 visitors:2025-06-02
PFCOUNT visitors:week # → ~5 (1000 aparece nas duas, contado uma vez)
Bitmaps — Operações em bit level, ultra-eficiente para flags:
# Rastrear atividade diária de usuários
# Bit offset = user_id, valor = 1 se ativo
SETBIT daily:active:2025-06-01 1000 1 # user 1000 ativo em 01/06
SETBIT daily:active:2025-06-01 2000 1
SETBIT daily:active:2025-06-01 3000 1
# Quantos usuários ativos no dia?
BITCOUNT daily:active:2025-06-01
# Usuários ativos nos dois dias (interseção)
BITOP AND active:both daily:active:2025-06-01 daily:active:2025-06-02
BITCOUNT active:both
3. Instalação e Configuração Profissional no Node.js {#instalacao}
Dependências e Setup
pnpm add ioredis
pnpm add -D @types/ioredis # Se não vier automaticamente
Por que ioredis e não o cliente redis oficial?
- ioredis tem suporte nativo a Cluster
- Melhor handling de reconnect
- Suporte a Sentinel embutido
- Pipeline e pipelining automático
- API Promise-first desde a v5
Configuração Base com Boas Práticas
// src/config/redis.ts
import Redis, { RedisOptions, Cluster } from 'ioredis';
import { env } from './env';
const commonOptions: RedisOptions = {
// Reconnect automático com exponential backoff
retryStrategy(times) {
const maxRetryDelay = 30000; // 30 segundos máximo
const delay = Math.min(times * 200, maxRetryDelay);
console.warn(`Redis reconectando em ${delay}ms (tentativa ${times})`);
return delay;
},
reconnectOnError(err) {
// Reconectar em erros de LOADING (Redis iniciando) e READONLY (failover de Sentinel)
const targetErrors = ['READONLY', 'LOADING'];
return targetErrors.some(e => err.message.includes(e));
},
// Timeouts
connectTimeout: 10000,
commandTimeout: 5000,
// Keepalive
keepAlive: 10000,
// TLS em produção
...(env.NODE_ENV === 'production' && env.REDIS_TLS === 'true' ? {
tls: { rejectUnauthorized: true },
} : {}),
// Autenticação
password: env.REDIS_PASSWORD,
// Keyspace notifications para invalidação
// Habilitar com: redis-cli config set notify-keyspace-events KEA
// (K=keyspace, E=keyevent, A=all events, x=expired, d=deleted)
};
// Cliente padrão (single instance ou proxy para cluster)
function createRedisClient(): Redis {
const client = new Redis({
host: env.REDIS_HOST || 'localhost',
port: Number(env.REDIS_PORT) || 6379,
db: Number(env.REDIS_DB) || 0,
...commonOptions,
});
client.on('connect', () => console.log('✅ Redis conectado'));
client.on('ready', () => console.log('✅ Redis pronto'));
client.on('error', (err) => console.error('❌ Redis erro:', err.message));
client.on('close', () => console.warn('⚠️ Redis conexão fechada'));
client.on('reconnecting', () => console.warn('🔄 Redis reconectando...'));
return client;
}
// Singleton pattern (evitar múltiplas conexões em hot-reload)
declare global {
var __redisClient: Redis | undefined;
}
export const redis: Redis = global.__redisClient ?? createRedisClient();
if (env.NODE_ENV !== 'production') {
global.__redisClient = redis;
}
// Cliente separado para Pub/Sub (SUBSCRIBE bloqueia o cliente)
export const redisSub: Redis = new Redis({
host: env.REDIS_HOST || 'localhost',
port: Number(env.REDIS_PORT) || 6379,
...commonOptions,
});
export const redisPub: Redis = new Redis({
host: env.REDIS_HOST || 'localhost',
port: Number(env.REDIS_PORT) || 6379,
...commonOptions,
});
Wrapper de Cache com TypeScript Genérico
// src/shared/cache/cache.service.ts
import { redis } from '@/config/redis';
import { logger } from '@/config/logger';
interface CacheOptions {
ttl?: number; // Time-to-live em segundos
prefix?: string; // Prefixo da chave
serialize?: boolean; // Auto-serializar JSON (default: true)
}
interface CacheStats {
hits: number;
misses: number;
errors: number;
}
const stats: CacheStats = { hits: 0, misses: 0, errors: 0 };
export class CacheService {
private prefix: string;
private defaultTTL: number;
constructor(options: { prefix: string; defaultTTL?: number } = { prefix: 'app' }) {
this.prefix = options.prefix;
this.defaultTTL = options.defaultTTL || 3600; // 1 hora por padrão
}
private buildKey(key: string): string {
return `${this.prefix}:${key}`;
}
/**
* Get ou Set: o padrão mais comum
* Se não existe, chama a função factory e armazena o resultado
*/
async getOrSet(
key: string,
factory: () => Promise,
ttl?: number
): Promise {
const fullKey = this.buildKey(key);
try {
const cached = await redis.get(fullKey);
if (cached !== null) {
stats.hits++;
return JSON.parse(cached) as T;
}
stats.misses++;
// Calcular o valor
const value = await factory();
if (value !== null && value !== undefined) {
await redis.setex(
fullKey,
ttl ?? this.defaultTTL,
JSON.stringify(value)
);
}
return value;
} catch (error) {
stats.errors++;
logger.error('Cache error em getOrSet:', { key: fullKey, error });
// Fallback: retornar direto do factory em caso de erro no Redis
return factory();
}
}
async get(key: string): Promise {
const fullKey = this.buildKey(key);
try {
const value = await redis.get(fullKey);
if (value === null) {
stats.misses++;
return null;
}
stats.hits++;
return JSON.parse(value) as T;
} catch (error) {
stats.errors++;
logger.error('Cache error em get:', { key: fullKey, error });
return null;
}
}
async set(key: string, value: T, ttl?: number): Promise {
const fullKey = this.buildKey(key);
try {
await redis.setex(
fullKey,
ttl ?? this.defaultTTL,
JSON.stringify(value)
);
} catch (error) {
stats.errors++;
logger.error('Cache error em set:', { key: fullKey, error });
}
}
async delete(key: string): Promise {
try {
await redis.del(this.buildKey(key));
} catch (error) {
logger.error('Cache error em delete:', error);
}
}
async deletePattern(pattern: string): Promise {
try {
// SCAN é seguro para produção (não bloqueia como KEYS)
const keys = await this.scanKeys(`${this.prefix}:${pattern}`);
if (keys.length === 0) return 0;
// Pipeline para deletar em batch
const pipeline = redis.pipeline();
keys.forEach(key => pipeline.del(key));
await pipeline.exec();
return keys.length;
} catch (error) {
logger.error('Cache error em deletePattern:', error);
return 0;
}
}
private async scanKeys(pattern: string): Promise {
const keys: string[] = [];
let cursor = '0';
do {
const [nextCursor, found] = await redis.scan(
cursor,
'MATCH', pattern,
'COUNT', 100
);
cursor = nextCursor;
keys.push(...found);
} while (cursor !== '0');
return keys;
}
async exists(key: string): Promise {
return (await redis.exists(this.buildKey(key))) === 1;
}
async ttl(key: string): Promise {
return redis.ttl(this.buildKey(key));
}
async increment(key: string, by = 1): Promise {
const fullKey = this.buildKey(key);
return redis.incrby(fullKey, by);
}
async mget(keys: string[]): Promise<(T | null)[]> {
if (keys.length === 0) return [];
const fullKeys = keys.map(k => this.buildKey(k));
const values = await redis.mget(...fullKeys);
return values.map(v => (v ? JSON.parse(v) as T : null));
}
async mset(items: Array<{ key: string; value: T; ttl?: number }>): Promise {
const pipeline = redis.pipeline();
for (const item of items) {
pipeline.setex(
this.buildKey(item.key),
item.ttl ?? this.defaultTTL,
JSON.stringify(item.value)
);
}
await pipeline.exec();
}
getStats(): CacheStats & { hitRatio: string } {
const total = stats.hits + stats.misses;
return {
...stats,
hitRatio: total > 0
? `${((stats.hits / total) * 100).toFixed(2)}%`
: 'N/A',
};
}
}
// Instâncias por domínio
export const userCache = new CacheService({ prefix: 'users', defaultTTL: 3600 });
export const productCache = new CacheService({ prefix: 'products', defaultTTL: 1800 });
export const sessionCache = new CacheService({ prefix: 'sessions', defaultTTL: 86400 });
4. Padrões de Cache: Cache-Aside, Write-Through e Write-Behind {#padroes}
Cache-Aside (Lazy Loading) — O Padrão Mais Comum
No Cache-Aside, a aplicação gerencia explicitamente o cache. Ao buscar dados, verifica primeiro o cache; se não encontrar (cache miss), busca no banco e armazena no cache.
// src/modules/products/product.service.ts
import { productCache } from '@/shared/cache/cache.service';
import { productRepository } from './product.repository';
import { AppError } from '@/shared/errors/AppError';
export class ProductService {
/**
* Cache-Aside com getOrSet
*/
async getProductById(id: string) {
return productCache.getOrSet(
`id:${id}`,
async () => {
const product = await productRepository.findById(id);
if (!product) throw new AppError('Produto não encontrado', 404);
return product;
},
1800 // 30 minutos
);
}
/**
* Cache-Aside manual (mais controle)
*/
async getProductsByCategory(categoryId: string, page = 1, limit = 20) {
const cacheKey = `category:${categoryId}:page:${page}:limit:${limit}`;
const cached = await productCache.get(cacheKey);
if (cached) return cached;
const products = await productRepository.findByCategory(categoryId, { page, limit });
// Armazenar por 5 minutos (dados de listagem mudam com mais frequência)
await productCache.set(cacheKey, products, 300);
return products;
}
/**
* Ao atualizar, invalidar o cache
*/
async updateProduct(id: string, data: UpdateProductDTO) {
const product = await productRepository.update(id, data);
// Invalidar cache do produto específico
await productCache.delete(`id:${id}`);
// Invalidar todos os caches de listagem desta categoria
await productCache.deletePattern(`category:${product.categoryId}:*`);
return product;
}
/**
* Carregamento em batch: reduz N+1 queries
*/
async getProductsBatch(ids: string[]) {
// 1. Tentar buscar todos do cache de uma vez
const cached = await productCache.mget(ids.map(id => `id:${id}`));
const result: Product[] = [];
const missingIds: string[] = [];
cached.forEach((product, index) => {
if (product) {
result.push(product);
} else {
missingIds.push(ids[index]);
}
});
if (missingIds.length > 0) {
// 2. Buscar apenas os que não estavam no cache
const products = await productRepository.findByIds(missingIds);
// 3. Armazenar no cache em batch
await productCache.mset(
products.map(p => ({ key: `id:${p.id}`, value: p, ttl: 1800 }))
);
result.push(...products);
}
return result;
}
}
Write-Through — Escrita Simultânea no Cache e Banco
/**
* Write-Through: ao criar/atualizar no banco, atualiza o cache imediatamente.
* Vantagem: cache sempre atualizado, sem latência na primeira leitura.
* Desvantagem: toda escrita tem latência adicional do cache.
*/
async createProduct(data: CreateProductDTO): Promise {
// 1. Criar no banco
const product = await productRepository.create(data);
// 2. Imediatamente armazenar no cache (Write-Through)
await Promise.all([
productCache.set(`id:${product.id}`, product, 1800),
productCache.set(`slug:${product.slug}`, product, 1800),
]);
// 3. Invalidar listagens (que agora estão desatualizadas)
await productCache.deletePattern(`category:${product.categoryId}:*`);
return product;
}
async updateProductStock(id: string, delta: number): Promise {
// Para operações frequentes de atualização de estoque,
// atualizar no banco E no cache atomicamente
const product = await productRepository.updateStock(id, delta);
// Write-Through: manter cache em sincronia
const cachedProduct = await productCache.get(`id:${id}`);
if (cachedProduct) {
await productCache.set(`id:${id}`, {
...cachedProduct,
stock: product.stock,
}, 1800);
}
}
Write-Behind (Write-Back) — Para Alta Frequência de Escrita
/**
* Write-Behind: escreve no cache imediatamente, propaga para o banco de forma assíncrona.
* Ideal para: counters, analytics, dados que podem tolerar latência de persistência.
* Risco: perda de dados se o Redis cair antes da propagação.
*/
export class AnalyticsService {
private flushInterval: NodeJS.Timeout;
private pendingWrites = new Map();
constructor() {
// Flush para o banco a cada 5 segundos
this.flushInterval = setInterval(() => this.flush(), 5000);
}
async trackProductView(productId: string): Promise {
const key = `product:views:${productId}`;
// Incremento atômico no Redis (muito rápido)
const count = await redis.incr(key);
await redis.expire(key, 86400); // Expirar em 24h
// Marcar como pendente para flush
this.pendingWrites.set(productId, count);
}
async getViewCount(productId: string): Promise {
const cached = await redis.get(`product:views:${productId}`);
if (cached) return parseInt(cached);
// Fallback para banco
return productRepository.getViewCount(productId);
}
private async flush(): Promise {
if (this.pendingWrites.size === 0) return;
const writes = new Map(this.pendingWrites);
this.pendingWrites.clear();
// Batch update no banco
await Promise.all(
Array.from(writes.entries()).map(([productId, views]) =>
productRepository.updateViewCount(productId, views)
)
);
}
async destroy(): Promise {
clearInterval(this.flushInterval);
await this.flush(); // Flush final
}
}
5. Estratégias de Invalidação de Cache {#invalidacao}
A invalidação de cache é uma das partes mais complexas de qualquer sistema de cache. Phil Karlton famosamente disse: “There are only two hard things in Computer Science: cache invalidation and naming things.”
TTL-Based Invalidation
A estratégia mais simples: deixar o cache expirar naturalmente.
// Hierarquia de TTLs baseada na frequência de mudança dos dados
const CACHE_TTL = {
// Dados que raramente mudam
STATIC_CONFIG: 86400, // 24 horas
CATEGORIES: 3600, // 1 hora
USER_PROFILE: 1800, // 30 minutos
// Dados que mudam com frequência moderada
PRODUCT_DETAILS: 900, // 15 minutos
ORDER_HISTORY: 300, // 5 minutos
// Dados em tempo real ou quase-real
PRODUCT_STOCK: 60, // 1 minuto
LEADERBOARD: 30, // 30 segundos
// Dados muito dinâmicos (cache apenas para deduplication)
TRENDING: 10, // 10 segundos
};
Tag-Based Invalidation (Cache Tagging)
Agrupa entradas de cache por tags para invalidação em massa:
// src/shared/cache/tagged-cache.service.ts
export class TaggedCacheService {
async setWithTags(
key: string,
value: T,
tags: string[],
ttl: number
): Promise {
const pipeline = redis.pipeline();
// Armazenar o valor
pipeline.setex(`cache:${key}`, ttl, JSON.stringify(value));
// Para cada tag, adicionar a chave ao set da tag
for (const tag of tags) {
pipeline.sadd(`cache:tag:${tag}`, `cache:${key}`);
pipeline.expire(`cache:tag:${tag}`, ttl * 2); // Tag expira depois do dado
}
await pipeline.exec();
}
async invalidateTag(tag: string): Promise {
const tagKey = `cache:tag:${tag}`;
const keys = await redis.smembers(tagKey);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
keys.forEach(key => pipeline.del(key));
pipeline.del(tagKey);
await pipeline.exec();
return keys.length;
}
async invalidateTags(tags: string[]): Promise {
await Promise.all(tags.map(tag => this.invalidateTag(tag)));
}
}
// Uso:
const taggedCache = new TaggedCacheService();
// Armazenar produto com tags relacionadas
await taggedCache.setWithTags(
`product:${productId}`,
product,
[`product:${productId}`, `category:${product.categoryId}`, `brand:${product.brand}`],
1800
);
// Quando uma categoria é atualizada, invalidar tudo relacionado a ela
await taggedCache.invalidateTag(`category:${categoryId}`);
// → Invalida todos os produtos desta categoria, listagens, etc.
Event-Driven Invalidation com Keyspace Notifications
// src/shared/cache/cache-invalidation.service.ts
import { redisSub } from '@/config/redis';
export class CacheInvalidationService {
async subscribeToExpiredKeys(handler: (key: string) => void): Promise {
// Requer: redis-cli config set notify-keyspace-events Ex
await redisSub.psubscribe('__keyevent@0__:expired');
redisSub.on('pmessage', (pattern, channel, message) => {
handler(message); // message = a chave que expirou
});
}
async subscribeToDeletedKeys(handler: (key: string) => void): Promise {
await redisSub.psubscribe('__keyevent@0__:del');
redisSub.on('pmessage', (pattern, channel, message) => {
handler(message);
});
}
}
// Exemplo: quando um produto expira do cache, pré-aquecer automaticamente
const invalidationService = new CacheInvalidationService();
await invalidationService.subscribeToExpiredKeys(async (key) => {
if (key.startsWith('products:id:')) {
const productId = key.replace('products:id:', '');
// Pré-aquecer o cache assincronamente
const product = await productRepository.findById(productId);
if (product) {
await productCache.set(`id:${productId}`, product, 1800);
}
}
});
6. Caching de Queries de Banco de Dados {#db-cache}
Decorator de Cache para Métodos
// src/shared/cache/cache.decorator.ts
/**
* Decorator para cache automático de métodos
* @param ttl - TTL em segundos
* @param keyFn - Função para gerar a chave (opcional)
*/
export function Cacheable(ttl: number, keyFn?: (...args: any[]) => string) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const key = keyFn
? keyFn(...args)
: `${target.constructor.name}:${propertyKey}:${JSON.stringify(args)}`;
const cached = await redis.get(key);
if (cached !== null) {
return JSON.parse(cached);
}
const result = await originalMethod.apply(this, args);
if (result !== null && result !== undefined) {
await redis.setex(key, ttl, JSON.stringify(result));
}
return result;
};
return descriptor;
};
}
/**
* Decorator para invalidação de cache
*/
export function CacheEvict(...patterns: string[]) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
const result = await originalMethod.apply(this, args);
// Invalidar patterns após execução bem-sucedida
for (const pattern of patterns) {
const resolvedPattern = typeof pattern === 'function'
? pattern(...args)
: pattern;
const keys = await scanKeys(resolvedPattern);
if (keys.length > 0) {
await redis.del(...keys);
}
}
return result;
};
return descriptor;
};
}
// Uso dos decorators:
export class ProductRepository {
@Cacheable(1800, (id: string) => `products:id:${id}`)
async findById(id: string): Promise {
return prisma.product.findUnique({ where: { id } });
}
@Cacheable(300, (categoryId: string, page: number, limit: number) =>
`products:category:${categoryId}:${page}:${limit}`)
async findByCategory(categoryId: string, page: number, limit: number) {
return prisma.product.findMany({
where: { categoryId, deletedAt: null },
skip: (page - 1) * limit,
take: limit,
});
}
@CacheEvict(`products:id:*`, `products:category:*`)
async update(id: string, data: any): Promise {
return prisma.product.update({ where: { id }, data });
}
}
Stale-While-Revalidate: Cache Nunca Expira para o Usuário
/**
* Stale-While-Revalidate: serve dados "velhos" imediatamente
* enquanto atualiza o cache em background.
* Elimina o "cold start" problem.
*/
export async function staleWhileRevalidate(
key: string,
factory: () => Promise,
options: {
ttl: number; // Tempo "fresco" do dado
staleTtl: number; // Quanto tempo dados "velhos" ainda são servidos
}
): Promise {
const fullKey = `cache:${key}`;
const staleKey = `cache:stale:${key}`;
// Tentar cache "fresco" primeiro
const fresh = await redis.get(fullKey);
if (fresh !== null) {
return JSON.parse(fresh);
}
// Cache fresco expirou, verificar se há dado "velho"
const stale = await redis.get(staleKey);
if (stale !== null) {
// Há dado velho: servir imediatamente e atualizar em background
setImmediate(async () => {
try {
const newValue = await factory();
const pipeline = redis.pipeline();
pipeline.setex(fullKey, options.ttl, JSON.stringify(newValue));
pipeline.setex(staleKey, options.staleTtl, JSON.stringify(newValue));
await pipeline.exec();
} catch (error) {
console.error('Erro na revalidação de cache:', error);
}
});
return JSON.parse(stale);
}
// Nenhum cache disponível: calcular e armazenar
const value = await factory();
const pipeline = redis.pipeline();
pipeline.setex(fullKey, options.ttl, JSON.stringify(value));
pipeline.setex(staleKey, options.staleTtl, JSON.stringify(value));
await pipeline.exec();
return value;
}
// Uso:
const product = await staleWhileRevalidate(
`product:${id}`,
() => productRepository.findById(id),
{ ttl: 300, staleTtl: 3600 } // Fresco por 5min, servido "velho" por 1h
);
7. Sessões Distribuídas com Redis {#sessoes}
pnpm add express-session connect-redis
pnpm add -D @types/express-session
// src/config/session.ts
import session from 'express-session';
import { createClient } from 'redis';
import RedisStore from 'connect-redis';
import { env } from './env';
const redisClient = createClient({
url: env.REDIS_URL || 'redis://localhost:6379',
});
redisClient.connect();
export const sessionMiddleware = session({
store: new RedisStore({ client: redisClient }),
secret: env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: env.NODE_ENV === 'production', // HTTPS apenas em produção
httpOnly: true, // Prevenir XSS
sameSite: 'strict', // Prevenir CSRF
maxAge: 24 * 60 * 60 * 1000, // 24 horas
},
name: '__session', // Evitar o nome padrão 'connect.sid'
rolling: true, // Renovar TTL a cada requisição
});
// Implementação manual de sessão com JWT + Redis (mais flexível)
export class SessionService {
private readonly SESSION_PREFIX = 'session:';
private readonly SESSION_TTL = 86400; // 24 horas
async createSession(userId: string, data: Record): Promise {
const sessionId = crypto.randomUUID();
const key = `${this.SESSION_PREFIX}${sessionId}`;
await redis.setex(key, this.SESSION_TTL, JSON.stringify({
userId,
...data,
createdAt: new Date().toISOString(),
lastAccessAt: new Date().toISOString(),
}));
// Rastrear sessões por usuário (para logout de todos os dispositivos)
await redis.sadd(`user:sessions:${userId}`, sessionId);
await redis.expire(`user:sessions:${userId}`, this.SESSION_TTL);
return sessionId;
}
async getSession(sessionId: string): Promise | null> {
const key = `${this.SESSION_PREFIX}${sessionId}`;
const data = await redis.get(key);
if (!data) return null;
const session = JSON.parse(data);
// Atualizar lastAccessAt e renovar TTL (sliding session)
session.lastAccessAt = new Date().toISOString();
await redis.setex(key, this.SESSION_TTL, JSON.stringify(session));
return session;
}
async destroySession(sessionId: string): Promise {
const key = `${this.SESSION_PREFIX}${sessionId}`;
const session = await this.getSession(sessionId);
if (session) {
await redis.srem(`user:sessions:${session.userId}`, sessionId);
}
await redis.del(key);
}
async destroyAllUserSessions(userId: string): Promise {
const sessionIds = await redis.smembers(`user:sessions:${userId}`);
if (sessionIds.length > 0) {
const pipeline = redis.pipeline();
sessionIds.forEach(id => pipeline.del(`${this.SESSION_PREFIX}${id}`));
pipeline.del(`user:sessions:${userId}`);
await pipeline.exec();
}
}
async getUserActiveSessions(userId: string): Promise>> {
const sessionIds = await redis.smembers(`user:sessions:${userId}`);
const sessions = await redis.mget(...sessionIds.map(id => `${this.SESSION_PREFIX}${id}`));
return sessions
.filter(Boolean)
.map(s => JSON.parse(s!));
}
}
8. Rate Limiting Distribuído e Avançado {#rate-limiting}
Rate limiting em ambiente distribuído (múltiplas instâncias) exige Redis para sincronização.
// src/shared/rate-limit/distributed-rate-limiter.ts
import { redis } from '@/config/redis';
interface RateLimitConfig {
windowMs: number; // Janela em milissegundos
maxRequests: number; // Máximo de requisições na janela
keyPrefix?: string;
}
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: Date;
totalHits: number;
}
// Algoritmo: Fixed Window Counter
export class FixedWindowRateLimiter {
constructor(private config: RateLimitConfig) {}
async check(identifier: string): Promise {
const windowKey = Math.floor(Date.now() / this.config.windowMs);
const key = `rate:fixed:${this.config.keyPrefix || ''}:${identifier}:${windowKey}`;
const pipeline = redis.pipeline();
pipeline.incr(key);
pipeline.pexpire(key, this.config.windowMs);
const results = await pipeline.exec();
const count = results?.[0]?.[1] as number;
const resetAt = new Date(
(windowKey + 1) * this.config.windowMs
);
return {
allowed: count <= this.config.maxRequests,
remaining: Math.max(0, this.config.maxRequests - count),
resetAt,
totalHits: count,
};
}
}
// Algoritmo: Sliding Window Log (mais preciso, mais memória)
export class SlidingWindowRateLimiter {
constructor(private config: RateLimitConfig) {}
async check(identifier: string): Promise {
const key = `rate:sliding:${this.config.keyPrefix || ''}:${identifier}`;
const now = Date.now();
const windowStart = now - this.config.windowMs;
// Usar Sorted Set: score = timestamp, member = request ID
const requestId = `${now}-${Math.random()}`;
const luaScript = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_start = tonumber(ARGV[2])
local max_requests = tonumber(ARGV[3])
local window_ms = tonumber(ARGV[4])
local request_id = ARGV[5]
-- Remover requisições fora da janela
redis.call('ZREMRANGEBYSCORE', key, '-inf', window_start)
-- Contar requisições na janela
local count = redis.call('ZCARD', key)
-- Verificar limite
if count < max_requests then
redis.call('ZADD', key, now, request_id)
redis.call('PEXPIRE', key, window_ms)
return {1, count + 1} -- allowed=true, new_count
else
return {0, count} -- allowed=false, count
end
`;
const result = await redis.eval(
luaScript,
1,
key,
now.toString(),
windowStart.toString(),
this.config.maxRequests.toString(),
this.config.windowMs.toString(),
requestId
) as [number, number];
const [allowed, count] = result;
return {
allowed: allowed === 1,
remaining: Math.max(0, this.config.maxRequests - count),
resetAt: new Date(now + this.config.windowMs),
totalHits: count,
};
}
}
// Algoritmo: Token Bucket (permite bursts, mais suave)
export class TokenBucketRateLimiter {
constructor(
private capacity: number, // Capacidade máxima do bucket
private refillRate: number, // Tokens adicionados por segundo
private keyPrefix = 'bucket'
) {}
async consume(identifier: string, tokens = 1): Promise {
const key = `rate:${this.keyPrefix}:${identifier}`;
const luaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local tokens_needed = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- Buscar estado atual do bucket
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local current_tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
-- Calcular tokens adicionados desde último refill
local elapsed = (now - last_refill) / 1000 -- converter para segundos
local new_tokens = math.min(capacity, current_tokens + elapsed * refill_rate)
-- Verificar se há tokens suficientes
if new_tokens >= tokens_needed then
new_tokens = new_tokens - tokens_needed
redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 60)
return {1, math.floor(new_tokens)} -- allowed, remaining
else
redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refill_rate) + 60)
return {0, math.floor(new_tokens)} -- not allowed, remaining
end
`;
const result = await redis.eval(
luaScript,
1,
key,
this.capacity.toString(),
this.refillRate.toString(),
tokens.toString(),
Date.now().toString()
) as [number, number];
const [allowed, remaining] = result;
const refillTime = (tokens - remaining) / this.refillRate;
return {
allowed: allowed === 1,
remaining,
resetAt: new Date(Date.now() + refillTime * 1000),
totalHits: this.capacity - remaining,
};
}
}
// Middleware Express com rate limiter distribuído
export function createDistributedRateLimiter(config: {
windowMs: number;
maxRequests: number;
keyGenerator?: (req: Request) => string;
skipSuccessfulRequests?: boolean;
}) {
const limiter = new SlidingWindowRateLimiter({
windowMs: config.windowMs,
maxRequests: config.maxRequests,
});
return async (req: Request, res: Response, next: NextFunction) => {
const identifier = config.keyGenerator
? config.keyGenerator(req)
: req.ip || 'unknown';
const result = await limiter.check(identifier);
// Headers informativos (RFC 6585)
res.setHeader('X-RateLimit-Limit', config.maxRequests);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', Math.floor(result.resetAt.getTime() / 1000));
res.setHeader('Retry-After', Math.ceil((result.resetAt.getTime() - Date.now()) / 1000));
if (!result.allowed) {
return res.status(429).json({
status: 'error',
message: 'Muitas requisições. Tente novamente em breve.',
retryAfter: result.resetAt.toISOString(),
});
}
next();
};
}
9. Pub/Sub: Comunicação em Tempo Real {#pubsub}
// src/shared/pubsub/pubsub.service.ts
import { redisPub, redisSub } from '@/config/redis';
type MessageHandler = (data: any) => void | Promise;
export class PubSubService {
private handlers = new Map();
private isSubscribed = false;
async subscribe(channel: string, handler: MessageHandler): Promise {
if (!this.handlers.has(channel)) {
this.handlers.set(channel, []);
await redisSub.subscribe(channel);
}
this.handlers.get(channel)!.push(handler);
if (!this.isSubscribed) {
this.setupMessageHandler();
this.isSubscribed = true;
}
}
async psubscribe(pattern: string, handler: MessageHandler): Promise {
if (!this.handlers.has(pattern)) {
this.handlers.set(pattern, []);
await redisSub.psubscribe(pattern);
}
this.handlers.get(pattern)!.push(handler);
}
async publish(channel: string, data: any): Promise {
const message = JSON.stringify({
data,
timestamp: new Date().toISOString(),
serviceId: process.env.SERVICE_NAME,
});
return redisPub.publish(channel, message);
}
private setupMessageHandler(): void {
redisSub.on('message', async (channel, message) => {
const handlers = this.handlers.get(channel);
if (!handlers) return;
try {
const parsed = JSON.parse(message);
await Promise.all(handlers.map(h => h(parsed)));
} catch (error) {
console.error(`Erro no handler de ${channel}:`, error);
}
});
redisSub.on('pmessage', async (pattern, channel, message) => {
const handlers = this.handlers.get(pattern);
if (!handlers) return;
try {
const parsed = JSON.parse(message);
await Promise.all(handlers.map(h => h({ ...parsed, channel })));
} catch (error) {
console.error(`Erro no pmessage handler:`, error);
}
});
}
async unsubscribe(channel: string): Promise {
this.handlers.delete(channel);
await redisSub.unsubscribe(channel);
}
}
export const pubSub = new PubSubService();
// Integração com WebSockets para real-time
// src/modules/notifications/notification.gateway.ts
import { Server as SocketIOServer } from 'socket.io';
export class NotificationGateway {
constructor(private io: SocketIOServer) {
this.setupPubSubListeners();
}
private setupPubSubListeners(): void {
// Ouvir eventos de pedido e retransmitir para clientes WebSocket
pubSub.subscribe('orders:status-changed', async ({ data }) => {
const { orderId, userId, newStatus } = data;
// Emitir para o cliente específico (por socket room = userId)
this.io.to(`user:${userId}`).emit('order:status-changed', {
orderId,
newStatus,
timestamp: new Date().toISOString(),
});
});
pubSub.subscribe('notifications:new', async ({ data }) => {
const { userId, notification } = data;
this.io.to(`user:${userId}`).emit('notification:new', notification);
});
}
handleConnection(socket: any): void {
const userId = socket.handshake.auth?.userId;
if (userId) {
socket.join(`user:${userId}`);
console.log(`Socket ${socket.id} joined room user:${userId}`);
}
socket.on('disconnect', () => {
socket.leave(`user:${userId}`);
});
}
}
10. Filas de Tarefas com BullMQ {#filas}
pnpm add bullmq
// src/shared/queue/queue.service.ts
import { Queue, Worker, Job, QueueEvents } from 'bullmq';
import IORedis from 'ioredis';
const connection = new IORedis(process.env.REDIS_URL || 'redis://localhost:6379', {
maxRetriesPerRequest: null, // Obrigatório para BullMQ
});
// Definição dos tipos de jobs
interface EmailJob {
to: string;
subject: string;
template: string;
data: Record;
}
interface ImageProcessingJob {
imageUrl: string;
userId: string;
operations: Array<'resize' | 'compress' | 'watermark'>;
}
// Criar filas
export const emailQueue = new Queue('email', {
connection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000,
},
removeOnComplete: { count: 1000 }, // Manter últimos 1000 jobs completos
removeOnFail: { count: 5000 }, // Manter últimos 5000 jobs falhos
},
});
export const imageProcessingQueue = new Queue('image-processing', {
connection,
defaultJobOptions: {
attempts: 2,
timeout: 60000, // 1 minuto de timeout
},
});
// Worker para processamento de emails
export function createEmailWorker() {
const worker = new Worker(
'email',
async (job: Job) => {
const { to, subject, template, data } = job.data;
console.log(`Processando email para ${to} (Job ${job.id})`);
// Atualizar progresso
await job.updateProgress(10);
// Renderizar template
const html = await renderEmailTemplate(template, data);
await job.updateProgress(50);
// Enviar email
await emailProvider.send({ to, subject, html });
await job.updateProgress(100);
return { sent: true, timestamp: new Date().toISOString() };
},
{
connection,
concurrency: 10, // Processar 10 emails em paralelo
limiter: {
max: 100, // Máximo 100 jobs por intervalo
duration: 60000, // Por minuto
},
}
);
worker.on('completed', (job) => {
console.log(`✅ Email enviado (Job ${job.id})`);
});
worker.on('failed', (job, err) => {
console.error(`❌ Falha no email (Job ${job?.id}):`, err.message);
});
worker.on('progress', (job, progress) => {
console.log(`📊 Job ${job.id}: ${progress}%`);
});
return worker;
}
// API para adicionar jobs
export class QueueService {
async sendEmail(data: EmailJob, options?: {
delay?: number;
priority?: number;
}): Promise> {
return emailQueue.add('send', data, {
delay: options?.delay,
priority: options?.priority,
});
}
async sendEmailBatch(emails: EmailJob[]): Promise {
await emailQueue.addBulk(
emails.map(data => ({ name: 'send', data }))
);
}
async scheduleEmail(data: EmailJob, scheduledAt: Date): Promise> {
const delay = scheduledAt.getTime() - Date.now();
return this.sendEmail(data, { delay: Math.max(0, delay) });
}
async getQueueStats() {
const [waiting, active, completed, failed, delayed] = await Promise.all([
emailQueue.getWaitingCount(),
emailQueue.getActiveCount(),
emailQueue.getCompletedCount(),
emailQueue.getFailedCount(),
emailQueue.getDelayedCount(),
]);
return { waiting, active, completed, failed, delayed };
}
async retryFailedJobs(): Promise {
const failedJobs = await emailQueue.getFailed();
await Promise.all(failedJobs.map(job => job.retry()));
}
}
export const queueService = new QueueService();
11. Distributed Locks: Mutex com Redlock {#locks}
pnpm add redlock
// src/shared/locks/distributed-lock.service.ts
import Redlock, { ExecutionError, ResourceLockedError } from 'redlock';
import { redis } from '@/config/redis';
const redlock = new Redlock(
[redis],
{
driftFactor: 0.01, // Fator de drift do clock
retryCount: 3, // Tentar 3 vezes
retryDelay: 200, // Delay entre tentativas
retryJitter: 100, // Jitter aleatório
automaticExtensionThreshold: 500, // Estender automaticamente
}
);
redlock.on('error', (error) => {
if (!(error instanceof ResourceLockedError)) {
console.error('Redlock error:', error);
}
});
export class DistributedLockService {
/**
* Executar uma função com lock exclusivo
*/
async withLock(
resource: string,
ttl: number,
fn: () => Promise
): Promise {
const lockKey = `lock:${resource}`;
try {
return await redlock.using([lockKey], ttl, async (signal) => {
const result = await fn();
// Verificar se o lock ainda é válido após a execução
if (signal.aborted) {
throw signal.error;
}
return result;
});
} catch (error) {
if (error instanceof ExecutionError) {
throw new Error(`Não foi possível adquirir lock para: ${resource}`);
}
throw error;
}
}
/**
* Tentar adquirir o lock sem esperar
*/
async tryLock(resource: string, ttl: number): Promise<{
locked: boolean;
release?: () => Promise;
}> {
try {
const lock = await redlock.acquire([`lock:${resource}`], ttl);
return {
locked: true,
release: async () => { await lock.release(); },
};
} catch {
return { locked: false };
}
}
}
export const lockService = new DistributedLockService();
// Caso de uso prático: processar pedido com lock para evitar duplo processamento
async function processOrder(orderId: string): Promise {
await lockService.withLock(
`order:${orderId}`,
30000, // Lock por 30 segundos
async () => {
const order = await orderRepository.findById(orderId);
if (order.status !== 'PENDING') {
throw new Error('Pedido já foi processado');
}
await paymentService.charge(order);
await inventoryService.reserveItems(order.items);
await orderRepository.update(orderId, { status: 'CONFIRMED' });
}
);
}
12. Redis Streams: Processamento de Eventos {#streams}
Redis Streams é uma estrutura de dados para log de eventos persistente e processamento de mensagens com grupos de consumidores.
// src/shared/streams/event-stream.service.ts
export class EventStreamService {
private readonly STREAM_KEY = 'app:events';
private readonly GROUP_NAME = 'event-processors';
async publishEvent(event: {
type: string;
aggregateId: string;
payload: Record;
}): Promise {
const eventId = await redis.xadd(
this.STREAM_KEY,
'*', // ID automático (timestamp-sequence)
'type', event.type,
'aggregateId', event.aggregateId,
'payload', JSON.stringify(event.payload),
'timestamp', Date.now().toString(),
'service', process.env.SERVICE_NAME || 'unknown'
);
return eventId as string;
}
async createConsumerGroup(): Promise {
try {
await redis.xgroup('CREATE', this.STREAM_KEY, this.GROUP_NAME, '$', 'MKSTREAM');
console.log('Consumer group criado');
} catch (error: any) {
if (error.message.includes('BUSYGROUP')) {
console.log('Consumer group já existe');
} else {
throw error;
}
}
}
async consumeEvents(
consumerName: string,
handler: (event: any) => Promise,
options: { batchSize?: number; blockMs?: number } = {}
): Promise {
const { batchSize = 10, blockMs = 2000 } = options;
while (true) {
const messages = await redis.xreadgroup(
'GROUP', this.GROUP_NAME, consumerName,
'COUNT', batchSize,
'BLOCK', blockMs,
'STREAMS', this.STREAM_KEY,
'>' // Apenas mensagens não entregues a este grupo
);
if (!messages) continue; // Timeout, tentar novamente
for (const [stream, events] of messages as any[]) {
for (const [id, fields] of events) {
const event = this.parseEvent(fields);
try {
await handler(event);
// ACK após processamento bem-sucedido
await redis.xack(this.STREAM_KEY, this.GROUP_NAME, id);
} catch (error) {
console.error(`Falha ao processar evento ${id}:`, error);
// Não faz ACK → fica no Pending Entries List (PEL)
// Será reprocessado via XAUTOCLAIM
}
}
}
// Reclamar mensagens pendentes há mais de 1 minuto (de outros consumidores que falharam)
await this.reclaimPendingMessages(consumerName);
}
}
private async reclaimPendingMessages(consumerName: string): Promise {
const minIdleTime = 60000; // 1 minuto
const result = await redis.xautoclaim(
this.STREAM_KEY,
this.GROUP_NAME,
consumerName,
minIdleTime,
'0-0', // Começar do início da PEL
'COUNT', 10
);
const [nextId, messages] = result as any;
for (const [id, fields] of (messages || [])) {
const event = this.parseEvent(fields);
console.log(`Reprocessando evento pendente: ${id}`, event.type);
}
}
private parseEvent(fields: string[]): Record {
const obj: Record = {};
for (let i = 0; i < fields.length; i += 2) {
const key = fields[i];
const value = fields[i + 1];
obj[key] = key === 'payload' ? JSON.parse(value) : value;
}
return obj;
}
// Ler eventos históricos (event sourcing)
async readHistory(
from: string = '0',
count = 100
): Promise {
const messages = await redis.xrange(this.STREAM_KEY, from, '+', 'COUNT', count);
return (messages as any[]).map(([id, fields]) => ({
id,
...this.parseEvent(fields),
}));
}
// Trim automático para não crescer infinitamente
async trimStream(maxLength = 100000): Promise {
await redis.xtrim(this.STREAM_KEY, 'MAXLEN', '~', maxLength);
}
}
13. Monitoramento, Diagnóstico e Otimização {#monitoramento}
// src/config/redis-monitoring.ts
export class RedisMonitor {
async getInfo(): Promise> {
const info = await redis.info();
const parsed: Record = {};
info.split('rn').forEach(line => {
const [key, value] = line.split(':');
if (key && value) parsed[key.trim()] = value.trim();
});
return parsed;
}
async getHealthMetrics() {
const info = await this.getInfo();
return {
// Memória
usedMemory: info.used_memory_human,
maxMemory: info.maxmemory_human,
memoryFragmentationRatio: parseFloat(info.mem_fragmentation_ratio),
// Performance
opsPerSecond: parseInt(info.instantaneous_ops_per_sec),
hitRate: this.calculateHitRate(info),
evictedKeys: parseInt(info.evicted_keys),
// Conexões
connectedClients: parseInt(info.connected_clients),
blockedClients: parseInt(info.blocked_clients),
// Persistência
rdbLastSaveTime: new Date(parseInt(info.rdb_last_save_time) * 1000),
// Replicação
role: info.role,
connectedSlaves: parseInt(info.connected_slaves || '0'),
};
}
private calculateHitRate(info: Record): string {
const hits = parseInt(info.keyspace_hits || '0');
const misses = parseInt(info.keyspace_misses || '0');
const total = hits + misses;
if (total === 0) return 'N/A';
return `${((hits / total) * 100).toFixed(2)}%`;
}
async getMemoryUsageByPattern(pattern = '*'): Promise> {
const keys = await this.scanAllKeys(pattern);
const results = [];
for (const key of keys.slice(0, 100)) { // Limitar a 100 para não sobrecarregar
const size = await redis.memory('USAGE', key) as number;
results.push({
key,
sizeBytes: size || 0,
sizePretty: this.formatBytes(size || 0),
});
}
return results.sort((a, b) => b.sizeBytes - a.sizeBytes);
}
private async scanAllKeys(pattern: string): Promise {
const keys: string[] = [];
let cursor = '0';
do {
const [nextCursor, found] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
keys.push(...found);
} while (cursor !== '0');
return keys;
}
private formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)}KB`;
return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
}
// Monitoramento em tempo real com MONITOR (apenas desenvolvimento!)
async startMonitor(): Promise {
if (process.env.NODE_ENV === 'production') {
throw new Error('MONITOR não deve ser usado em produção!');
}
const monitor = await redis.monitor();
monitor.on('monitor', (time, args) => {
console.log(`${time}: ${args.join(' ')}`);
});
}
}
14. Redis em Produção: Cluster e Sentinel {#producao}
# redis.conf para produção
# ==========================================
# Persistência (AOF + RDB)
# ==========================================
appendonly yes
appendfsync everysec # Sync a cada segundo (equilíbrio performance/durabilidade)
no-appendfsync-on-rewrite yes # Não pausar writes durante rewrite
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
save 900 1 # RDB snapshot se 1 chave mudou em 15 minutos
save 300 10 # Se 10 chaves mudaram em 5 minutos
save 60 10000 # Se 10.000 chaves mudaram em 1 minuto
# ==========================================
# Memória
# ==========================================
maxmemory 4gb
maxmemory-policy allkeys-lru # LRU para todas as chaves quando atingir limite
# ==========================================
# Segurança
# ==========================================
requirepass seu_password_forte_aqui
rename-command FLUSHALL "" # Desabilitar comandos perigosos
rename-command FLUSHDB ""
rename-command DEBUG ""
rename-command CONFIG ""
# ==========================================
# Performance
# ==========================================
tcp-backlog 511
tcp-keepalive 300
hz 15 # Frequência do loop interno (padrão 10)
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes
15. Conclusão e Boas Práticas {#conclusao}
Redis é muito mais do que um simples cache. É uma ferramenta versátil que pode resolver dezenas de problemas de backend de forma elegante e performática.
Regras de ouro para trabalhar com Redis:
1. Sempre defina TTL. Nunca armazene dados sem expiração, exceto quando absolutamente necessário. Memória é finita.
2. Design de chaves consistente. Use namespacing com : como separador: service:entity:id:field. Exemplo: users:profile:abc123:avatar.
3. Não use KEYS em produção. Sempre use SCAN com cursor para iterar sobre chaves. KEYS bloqueia o servidor inteiro.
4. Use Pipeline para operações em batch. Reduz round-trips de N para 1.
5. Trate Redis como cache, não banco primário. Sua aplicação deve funcionar (mais devagar, mas corretamente) sem Redis. Design para failover.
6. Monitore constantemente. Hit ratio, eviction rate, memory fragmentation e latência de comandos devem ser métricas no seu dashboard.
7. Use conexões separadas para Pub/Sub. Um cliente em modo SUBSCRIBE não pode executar outros comandos.
8. Lua scripts para atomicidade. Para operações complexas que precisam ser atômicas, use scripts Lua com EVAL.
9. Cuidado com Big Keys. Chaves com valores grandes (>10MB) ou estruturas com muitos elementos (>10.000) podem causar latência de milissegundos a segundos. Use OBJECT ENCODING para diagnosticar.
10. Planeje a eviction policy. Para cache puro: allkeys-lru. Para dados que não podem ser evictados: volatile-lru (apenas keys com TTL são evictadas).
Publicado em 2025 | Categoria: Backend Performance | Tags: Redis, Cache, Node.js, Rate Limiting, Pub/Sub, BullMQ, Filas, Sessões, Performance