Redis Caching Patterns for Web Applications

Master Redis caching patterns for Node.js and NestJS: cache-aside, write-through, cache stampede prevention, TTL strategies, invalidation, and production monitoring.

E
ECOSIRE Research and Development Team
|19 de marzo de 202611 min de lectura2.5k Palabras|

Parte de nuestra serie Performance & Scalability

Leer la guía completa

Patrones 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 datosTTL recomendadoEstrategia de invalidación
Perfil de usuario15 minutosSobre actualización de perfil
Configuración de la organización1 horaSobre el cambio de configuración
Catálogo de productos24 horasSobre creación/actualización de producto
Lista de publicaciones de blog10 minutosEn publicación posterior
Contador de límite de tasa APIPor ventana (60s)Vencimiento automático
Sesión de autenticación15 minutosAl cerrar sesión o revocar token
Resultados de la búsqueda5 minutosSólo TTL
Métricas agregadas1 minutoSólo TTL
Códigos de un solo uso (autenticación)60 segundosDespués de su uso o caducidad
Consulta de lista paginada5 minutosSobre 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 ilimitado
  • evicted_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 conexiones
  • instantaneous_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.

E

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.

Chatea en whatsapp