Parte de nuestra serie Performance & Scalability
Leer la guía completaPatrones de almacenamiento en caché de Redis para aplicaciones web
Las consultas a bases de datos son el cuello de botella de rendimiento más común en las aplicaciones web. Una consulta mal indexada que tarda 200 ms, solicitada en cada carga de página para 1000 usuarios simultáneos, genera 200 segundos de CPU de base de datos por segundo: una espiral de muerte. Redis es el antídoto: un almacén en memoria de menos de un milisegundo que absorbe la carga de lectura que, de otro modo, llegaría a su base de datos repetidamente para obtener datos idénticos.
Pero el almacenamiento en caché no consiste simplemente en "poner cosas en Redis". El patrón incorrecto provoca errores en los datos obsoletos, estruendosos desastres rebaños o un crecimiento ilimitado de la memoria. Esta guía cubre los cuatro patrones de almacenamiento en caché canónicos, las estrategias de invalidación de caché, la prevención de estampidas y la implementación de NestJS en producción para que pueda almacenar en caché correctamente desde el principio.
Conclusiones clave
- Caché aparte (carga diferida) es el valor predeterminado más seguro: solo almacena en caché lo que realmente se solicita
- La escritura directa mantiene sincronizadas la caché y la base de datos, pero agrega latencia de escritura: utilícela para datos que se leen con frecuencia y se escriben con poca frecuencia.
- La estampida de caché (rebaño atronador) destruye las bases de datos cuando caduca una clave popular: use vencimiento anticipado probabilístico o bloqueos mutex
- Se debe configurar TTL en cada clave: el crecimiento ilimitado de Redis es un incidente a punto de ocurrir
- La invalidación de la caché es difícil: prefiera los TTL cortos y la invalidación basada en eventos en lugar de intentar invalidar perfectamente
- Las claves de caché deben incluir todas las dimensiones de la consulta:
contacts:{orgId}:{page}:{limit}:{search}:{sortField}:{sortDir}- Utilice espacios de nombres y patrones de claves de Redis para habilitar la invalidación masiva
- Monitorear la tasa de aciertos de la caché; por debajo del 80% significa que sus TTL son demasiado cortos o su estructura clave es incorrecta
Configuración de Redis en 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 {}
Patrón 1: caché aparte (carga diferida)
El patrón más común. Leer desde caché; en caso de error, lea desde la base de datos y complete el caché.
// 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;
});
}
Ventajas: Sencillo; El caché solo contiene lo que realmente se solicita. Contras: La primera solicitud siempre es lenta (falta de caché); los datos pueden estar obsoletos hasta que caduque el TTL.
Cuándo invalidar: Después de crear/actualizar/eliminar, elimine todas las claves que coincidan con contacts:{orgId}:*.
Patrón 2: Escritura simultánea
Actualice el caché simultáneamente con la base de datos en cada escritura. Los intercambios escriben latencia para lograr coherencia en la lectura.
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;
}
Cuándo usarlo: registros leídos con frecuencia y escritos con poca frecuencia (perfiles de usuario, configuraciones, catálogo de productos).
Patrón 3: Prevención de estampida de caché
Cuando una clave de caché popular caduca, cientos de solicitudes simultáneas pierden el caché simultáneamente y todas llegan a la base de datos: el "rebaño atronador". La base de datos colapsa bajo la carga repentina.
Patrón de bloqueo mutex
Sólo una solicitud reconstruye el caché; otros esperan y vuelven a intentar:
// 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);
}
}
Vencimiento anticipado probabilístico (XFetch)
Comience a reconstruir el caché de manera probabilística antes de que caduque, distribuyendo la carga de recálculo:
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;
}
Patrón 4: Invalidación de caché basada en etiquetas
Agrupe las claves de caché por etiquetas lógicas e invalide una etiqueta completa a la 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}`);
Almacenamiento en caché de sesión y autenticación
Redis es ideal para el almacenamiento de sesiones de autenticación: elimine las búsquedas en bases de datos en cada solicitud:
// 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}`);
}
Estrategia TTL por tipo de datos
| Tipo de datos | TTL recomendado | Estrategia de invalidación |
|---|---|---|
| Perfil de usuario | 15 minutos | Sobre actualización de perfil |
| Configuración de la organización | 1 hora | Sobre el cambio de configuración |
| Catálogo de productos | 24 horas | Sobre creación/actualización de producto |
| Lista de publicaciones de blog | 10 minutos | En publicación posterior |
| Contador de límite de tasa API | Por ventana (60s) | Vencimiento automático |
| Sesión de autenticación | 15 minutos | Al cerrar sesión o revocar token |
| Resultados de la búsqueda | 5 minutos | Sólo TTL |
| Métricas agregadas | 1 minuto | Sólo TTL |
| Códigos de un solo uso (autenticación) | 60 segundos | Después de su uso o caducidad |
| Consulta de lista paginada | 5 minutos | Sobre cualquier mutación en org |
Supervisión del rendimiento de la caché
// 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');
}
Métricas clave de Redis para monitorear a través de redis-cli INFO stats:
keyspace_hits/keyspace_misses— tasa de acierto global (objetivo superior al 80%)used_memory— esté atento al crecimiento ilimitadoevicted_keys: claves expulsadas por la política de maxmemory (debe estar cerca de 0 para el almacenamiento en caché)connected_clients— utilización del grupo de conexionesinstantaneous_ops_per_sec— rendimiento actual
Preguntas frecuentes
¿Qué política de maxmemory debo usar para un caché?
Utilice allkeys-lru (desalojar las claves utilizadas menos recientemente de todas las claves) o volatile-lru (desalojar las claves LRU que tienen un TTL configurado). Para una carga de trabajo de caché pura, allkeys-lru es estándar: Redis expulsa automáticamente las claves frías cuando la memoria está llena. Nunca use noeviction para caché: devuelve errores cuando la memoria está llena en lugar de desalojar datos antiguos.
¿Cómo evito almacenar datos confidenciales en Redis?
Nunca almacene en caché contraseñas sin formato, claves privadas, datos de tarjetas de pago o números de seguro social. Para sesiones de autenticación, almacene en caché los datos mínimos: ID de usuario, rol, ID de organización, versión de token, no cargas útiles JWT completas ni credenciales de API. Habilite Redis AUTH y TLS en producción. Si su instancia de Redis está comprometida, solo se exponen los metadatos cuando sigue esta disciplina.
¿Cuál es la diferencia entre SCAN y KEYS para la eliminación de patrones?
KEYS pattern es una operación de bloqueo O(n) que pausa todos los comandos de Redis durante el escaneo; puede causar segundos de tiempo de inactividad en espacios clave grandes. SCAN no bloquea y se itera en pequeños fragmentos con un cursor. Utilice siempre SCAN para eliminar patrones de producción. La desventaja es que es posible que SCAN no devuelva todas las claves si se agregan o eliminan durante el escaneo, lo cual es aceptable para la invalidación de la caché.
¿Debo usar una instancia de Redis separada para el almacenamiento en caché, la limitación de velocidad y las sesiones?
Para la mayoría de las aplicaciones, una instancia de Redis con diferentes prefijos de clave está bien. Las instancias separadas tienen sentido cuando: la caché necesita un maxmemory-policy diferente al almacén de límite de velocidad (caché: allkeys-lru, límites de velocidad: noeviction), o cuando necesita escalado y conmutación por error independientes. A escala, utilice Redis Cluster con clústeres separados por tipo de carga de trabajo.
¿Cómo manejo la invalidación de caché para consultas de listas paginadas?
Los cachés de listas paginadas son complicados: agregar un contacto en la página 1 desplaza todos los contactos a las páginas 2+. La solución pragmática: utilice TTL cortos (de 2 a 5 minutos) e invalide todas las páginas de la organización en cualquier escritura mediante la invalidación de patrón (contacts:{orgId}:*). Para organizaciones grandes con grandes volúmenes de escritura, omita por completo la paginación del almacenamiento en caché y, en su lugar, confíe en la optimización a nivel de base de datos (índices adecuados, índices que cubren).
¿Cómo pruebo el comportamiento de la caché en Vitest?
Simule CacheService en pruebas unitarias con vi.fn() devolviendo valores apropiados para escenarios de acierto/error. Para las pruebas de integración, use una instancia real de Redis (Docker) y use redis.flushdb() en beforeEach para comenzar a limpiar. Pruebe explícitamente la ruta de acierto de caché (sin llamada a la base de datos) y la ruta de falta de caché (base de datos llamada + caché rellenado).
Próximos pasos
El almacenamiento en caché de Redis es una de las inversiones en rendimiento con mayor retorno de la inversión en una aplicación web. La diferencia entre una consulta de base de datos de 200 ms y un acceso a Redis de 2 ms es 100 veces mayor; a escala, eso se traduce directamente en ahorros de costos de infraestructura y mejora de la experiencia del usuario.
ECOSIRE implementa el almacenamiento en caché de Redis con caché aparte, invalidación de escritura directa, prevención de estampidas y monitoreo de la tasa de aciertos de caché en cada proyecto NestJS. Explore nuestros servicios de ingeniería backend para saber cómo diseñamos API escalables y de alto rendimiento.
Escrito por
ECOSIRE Research and Development Team
Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.
Artículos 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.
Más 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.