Parte da nossa série Performance & Scalability
Leia o guia completoPadrões de cache Redis para aplicativos da Web
As consultas ao banco de dados são o gargalo de desempenho mais comum em aplicações web. Uma consulta mal indexada que leva 200 ms, chamada a cada carregamento de página para 1.000 usuários simultâneos, gera 200 segundos de CPU de banco de dados por segundo – uma espiral mortal. Redis é o antídoto: um armazenamento na memória de menos de um milissegundo que absorve a carga de leitura que, de outra forma, atingiria seu banco de dados repetidamente para obter dados idênticos.
Mas o cache não é apenas “colocar coisas no Redis”. O padrão errado causa bugs de dados obsoletos, desastres estrondosos ou crescimento ilimitado de memória. Este guia aborda os quatro padrões canônicos de cache, estratégias de invalidação de cache, prevenção de debandada e implementação de produção do NestJS para que você armazene o cache corretamente desde o início.
Principais conclusões
- Cache-aside (carregamento lento) é o padrão mais seguro - armazena em cache apenas o que é realmente solicitado
- Write-through mantém o cache e o banco de dados sincronizados, mas adiciona latência de gravação — use para dados lidos com frequência e gravados com pouca frequência
- A debandada de cache (rebanho trovejante) destrói bancos de dados quando uma chave popular expira — use expiração antecipada probabilística ou bloqueios mutex
- O TTL deve ser definido em todas as chaves — o crescimento ilimitado do Redis é um incidente esperando para acontecer
- A invalidação de cache é difícil: prefira TTLs curtos e invalidação orientada a eventos a tentar invalidar perfeitamente
- As chaves de cache devem incluir todas as dimensões da consulta:
contacts:{orgId}:{page}:{limit}:{search}:{sortField}:{sortDir}- Use namespaces e padrões de chave do Redis para ativar a invalidação em massa
- Monitorar taxa de acerto do cache; abaixo de 80% significa que seus TTLs são muito curtos ou que sua estrutura de chave está errada
Configuração do Redis no NestJS
pnpm add ioredis @nestjs-modules/ioredis
// src/modules/cache/cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
@Injectable()
export class CacheService {
private readonly logger = new Logger(CacheService.name);
constructor(@InjectRedis() private readonly redis: Redis) {}
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key);
if (!value) return null;
return JSON.parse(value) as T;
}
async set<T>(key: string, value: T, ttlSeconds = 300): Promise<void> {
await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
}
async del(key: string): Promise<void> {
await this.redis.del(key);
}
// Use SCAN instead of KEYS to avoid blocking Redis on large key spaces
async invalidatePattern(pattern: string): Promise<void> {
let cursor = '0';
do {
const [nextCursor, keys] = await this.redis.scan(
cursor,
'MATCH', pattern,
'COUNT', 100
);
cursor = nextCursor;
if (keys.length > 0) {
await this.redis.del(...keys);
}
} while (cursor !== '0');
}
async remember<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>
): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== null) return cached;
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
}
}
// src/modules/cache/cache.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-modules/ioredis';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheService } from './cache.service';
@Global()
@Module({
imports: [
RedisModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'single',
options: {
host: config.get('REDIS_HOST', 'localhost'),
port: config.get<number>('REDIS_PORT', 6379),
password: config.get('REDIS_PASSWORD'),
db: config.get<number>('REDIS_DB', 0),
keyPrefix: 'ecosire:',
},
}),
}),
],
providers: [CacheService],
exports: [CacheService],
})
export class AppCacheModule {}
Padrão 1: Cache-Aside (carregamento lento)
O padrão mais comum. Leia do cache; em caso de falha, leia o banco de dados e preencha o cache.
// contacts.service.ts
async findAll(orgId: string, page: number, limit: number, search?: string) {
const cacheKey = `contacts:${orgId}:${page}:${limit}:${search ?? 'all'}`;
return this.cache.remember(cacheKey, 300, async () => {
const results = await this.db.query.contacts.findMany({
where: and(
eq(contacts.organizationId, orgId),
search
? or(
ilike(contacts.name, `%${search}%`),
ilike(contacts.email, `%${search}%`)
)
: undefined
),
limit,
offset: (page - 1) * limit,
orderBy: desc(contacts.createdAt),
});
return results;
});
}
Prós: Simples; cache contém apenas o que é realmente solicitado. Contras: A primeira solicitação é sempre lenta (cache miss); os dados podem ficar obsoletos até que o TTL expire.
Quando invalidar: Após criar/atualizar/excluir, exclua todas as chaves correspondentes a contacts:{orgId}:*.
Padrão 2: Write-Through
Atualize o cache simultaneamente com o banco de dados em cada gravação. Troca a latência de gravação pela consistência de leitura.
async update(orgId: string, contactId: string, dto: UpdateContactDto) {
// 1. Update the database
const [updated] = await this.db
.update(contacts)
.set({ ...dto, updatedAt: new Date() })
.where(
and(
eq(contacts.id, contactId),
eq(contacts.organizationId, orgId)
)
)
.returning();
// 2. Update the individual contact cache immediately (write-through)
const singleKey = `contact:${orgId}:${contactId}`;
await this.cache.set(singleKey, updated, 3600);
// 3. Invalidate list caches (they now contain stale order/counts)
await this.cache.invalidatePattern(`contacts:${orgId}:*`);
return updated;
}
Quando usar: registros lidos com frequência e escritos com pouca frequência (perfis de usuário, configurações, catálogo de produtos).
Padrão 3: Prevenção de estouro de cache
Quando uma chave de cache popular expira, centenas de solicitações simultâneas perdem o cache simultaneamente e atingem o banco de dados - o "rebanho trovejante". O banco de dados entra em colapso sob a carga repentina.
Padrão de bloqueio mutex
Apenas uma solicitação reconstrói o cache; outros esperam e tentam novamente:
// cache.service.ts — mutex-protected cache fetch
async getWithLock<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>
): Promise<T> {
// Try cache first
const cached = await this.get<T>(key);
if (cached !== null) return cached;
const lockKey = `lock:${key}`;
const lockOwner = `${process.pid}-${Date.now()}`;
// Try to acquire lock: NX = only set if not exists, EX = expire in 30s
const acquired = await this.redis.set(lockKey, lockOwner, 'NX', 'EX', 30);
if (acquired === 'OK') {
try {
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
} finally {
// Atomic check-and-delete: only release if we still own the lock
// Uses Lua defined with defineCommand for atomicity
const currentOwner = await this.redis.get(lockKey);
if (currentOwner === lockOwner) {
await this.redis.del(lockKey);
}
}
} else {
// Another process is rebuilding — wait 100ms and retry
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 100);
// Store timer reference so Node.js does not block on it
timer.unref?.();
});
return this.getWithLock(key, ttlSeconds, factory);
}
}
Expiração antecipada probabilística (XFetch)
Comece a reconstruir o cache probabilisticamente antes que ele expire, espalhando a carga de recomputação:
async getWithEarlyExpiration<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>,
beta = 1 // Higher = more aggressive early recomputation
): Promise<T> {
const metaKey = `${key}:meta`;
const meta = await this.get<{ createdAt: number; ttl: number }>(metaKey);
const value = await this.get<T>(key);
if (value !== null && meta) {
const elapsed = Date.now() / 1000 - meta.createdAt;
const remaining = meta.ttl - elapsed;
// XFetch: trigger early recomputation probabilistically
const shouldRecompute = -Math.log(Math.random()) * beta > remaining;
if (!shouldRecompute) return value;
}
const newValue = await factory();
const now = Date.now() / 1000;
await this.set(key, newValue, ttlSeconds);
await this.set(metaKey, { createdAt: now, ttl: ttlSeconds }, ttlSeconds + 60);
return newValue;
}
Padrão 4: invalidação de cache baseada em tags
Agrupe chaves de cache por tags lógicas e invalide uma tag inteira de uma só vez:
// Tag-based invalidation using Redis sets
async setWithTags(
key: string,
value: unknown,
ttlSeconds: number,
tags: string[]
): Promise<void> {
await this.set(key, value, ttlSeconds);
// Add key to each tag's member set using a pipeline
const pipeline = this.redis.pipeline();
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, key);
pipeline.expire(`tag:${tag}`, ttlSeconds + 60);
}
await pipeline.exec();
}
async invalidateByTag(tag: string): Promise<void> {
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length > 0) {
const pipeline = this.redis.pipeline();
for (const cacheKey of keys) {
pipeline.del(cacheKey);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
}
}
// Usage
await this.setWithTags(
`contacts:${orgId}:page:1`,
contacts,
300,
[`org:${orgId}`, 'contacts']
);
// After any contact mutation in org_123:
await this.invalidateByTag(`org:${orgId}`);
Cache de sessão e autenticação
O Redis é ideal para armazenamento de sessões de autenticação — elimine pesquisas no banco de dados em todas as solicitações:
// Cache user session data (role, permissions, orgId) for JWT validation
async cacheUserSession(userId: string, session: UserSession): Promise<void> {
const key = `session:${userId}`;
await this.redis.hset(key,
'id', session.id,
'email', session.email,
'role', session.role,
'organizationId', session.organizationId,
'tokenVersion', String(session.tokenVersion)
);
await this.redis.expire(key, 900); // 15 minutes — matches access token TTL
}
async getUserSession(userId: string): Promise<UserSession | null> {
const data = await this.redis.hgetall(`session:${userId}`);
if (!data || Object.keys(data).length === 0) return null;
return {
...data,
tokenVersion: parseInt(data.tokenVersion, 10),
} as UserSession;
}
async invalidateUserSession(userId: string): Promise<void> {
await this.redis.del(`session:${userId}`);
}
Estratégia TTL por tipo de dados
| Tipo de dados | TTL recomendado | Estratégia de invalidação |
|---|---|---|
| Perfil de usuário | 15 minutos | Na atualização do perfil |
| Configurações da organização | 1 hora | Na alteração das configurações |
| Catálogo de produtos | 24 horas | Na criação/atualização do produto |
| Lista de postagens do blog | 10 minutos | Na postagem, publicar |
| Contador de limite de taxa API | Por janela (anos 60) | Expiração automática |
| Sessão de autenticação | 15 minutos | Ao sair ou revogar o token |
| Resultados da pesquisa | 5 minutos | Apenas TTL |
| Métricas agregadas | 1 minuto | Apenas TTL |
| Códigos únicos (auth) | 60 segundos | Após utilização ou expiração |
| Consulta de lista paginada | 5 minutos | Em qualquer mutação em org |
Monitorando o desempenho do cache
// Add hit/miss tracking to CacheService
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key);
if (value) {
await this.redis.incr('metrics:cache_hits');
return JSON.parse(value) as T;
}
await this.redis.incr('metrics:cache_misses');
return null;
}
// Report hit rate every 5 minutes
@Cron('*/5 * * * *')
async reportCacheMetrics(): Promise<void> {
const [hits, misses] = await this.redis.mget(
'metrics:cache_hits',
'metrics:cache_misses'
);
const h = parseInt(hits ?? '0', 10);
const m = parseInt(misses ?? '0', 10);
const total = h + m;
const hitRate = total === 0 ? 1 : h / total;
if (hitRate < 0.8) {
this.logger.warn(`Low cache hit rate: ${(hitRate * 100).toFixed(1)}%`);
}
// Reset counters
await this.redis.set('metrics:cache_hits', '0');
await this.redis.set('metrics:cache_misses', '0');
}
Principais métricas do Redis para monitorar via redis-cli INFO stats:
keyspace_hits/keyspace_misses— taxa de acerto global (meta acima de 80%)used_memory— observe o crescimento ilimitadoevicted_keys— chaves removidas pela política maxmemory (devem estar próximas de 0 para cache-aside)connected_clients— utilização do pool de conexõesinstantaneous_ops_per_sec— rendimento atual
Perguntas frequentes
Qual política de maxmemory devo usar para um cache?
Use allkeys-lru (remova as chaves usadas menos recentemente de todas as chaves) ou volatile-lru (remova as chaves LRU que possuem um TTL definido). Para uma carga de trabalho de cache pura, allkeys-lru é padrão – o Redis despeja automaticamente as chaves frias quando a memória está cheia. Nunca use noeviction para cache — ele retorna erros quando a memória está cheia em vez de despejar dados antigos.
Como evito armazenar dados confidenciais no Redis?
Nunca armazene em cache senhas brutas, chaves privadas, dados de cartões de pagamento ou SSNs. Para sessões de autenticação, armazene em cache os dados mínimos: userId, role, OrganizationId, tokenVersion — não cargas completas de JWT ou credenciais de API. Habilite Redis AUTH e TLS em produção. Se a sua instância do Redis estiver comprometida, apenas os metadados serão expostos quando você seguir esta disciplina.
Qual é a diferença entre SCAN e KEYS para exclusão de padrões?
KEYS pattern é uma operação de bloqueio O(n) que pausa todos os comandos do Redis durante a verificação — pode causar segundos de inatividade em grandes espaços de chave. SCAN não é bloqueador, iterando em pequenos pedaços com um cursor. Sempre use SCAN para exclusão do padrão de produção. A desvantagem é que SCAN pode não retornar todas as chaves se elas forem adicionadas ou excluídas durante a varredura – aceitável para invalidação de cache.
Devo usar uma instância separada do Redis para armazenamento em cache versus limitação de taxa versus sessões?
Para a maioria dos aplicativos, uma instância do Redis com diferentes prefixos de chave é adequada. Instâncias separadas fazem sentido quando: o cache precisa de um maxmemory-policy diferente do armazenamento de limite de taxa (cache: allkeys-lru, limites de taxa: noeviction) ou quando você precisa de escalabilidade e failover independentes. Em escala, use o Redis Cluster com clusters separados por tipo de carga de trabalho.
Como lidar com a invalidação de cache para consultas de lista paginada?
Os caches de listas paginadas são complicados – adicionar um contato na página 1 desloca todos os contatos nas páginas 2+. A solução pragmática: use TTLs curtos (2 a 5 minutos) e invalide todas as páginas da organização em qualquer gravação usando a invalidação de padrão (contacts:{orgId}:*). Para grandes organizações com grandes volumes de gravação, ignore totalmente a paginação do cache e confie na otimização no nível do banco de dados (índices adequados, índices de cobertura).
Como faço para testar o comportamento do cache no Vitest?
Simule o CacheService em testes de unidade com vi.fn() retornando valores apropriados para cenários de acerto/erro. Para testes de integração, use uma instância real do Redis (Docker) e use redis.flushdb() em beforeEach para começar do zero. Teste explicitamente o caminho de cache-hit (sem chamada de banco de dados) e o caminho de cache-miss (banco de dados chamado + cache preenchido).
Próximas etapas
O cache Redis é um dos investimentos de desempenho com maior ROI em um aplicativo da web. A diferença entre uma consulta de banco de dados de 200 ms e um acerto de 2 ms no Redis é de 100 vezes — em escala, isso se traduz diretamente em economia de custos de infraestrutura e melhoria da experiência do usuário.
ECOSIRE implementa cache Redis com cache-aside, invalidação de gravação, prevenção de stampede e monitoramento de taxa de acerto de cache em cada projeto NestJS. Explore nossos serviços de engenharia de back-end para saber como projetamos APIs escalonáveis e de alto desempenho.
Escrito por
ECOSIRE Research and Development Team
Construindo produtos digitais de nível empresarial na ECOSIRE. Compartilhando insights sobre integrações Odoo, automação de e-commerce e soluções de negócios com IA.
Artigos Relacionados
API Rate Limiting: Patterns and Best Practices
Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
NestJS 11 Enterprise API Patterns
Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.
Mais de Performance & Scalability
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
Nginx Production Configuration: SSL, Caching, and Security
Nginx production configuration guide: SSL termination, HTTP/2, caching headers, security headers, rate limiting, reverse proxy setup, and Cloudflare integration patterns.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Odoo vs Acumatica: Cloud ERP for Growing Businesses
Odoo vs Acumatica compared for 2026: unique pricing models, scalability, manufacturing depth, and which cloud ERP fits your growth trajectory.
Testing and Monitoring AI Agents in Production
A complete guide to testing and monitoring AI agents in production environments. Covers evaluation frameworks, observability, drift detection, and incident response for OpenClaw deployments.
Compliance Monitoring Agents with OpenClaw
Deploy OpenClaw AI agents for continuous compliance monitoring. Automate regulatory checks, policy enforcement, audit trail generation, and compliance reporting.