Saltar al contenido
·34 min de lectura

Estrategias de caché con Redis que realmente funcionan en producción

Cache-aside, write-through, prevención de cache stampede, estrategias de TTL y patrones de invalidación. Los patrones de Redis que he usado en apps Node.js en producción con ejemplos de código reales.

Compartir:X / TwitterLinkedIn

Todo el mundo te dice que "simplemente añadas Redis" cuando tu API es lenta. Nadie te cuenta qué pasa seis meses después cuando tu caché está sirviendo datos obsoletos, tu lógica de invalidación está dispersa en 40 archivos, y un despliegue provoca un cache stampede que tumba tu base de datos con más fuerza que si nunca hubieras cacheado nada.

He estado ejecutando Redis en producción durante años. No como un juguete, no en un tutorial — en sistemas manejando tráfico real donde equivocarte con el caché significa alertas a las 3 AM. Lo que sigue es todo lo que he aprendido sobre cómo hacerlo bien.

¿Por qué cachear?#

Empecemos con lo obvio: las bases de datos son lentas en comparación con la memoria. Una consulta de PostgreSQL que tarda 15ms es rápida según los estándares de bases de datos. Pero si esa consulta se ejecuta en cada petición de la API, y estás manejando 1.000 peticiones por segundo, son 15.000ms de tiempo acumulado de base de datos por segundo. Tu pool de conexiones está agotado. Tu latencia p99 está por las nubes. Los usuarios están mirando spinners.

Redis sirve la mayoría de las lecturas en menos de 1ms. Esos mismos datos, cacheados, convierten una operación de 15ms en una operación de 0,3ms. Eso no es una micro-optimización. Es la diferencia entre necesitar 4 réplicas de base de datos y no necesitar ninguna.

Pero el caché no es gratis. Añade complejidad, introduce problemas de consistencia y crea una clase completamente nueva de modos de fallo. Antes de cachear cualquier cosa, pregúntate:

Cuándo el caché ayuda:

  • Los datos se leen mucho más frecuentemente de lo que se escriben (ratio 10:1 o superior)
  • La consulta subyacente es costosa (joins, agregaciones, llamadas a APIs externas)
  • Una ligera obsolescencia es aceptable (catálogo de productos, perfiles de usuario, configuración)
  • Tienes patrones de acceso predecibles (las mismas claves se consultan repetidamente)

Cuándo el caché perjudica:

  • Los datos cambian constantemente y deben estar frescos (precios de acciones en tiempo real, marcadores en vivo)
  • Cada petición es única (consultas de búsqueda con muchos parámetros)
  • Tu conjunto de datos es diminuto (si todo cabe en la memoria de tu aplicación, prescinde de Redis)
  • No tienes la madurez operativa para monitorear y depurar problemas de caché

Phil Karlton dijo famosamente que solo hay dos cosas difíciles en la informática: la invalidación de caché y nombrar cosas. Tenía razón en ambas, pero la invalidación de caché es la que te despierta por las noches.

Configurando ioredis#

Antes de sumergirnos en los patrones, establezcamos la conexión. Uso ioredis en todas partes — es el cliente Redis más maduro para Node.js, con soporte adecuado para TypeScript, modo clúster, soporte para Sentinel y scripting Lua.

typescript
import Redis from "ioredis";
 
const redis = new Redis({
  host: process.env.REDIS_HOST || "127.0.0.1",
  port: Number(process.env.REDIS_PORT) || 6379,
  password: process.env.REDIS_PASSWORD || undefined,
  db: Number(process.env.REDIS_DB) || 0,
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    const delay = Math.min(times * 200, 5000);
    return delay;
  },
  lazyConnect: true,
  enableReadyCheck: true,
  connectTimeout: 10000,
});
 
redis.on("error", (err) => {
  console.error("[Redis] Connection error:", err.message);
});
 
redis.on("connect", () => {
  console.log("[Redis] Connected");
});
 
export default redis;

Algunas cosas a destacar. lazyConnect: true significa que la conexión no se establece hasta que realmente ejecutas un comando, lo cual es útil durante las pruebas y la inicialización. retryStrategy implementa un backoff exponencial con un tope de 5 segundos — sin esto, una caída de Redis hace que tu aplicación bombardee con intentos de reconexión. Y maxRetriesPerRequest: 3 asegura que los comandos individuales fallen rápidamente en lugar de quedarse colgados para siempre.

Patrón Cache-Aside#

Este es el patrón que usarás el 80% del tiempo. También se llama "lazy loading" o "look-aside". El flujo es simple:

  1. La aplicación recibe una petición
  2. Comprueba Redis buscando el valor cacheado
  3. Si se encuentra (cache hit), lo devuelve
  4. Si no se encuentra (cache miss), consulta la base de datos
  5. Almacena el resultado en Redis
  6. Devuelve el resultado

Aquí tienes una implementación tipada:

typescript
import redis from "./redis";
 
interface CacheOptions {
  ttl?: number;       // seconds
  prefix?: string;
}
 
async function cacheAside<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: CacheOptions = {}
): Promise<T> {
  const { ttl = 3600, prefix = "cache" } = options;
  const cacheKey = `${prefix}:${key}`;
 
  // Step 1: Try to read from cache
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      return JSON.parse(cached) as T;
    } catch {
      // Corrupted cache entry, delete it and fall through
      await redis.del(cacheKey);
    }
  }
 
  // Step 2: Cache miss — fetch from source
  const result = await fetcher();
 
  // Step 3: Store in cache (don't await — fire and forget)
  redis
    .set(cacheKey, JSON.stringify(result), "EX", ttl)
    .catch((err) => {
      console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
    });
 
  return result;
}

El uso se ve así:

typescript
interface User {
  id: string;
  name: string;
  email: string;
  plan: "free" | "pro" | "enterprise";
}
 
async function getUser(userId: string): Promise<User | null> {
  return cacheAside<User | null>(
    `user:${userId}`,
    async () => {
      const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
      return row[0] ?? null;
    },
    { ttl: 1800 } // 30 minutes
  );
}

Observa que hago fire-and-forget en la llamada redis.set. Esto es intencional. Si Redis está caído o lento, la petición aún se completa. El caché es una optimización, no un requisito. Si la escritura en caché falla, la siguiente petición simplemente consultará la base de datos de nuevo. No es gran cosa.

Hay un bug sutil en muchas implementaciones de cache-aside que la gente pasa por alto: cachear valores nulos. Si un usuario no existe y no cacheas ese hecho, cada petición para ese usuario golpea la base de datos. Un atacante puede explotar esto solicitando IDs de usuario aleatorios, convirtiendo tu caché en algo inútil. Siempre cachea el resultado negativo también — solo con un TTL más corto.

typescript
async function getUserSafe(userId: string): Promise<User | null> {
  return cacheAside<User | null>(
    `user:${userId}`,
    async () => {
      const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
      return row[0] ?? null;
    },
    {
      // Shorter TTL for null results to limit memory usage
      // but long enough to absorb repeated misses
      ttl: row ? 1800 : 300,
    }
  );
}

En realidad, déjame reestructurar eso para que el TTL dinámico funcione correctamente:

typescript
async function getUserWithDynamicTTL(userId: string): Promise<User | null> {
  const cacheKey = `cache:user:${userId}`;
 
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as User | null;
  }
 
  const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
  const user: User | null = row[0] ?? null;
 
  // Cache exists results for 30 min, null results for 5 min
  const ttl = user ? 1800 : 300;
  await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
 
  return user;
}

Write-Through y Write-Behind#

Cache-aside funciona genial para cargas de trabajo con muchas lecturas, pero tiene un problema de consistencia: si otro servicio o proceso actualiza la base de datos directamente, tu caché estará obsoleto hasta que expire el TTL. Aquí entran los patrones write-through y write-behind.

Write-Through#

En write-through, cada escritura pasa por la capa de caché. El caché se actualiza primero, luego la base de datos. Esto garantiza que el caché siempre sea consistente con la base de datos (asumiendo que las escrituras siempre pasan por tu aplicación).

typescript
async function updateUser(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  // Step 1: Update the database
  const updated = await db.query(
    "UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *",
    [userId, updates.name, updates.email]
  );
  const user: User = updated[0];
 
  // Step 2: Update the cache immediately
  const cacheKey = `cache:user:${userId}`;
  await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
 
  return user;
}

La diferencia clave respecto a cache-aside: escribimos en el caché en cada escritura, no solo en las lecturas. Esto significa que el caché siempre está caliente para los datos actualizados recientemente.

La compensación: la latencia de escritura aumenta porque cada escritura ahora toca tanto la base de datos como Redis. Si Redis es lento, tus escrituras son lentas. En la mayoría de las aplicaciones, las lecturas superan ampliamente a las escrituras, así que esta compensación vale la pena.

Write-Behind (Write-Back)#

Write-behind invierte el guion: las escrituras van a Redis primero, y la base de datos se actualiza de forma asíncrona. Esto te da escrituras extremadamente rápidas a costa de una posible pérdida de datos si Redis se cae antes de que los datos se persistan.

typescript
async function updateUserWriteBehind(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const cacheKey = `cache:user:${userId}`;
 
  // Read current state
  const current = await redis.get(cacheKey);
  const user = current ? JSON.parse(current) as User : null;
  if (!user) throw new Error("User not in cache");
 
  // Update cache immediately
  const updated = { ...user, ...updates };
  await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
 
  // Queue database write for async processing
  await redis.rpush(
    "write_behind:users",
    JSON.stringify({ userId, updates, timestamp: Date.now() })
  );
 
  return updated;
}

Luego tendrías un worker separado drenando esa cola:

typescript
async function processWriteBehindQueue(): Promise<void> {
  while (true) {
    const item = await redis.blpop("write_behind:users", 5);
 
    if (item) {
      const { userId, updates } = JSON.parse(item[1]);
      try {
        await db.query(
          "UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1",
          [userId, updates.name, updates.email]
        );
      } catch (err) {
        // Re-queue on failure with retry count
        console.error("[WriteBehind] Failed:", err);
        await redis.rpush("write_behind:users:dlq", item[1]);
      }
    }
  }
}

Rara vez uso write-behind en la práctica. El riesgo de pérdida de datos es real — si Redis se cae antes de que el worker procese la cola, esas escrituras se pierden. Usa esto solo para datos donde la consistencia eventual es genuinamente aceptable, como contadores de visitas, eventos de analítica o preferencias de usuario no críticas.

Estrategia de TTL#

Acertar con el TTL es más matizado de lo que parece. Un TTL fijo de 1 hora para todo es fácil de implementar y casi siempre incorrecto.

Niveles de volatilidad de datos#

Categorizo los datos en tres niveles y asigno TTLs según corresponda:

typescript
const TTL = {
  // Tier 1: Rarely changes, expensive to compute
  // Examples: product catalog, site config, feature flags
  STATIC: 86400,       // 24 hours
 
  // Tier 2: Changes occasionally, moderate cost
  // Examples: user profiles, team settings, permissions
  MODERATE: 1800,      // 30 minutes
 
  // Tier 3: Changes frequently, cheap to compute but called often
  // Examples: feed data, notification counts, session info
  VOLATILE: 300,       // 5 minutes
 
  // Tier 4: Ephemeral, used for rate limiting and locks
  EPHEMERAL: 60,       // 1 minute
 
  // Null results: always short-lived
  NOT_FOUND: 120,      // 2 minutes
} as const;

Jitter de TTL: Previniendo el Thundering Herd#

Aquí tienes un escenario que me ha mordido: despliegas tu aplicación, el caché está vacío, y 10.000 peticiones cachean los mismos datos con un TTL de 1 hora. Una hora después, las 10.000 claves expiran simultáneamente. Las 10.000 peticiones golpean la base de datos a la vez. La base de datos se ahoga. He visto esto tumbar una instancia de Postgres en producción.

La solución es el jitter — añadir aleatoriedad a los valores de TTL:

typescript
function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
  const jitter = baseTtl * jitterPercent;
  const offset = Math.random() * jitter * 2 - jitter;
  return Math.max(1, Math.round(baseTtl + offset));
}
 
// Instead of: redis.set(key, value, "EX", 3600)
// Use:        redis.set(key, value, "EX", ttlWithJitter(3600))
 
// 3600 ± 10% = random value between 3240 and 3960

Esto distribuye la expiración a lo largo de una ventana, así que en lugar de 10.000 claves expirando en el mismo segundo, expiran a lo largo de una ventana de 12 minutos. La base de datos ve un aumento gradual en el tráfico, no un precipicio.

Para rutas críticas, voy más allá y uso un 20% de jitter:

typescript
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 seconds

Expiración deslizante#

Para datos tipo sesión donde el TTL debería reiniciarse en cada acceso, usa GETEX (Redis 6.2+):

typescript
async function getWithSlidingExpiry<T>(
  key: string,
  ttl: number
): Promise<T | null> {
  // GETEX atomically gets the value AND resets the TTL
  const value = await redis.getex(key, "EX", ttl);
  if (value === null) return null;
  return JSON.parse(value) as T;
}

Si estás en una versión anterior de Redis, usa un pipeline:

typescript
async function getWithSlidingExpiryCompat<T>(
  key: string,
  ttl: number
): Promise<T | null> {
  const pipeline = redis.pipeline();
  pipeline.get(key);
  pipeline.expire(key, ttl);
  const results = await pipeline.exec();
 
  if (!results || !results[0] || results[0][1] === null) return null;
  return JSON.parse(results[0][1] as string) as T;
}

Cache Stampede (Thundering Herd)#

El jitter de TTL ayuda con la expiración masiva, pero no resuelve el stampede de clave única: cuando una clave popular expira y cientos de peticiones concurrentes intentan regenerarla simultáneamente.

Imagina que cacheas el feed de tu página principal con un TTL de 5 minutos. Expira. Cincuenta peticiones concurrentes ven el cache miss. Las cincuenta golpean la base de datos con la misma consulta costosa. Efectivamente te has hecho un DDoS a ti mismo.

Solución 1: Bloqueo Mutex#

Solo una petición regenera el caché. Todos los demás esperan.

typescript
async function cacheAsideWithMutex<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T | null> {
  const cacheKey = `cache:${key}`;
  const lockKey = `lock:${key}`;
 
  // Try cache first
  const cached = await redis.get(cacheKey);
  if (cached !== null) {
    return JSON.parse(cached) as T;
  }
 
  // Try to acquire lock (NX = only if not exists, EX = auto-expire)
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (acquired) {
    try {
      // We got the lock — fetch and cache
      const result = await fetcher();
      await redis.set(
        cacheKey,
        JSON.stringify(result),
        "EX",
        ttlWithJitter(ttl)
      );
      return result;
    } finally {
      // Release lock
      await redis.del(lockKey);
    }
  }
 
  // Another request holds the lock — wait and retry
  await sleep(100);
 
  const retried = await redis.get(cacheKey);
  if (retried !== null) {
    return JSON.parse(retried) as T;
  }
 
  // Still no cache — fall through to database
  // (this handles the case where the lock holder failed)
  return fetcher();
}
 
function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Hay una condición de carrera sutil en la liberación del bloqueo anterior. Si el titular del bloqueo tarda más de 10 segundos (el TTL del bloqueo), otra petición adquiere el bloqueo, y entonces la primera petición elimina el bloqueo de la segunda petición. La solución adecuada es usar un token único:

typescript
import { randomUUID } from "crypto";
 
async function acquireLock(
  lockKey: string,
  ttl: number
): Promise<string | null> {
  const token = randomUUID();
  const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
  return acquired ? token : null;
}
 
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
  // Lua script ensures atomic check-and-delete
  const script = `
    if redis.call("get", KEYS[1]) == ARGV[1] then
      return redis.call("del", KEYS[1])
    else
      return 0
    end
  `;
  const result = await redis.eval(script, 1, lockKey, token);
  return result === 1;
}

Esto es esencialmente un Redlock simplificado. Para Redis de una sola instancia, es suficiente. Para configuraciones de Redis Cluster o Sentinel, investiga el algoritmo Redlock completo — pero honestamente, para la prevención de cache stampede, esta versión simple funciona bien.

Solución 2: Expiración anticipada probabilística#

Este es mi enfoque favorito. En lugar de esperar a que la clave expire, la regenera aleatoriamente un poco antes de la expiración. La idea viene de un artículo de Vattani, Chierichetti y Lowenstein.

typescript
interface CachedValue<T> {
  data: T;
  cachedAt: number;
  ttl: number;
}
 
async function cacheWithEarlyExpiration<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 3600
): Promise<T> {
  const cacheKey = `cache:${key}`;
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    const entry = JSON.parse(cached) as CachedValue<T>;
    const age = (Date.now() - entry.cachedAt) / 1000;
    const remaining = entry.ttl - age;
 
    // XFetch algorithm: probabilistically regenerate as expiry approaches
    // beta * Math.log(Math.random()) produces a negative number
    // that grows larger (more negative) as expiry approaches
    const beta = 1; // tuning parameter, 1 works well
    const shouldRegenerate =
      remaining - beta * Math.log(Math.random()) * -1 <= 0;
 
    if (!shouldRegenerate) {
      return entry.data;
    }
 
    // Fall through to regenerate
    console.log(`[Cache] Early regeneration triggered for ${key}`);
  }
 
  const data = await fetcher();
  const entry: CachedValue<T> = {
    data,
    cachedAt: Date.now(),
    ttl,
  };
 
  // Set with extra buffer so Redis doesn't expire before we can regenerate
  await redis.set(
    cacheKey,
    JSON.stringify(entry),
    "EX",
    Math.round(ttl * 1.1)
  );
 
  return data;
}

La belleza de este enfoque: a medida que el TTL restante de la clave disminuye, la probabilidad de regeneración aumenta. Con 1.000 peticiones concurrentes, quizás una o dos activarán la regeneración mientras el resto sigue sirviendo datos cacheados. Sin bloqueos, sin coordinación, sin esperas.

Solución 3: Stale-While-Revalidate#

Sirve el valor obsoleto mientras se regenera en segundo plano. Esto da la mejor latencia porque ninguna petición espera jamás al fetcher.

typescript
async function staleWhileRevalidate<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    freshTtl: number;   // how long the data is "fresh"
    staleTtl: number;   // how long stale data can be served
  }
): Promise<T | null> {
  const cacheKey = `cache:${key}`;
  const metaKey = `meta:${key}`;
 
  const [cached, meta] = await redis.mget(cacheKey, metaKey);
 
  if (cached !== null) {
    const parsedMeta = meta ? JSON.parse(meta) : null;
    const isFresh =
      parsedMeta && Date.now() - parsedMeta.cachedAt < options.freshTtl * 1000;
 
    if (!isFresh) {
      // Data is stale — serve it but trigger background refresh
      revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
    }
 
    return JSON.parse(cached) as T;
  }
 
  // Complete cache miss — must fetch synchronously
  return fetchAndCache(key, cacheKey, metaKey, fetcher, options);
}
 
async function fetchAndCache<T>(
  key: string,
  cacheKey: string,
  metaKey: string,
  fetcher: () => Promise<T>,
  options: { freshTtl: number; staleTtl: number }
): Promise<T> {
  const data = await fetcher();
  const totalTtl = options.freshTtl + options.staleTtl;
 
  const pipeline = redis.pipeline();
  pipeline.set(cacheKey, JSON.stringify(data), "EX", totalTtl);
  pipeline.set(
    metaKey,
    JSON.stringify({ cachedAt: Date.now() }),
    "EX",
    totalTtl
  );
  await pipeline.exec();
 
  return data;
}
 
function revalidateInBackground<T>(
  key: string,
  cacheKey: string,
  metaKey: string,
  fetcher: () => Promise<T>,
  options: { freshTtl: number; staleTtl: number }
): void {
  // Use a lock to prevent multiple background refreshes
  const lockKey = `revalidate_lock:${key}`;
 
  redis
    .set(lockKey, "1", "EX", 30, "NX")
    .then((acquired) => {
      if (!acquired) return;
 
      return fetchAndCache(key, cacheKey, metaKey, fetcher, options)
        .finally(() => redis.del(lockKey));
    })
    .catch((err) => {
      console.error(`[SWR] Background revalidation failed for ${key}:`, err);
    });
}

Uso:

typescript
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
  freshTtl: 300,     // 5 minutes fresh
  staleTtl: 3600,    // serve stale for up to 1 hour while revalidating
});

Uso este patrón para cualquier cosa orientada al usuario donde la latencia importa más que la frescura absoluta. Datos del dashboard, páginas de perfil, listados de productos — todos son candidatos perfectos.

Invalidación de caché#

Phil Karlton no bromeaba. La invalidación es donde el caché pasa de "optimización fácil" a "problema de sistemas distribuidos".

Invalidación simple basada en claves#

El caso más fácil: cuando actualizas un usuario, eliminas su clave de caché.

typescript
async function updateUserAndInvalidate(
  userId: string,
  updates: Partial<User>
): Promise<User> {
  const user = await db.query(
    "UPDATE users SET name = $2 WHERE id = $1 RETURNING *",
    [userId, updates.name]
  );
 
  // Invalidate the cache
  await redis.del(`cache:user:${userId}`);
 
  return user[0];
}

Esto funciona hasta que los datos del usuario aparecen en otros resultados cacheados. Quizás están incrustados en una lista de miembros del equipo. Quizás están en un resultado de búsqueda. Quizás están en 14 respuestas de API cacheadas diferentes. Ahora necesitas rastrear qué claves de caché contienen qué entidades.

Invalidación basada en etiquetas#

Etiqueta tus entradas de caché con las entidades que contienen, luego invalida por etiqueta.

typescript
async function setWithTags<T>(
  key: string,
  value: T,
  ttl: number,
  tags: string[]
): Promise<void> {
  const pipeline = redis.pipeline();
 
  // Store the value
  pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
 
  // Add the key to each tag's set
  for (const tag of tags) {
    pipeline.sadd(`tag:${tag}`, `cache:${key}`);
    pipeline.expire(`tag:${tag}`, ttl + 3600); // Tag sets live longer than values
  }
 
  await pipeline.exec();
}
 
async function invalidateByTag(tag: string): Promise<number> {
  const keys = await redis.smembers(`tag:${tag}`);
 
  if (keys.length === 0) return 0;
 
  const pipeline = redis.pipeline();
  for (const key of keys) {
    pipeline.del(key);
  }
  pipeline.del(`tag:${tag}`);
 
  await pipeline.exec();
  return keys.length;
}

Uso:

typescript
// When caching team data, tag it with all member IDs
const team = await fetchTeam(teamId);
await setWithTags(
  `team:${teamId}`,
  team,
  1800,
  [
    `entity:team:${teamId}`,
    ...team.members.map((m) => `entity:user:${m.id}`),
  ]
);
 
// When user 42 updates their profile, invalidate everything that contains them
await invalidateByTag("entity:user:42");

Invalidación dirigida por eventos#

Para sistemas más grandes, usa Redis Pub/Sub para difundir eventos de invalidación:

typescript
// Publisher (in your API service)
async function publishInvalidation(
  entityType: string,
  entityId: string
): Promise<void> {
  await redis.publish(
    "cache:invalidate",
    JSON.stringify({ entityType, entityId, timestamp: Date.now() })
  );
}
 
// Subscriber (in each app instance)
const subscriber = new Redis(/* same config */);
 
subscriber.subscribe("cache:invalidate", (err) => {
  if (err) console.error("[PubSub] Subscribe error:", err);
});
 
subscriber.on("message", async (_channel, message) => {
  const { entityType, entityId } = JSON.parse(message);
  await invalidateByTag(`entity:${entityType}:${entityId}`);
  console.log(`[Cache] Invalidated ${entityType}:${entityId}`);
});

Esto es crítico en despliegues multi-instancia. Si tienes 4 servidores de aplicación detrás de un balanceador de carga, una invalidación en el servidor 1 necesita propagarse a todos los servidores. Pub/Sub maneja esto automáticamente.

Invalidación basada en patrones (con cuidado)#

A veces necesitas invalidar todas las claves que coincidan con un patrón. Nunca uses KEYS en producción. Bloquea el servidor Redis mientras escanea todo el espacio de claves. Con millones de claves, esto puede bloquear durante segundos — una eternidad en términos de Redis.

Usa SCAN en su lugar:

typescript
async function invalidateByPattern(pattern: string): Promise<number> {
  let cursor = "0";
  let deletedCount = 0;
 
  do {
    const [nextCursor, keys] = await redis.scan(
      cursor,
      "MATCH",
      pattern,
      "COUNT",
      100
    );
    cursor = nextCursor;
 
    if (keys.length > 0) {
      await redis.del(...keys);
      deletedCount += keys.length;
    }
  } while (cursor !== "0");
 
  return deletedCount;
}
 
// Invalidate all cached data for a specific team
await invalidateByPattern("cache:team:42:*");

SCAN itera incrementalmente — nunca bloquea el servidor. La pista COUNT sugiere cuántas claves devolver por iteración (es una pista, no una garantía). Para espacios de claves grandes, este es el único enfoque seguro.

Dicho esto, la invalidación basada en patrones es un code smell. Si te encuentras escaneando frecuentemente, rediseña tu estructura de claves o usa etiquetas. SCAN es O(N) sobre el espacio de claves y está pensado para operaciones de mantenimiento, no para rutas calientes.

Estructuras de datos más allá de strings#

La mayoría de los desarrolladores tratan Redis como un almacén clave-valor para strings JSON. Eso es como comprar una navaja suiza y usar solo el abrebotellas. Redis tiene estructuras de datos ricas, y elegir la correcta puede eliminar categorías enteras de complejidad.

Hashes para objetos#

En lugar de serializar un objeto entero como JSON, almacénalo como un Hash de Redis. Esto te permite leer y actualizar campos individuales sin deserializar todo el objeto.

typescript
// Store user as a hash
async function setUserHash(user: User): Promise<void> {
  const key = `user:${user.id}`;
  await redis.hset(key, {
    name: user.name,
    email: user.email,
    plan: user.plan,
    updatedAt: Date.now().toString(),
  });
  await redis.expire(key, 1800);
}
 
// Read specific fields
async function getUserPlan(userId: string): Promise<string | null> {
  return redis.hget(`user:${userId}`, "plan");
}
 
// Update a single field
async function upgradeUserPlan(
  userId: string,
  plan: string
): Promise<void> {
  await redis.hset(`user:${userId}`, "plan", plan);
}
 
// Read entire hash as object
async function getUserHash(userId: string): Promise<User | null> {
  const data = await redis.hgetall(`user:${userId}`);
  if (!data || Object.keys(data).length === 0) return null;
 
  return {
    id: userId,
    name: data.name,
    email: data.email,
    plan: data.plan as User["plan"],
  };
}

Los hashes son eficientes en memoria para objetos pequeños (Redis usa una codificación compacta ziplist internamente) y evitan la sobrecarga de serialización/deserialización. La compensación: pierdes la capacidad de almacenar objetos anidados sin aplanarlos primero.

Sorted Sets para tablas de clasificación y limitación de tasa#

Los Sorted Sets son la estructura de datos más subestimada de Redis. Cada miembro tiene una puntuación, y el conjunto siempre está ordenado por puntuación. Esto los hace perfectos para tablas de clasificación, rankings y limitación de tasa con ventana deslizante.

typescript
// Leaderboard
async function addScore(
  leaderboard: string,
  userId: string,
  score: number
): Promise<void> {
  await redis.zadd(leaderboard, score, userId);
}
 
async function getTopPlayers(
  leaderboard: string,
  count: number = 10
): Promise<Array<{ userId: string; score: number }>> {
  const results = await redis.zrevrange(
    leaderboard,
    0,
    count - 1,
    "WITHSCORES"
  );
 
  const players: Array<{ userId: string; score: number }> = [];
  for (let i = 0; i < results.length; i += 2) {
    players.push({
      userId: results[i],
      score: parseFloat(results[i + 1]),
    });
  }
  return players;
}
 
async function getUserRank(
  leaderboard: string,
  userId: string
): Promise<number | null> {
  const rank = await redis.zrevrank(leaderboard, userId);
  return rank !== null ? rank + 1 : null; // 0-indexed to 1-indexed
}

Para limitación de tasa con ventana deslizante:

typescript
async function slidingWindowRateLimit(
  identifier: string,
  windowMs: number,
  maxRequests: number
): Promise<{ allowed: boolean; remaining: number }> {
  const key = `ratelimit:${identifier}`;
  const now = Date.now();
  const windowStart = now - windowMs;
 
  const pipeline = redis.pipeline();
 
  // Remove entries outside the window
  pipeline.zremrangebyscore(key, 0, windowStart);
 
  // Add current request
  pipeline.zadd(key, now, `${now}:${Math.random()}`);
 
  // Count requests in window
  pipeline.zcard(key);
 
  // Set expiry on the whole key
  pipeline.expire(key, Math.ceil(windowMs / 1000));
 
  const results = await pipeline.exec();
  const count = results?.[2]?.[1] as number;
 
  return {
    allowed: count <= maxRequests,
    remaining: Math.max(0, maxRequests - count),
  };
}

Esto es más preciso que el enfoque de contador con ventana fija y no sufre del problema de frontera donde una ráfaga al final de una ventana y al inicio de la siguiente efectivamente duplica tu límite de tasa.

Listas para colas#

Las Listas de Redis con LPUSH/BRPOP hacen excelentes colas de trabajo ligeras:

typescript
interface Job {
  id: string;
  type: string;
  payload: Record<string, unknown>;
  createdAt: number;
}
 
// Producer
async function enqueueJob(
  queue: string,
  type: string,
  payload: Record<string, unknown>
): Promise<string> {
  const job: Job = {
    id: randomUUID(),
    type,
    payload,
    createdAt: Date.now(),
  };
 
  await redis.lpush(`queue:${queue}`, JSON.stringify(job));
  return job.id;
}
 
// Consumer (blocks until a job is available)
async function dequeueJob(
  queue: string,
  timeout: number = 5
): Promise<Job | null> {
  const result = await redis.brpop(`queue:${queue}`, timeout);
  if (!result) return null;
 
  return JSON.parse(result[1]) as Job;
}

Para cualquier cosa más compleja que el encolado básico (reintentos, colas de mensajes muertos, prioridad, trabajos diferidos), usa BullMQ que se construye sobre Redis pero maneja todos los casos extremos.

Sets para seguimiento de únicos#

¿Necesitas rastrear visitantes únicos, deduplicar eventos o comprobar membresía? Los Sets son O(1) para añadir, eliminar y comprobar membresía.

typescript
// Track unique visitors per day
async function trackVisitor(
  page: string,
  visitorId: string
): Promise<boolean> {
  const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
  const isNew = await redis.sadd(key, visitorId);
 
  // Auto-expire after 48 hours
  await redis.expire(key, 172800);
 
  return isNew === 1; // 1 = new member, 0 = already existed
}
 
// Get unique visitor count
async function getUniqueVisitors(page: string, date: string): Promise<number> {
  return redis.scard(`visitors:${page}:${date}`);
}
 
// Check if user has already performed an action
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
  return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}

Para conjuntos muy grandes (millones de miembros), considera HyperLogLog en su lugar. Usa solo 12KB de memoria independientemente de la cardinalidad, a costa de un ~0,81% de error estándar:

typescript
// HyperLogLog for approximate unique counts
async function trackVisitorApprox(
  page: string,
  visitorId: string
): Promise<void> {
  const key = `hll:visitors:${page}:${new Date().toISOString().split("T")[0]}`;
  await redis.pfadd(key, visitorId);
  await redis.expire(key, 172800);
}
 
async function getApproxUniqueVisitors(
  page: string,
  date: string
): Promise<number> {
  return redis.pfcount(`hll:visitors:${page}:${date}`);
}

Serialización: JSON vs MessagePack#

JSON es la opción predeterminada para la serialización en Redis. Es legible, universal y suficientemente bueno para la mayoría de los casos. Pero para sistemas de alto rendimiento, la sobrecarga de serialización/deserialización se acumula.

El problema con JSON#

typescript
const user = {
  id: "usr_abc123",
  name: "Ahmet Kousa",
  email: "ahmet@example.com",
  plan: "pro",
  preferences: {
    theme: "dark",
    language: "tr",
    notifications: true,
  },
};
 
// JSON: 189 bytes
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
 
// JSON.parse on a hot path: ~0.02ms per call
// At 10,000 requests/sec: 200ms total CPU time per second

Alternativa MessagePack#

MessagePack es un formato de serialización binario que es más pequeño y más rápido que JSON:

bash
npm install msgpackr
typescript
import { pack, unpack } from "msgpackr";
 
// MessagePack: ~140 bytes (25% smaller)
const packed = pack(user);
console.log(packed.length); // ~140
 
// Store as Buffer
await redis.set("user:123", packed);
 
// Read as Buffer
const raw = await redis.getBuffer("user:123");
if (raw) {
  const data = unpack(raw);
}

Nota el uso de getBuffer en lugar de get — esto es crítico. get devuelve un string y corrompería datos binarios.

Compresión para valores grandes#

Para valores cacheados grandes (respuestas de API con cientos de elementos, HTML renderizado), añade compresión:

typescript
import { promisify } from "util";
import { gzip, gunzip } from "zlib";
 
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
 
async function setCompressed<T>(
  key: string,
  value: T,
  ttl: number
): Promise<void> {
  const json = JSON.stringify(value);
 
  // Only compress if larger than 1KB (compression overhead isn't worth it for small values)
  if (Buffer.byteLength(json) > 1024) {
    const compressed = await gzipAsync(json);
    await redis.set(key, compressed, "EX", ttl);
  } else {
    await redis.set(key, json, "EX", ttl);
  }
}
 
async function getCompressed<T>(key: string): Promise<T | null> {
  const raw = await redis.getBuffer(key);
  if (!raw) return null;
 
  try {
    // Try to decompress first
    const decompressed = await gunzipAsync(raw);
    return JSON.parse(decompressed.toString()) as T;
  } catch {
    // Not compressed, parse as regular JSON
    return JSON.parse(raw.toString()) as T;
  }
}

En mis pruebas, la compresión gzip típicamente reduce el tamaño del payload JSON entre un 70-85%. Una respuesta de API de 50KB se convierte en 8KB. Esto importa cuando estás pagando por memoria Redis — menos memoria por clave significa más claves en la misma instancia.

La compensación: la compresión añade 1-3ms de tiempo de CPU por operación. Para la mayoría de las aplicaciones, esto es insignificante. Para rutas de ultra-baja latencia, omítela.

Mi recomendación#

Usa JSON a menos que el profiling muestre que es un cuello de botella. La legibilidad y depurabilidad de JSON en Redis (puedes hacer redis-cli GET key y realmente leer el valor) supera la ganancia de rendimiento de MessagePack para el 95% de las aplicaciones. Añade compresión solo para valores mayores de 1KB.

Redis en Next.js#

Next.js tiene su propia historia de caché (Data Cache, Full Route Cache, etc.), pero Redis llena los huecos que el caché integrado no puede manejar — especialmente cuando necesitas compartir caché entre múltiples instancias o persistir el caché entre despliegues.

Cacheando respuestas de rutas API#

typescript
// app/api/products/route.ts
import { NextResponse } from "next/server";
import redis from "@/lib/redis";
 
export async function GET(request: Request) {
  const url = new URL(request.url);
  const category = url.searchParams.get("category") || "all";
  const cacheKey = `api:products:${category}`;
 
  // Check cache
  const cached = await redis.get(cacheKey);
  if (cached) {
    return NextResponse.json(JSON.parse(cached), {
      headers: {
        "X-Cache": "HIT",
        "Cache-Control": "public, s-maxage=60",
      },
    });
  }
 
  // Fetch from database
  const products = await db.products.findMany({
    where: category !== "all" ? { category } : undefined,
    orderBy: { createdAt: "desc" },
    take: 50,
  });
 
  // Cache for 5 minutes with jitter
  await redis.set(
    cacheKey,
    JSON.stringify(products),
    "EX",
    ttlWithJitter(300)
  );
 
  return NextResponse.json(products, {
    headers: {
      "X-Cache": "MISS",
      "Cache-Control": "public, s-maxage=60",
    },
  });
}

El header X-Cache es invaluable para depuración. Cuando la latencia se dispara, un rápido curl -I te dice si el caché está funcionando.

Almacenamiento de sesiones#

Next.js con Redis para sesiones supera a JWT para aplicaciones con estado:

typescript
// lib/session.ts
import { randomUUID } from "crypto";
import redis from "./redis";
 
interface Session {
  userId: string;
  role: string;
  createdAt: number;
  data: Record<string, unknown>;
}
 
const SESSION_TTL = 86400; // 24 hours
const SESSION_PREFIX = "session:";
 
export async function createSession(
  userId: string,
  role: string
): Promise<string> {
  const sessionId = randomUUID();
  const session: Session = {
    userId,
    role,
    createdAt: Date.now(),
    data: {},
  };
 
  await redis.set(
    `${SESSION_PREFIX}${sessionId}`,
    JSON.stringify(session),
    "EX",
    SESSION_TTL
  );
 
  return sessionId;
}
 
export async function getSession(
  sessionId: string
): Promise<Session | null> {
  const key = `${SESSION_PREFIX}${sessionId}`;
 
  // Use GETEX to refresh TTL on every access (sliding expiry)
  const raw = await redis.getex(key, "EX", SESSION_TTL);
  if (!raw) return null;
 
  return JSON.parse(raw) as Session;
}
 
export async function destroySession(sessionId: string): Promise<void> {
  await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
 
// Destroy all sessions for a user (useful for "logout everywhere")
export async function destroyAllUserSessions(
  userId: string
): Promise<void> {
  // This requires maintaining a user->sessions index
  const sessionIds = await redis.smembers(`user_sessions:${userId}`);
 
  if (sessionIds.length > 0) {
    const pipeline = redis.pipeline();
    for (const sid of sessionIds) {
      pipeline.del(`${SESSION_PREFIX}${sid}`);
    }
    pipeline.del(`user_sessions:${userId}`);
    await pipeline.exec();
  }
}

Middleware de limitación de tasa#

typescript
// middleware.ts (or a helper used by middleware)
import redis from "@/lib/redis";
 
interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}
 
export async function rateLimit(
  identifier: string,
  limit: number = 60,
  windowSeconds: number = 60
): Promise<RateLimitResult> {
  const key = `rate:${identifier}`;
  const now = Math.floor(Date.now() / 1000);
  const windowStart = now - windowSeconds;
 
  // Lua script for atomic rate limiting
  const script = `
    redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
    redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
    local count = redis.call('ZCARD', KEYS[1])
    redis.call('EXPIRE', KEYS[1], ARGV[4])
    return count
  `;
 
  const count = (await redis.eval(
    script,
    1,
    key,
    windowStart,
    now,
    `${now}:${Math.random()}`,
    windowSeconds
  )) as number;
 
  return {
    allowed: count <= limit,
    remaining: Math.max(0, limit - count),
    resetAt: now + windowSeconds,
  };
}

El script Lua es importante aquí. Sin él, la secuencia ZREMRANGEBYSCORE + ZADD + ZCARD no es atómica, y bajo alta concurrencia, el conteo podría ser inexacto. Los scripts Lua se ejecutan atómicamente en Redis — no pueden intercalarse con otros comandos.

Bloqueos distribuidos para Next.js#

Cuando tienes múltiples instancias de Next.js y necesitas asegurar que solo una procese una tarea (como enviar un email programado o ejecutar un trabajo de limpieza):

typescript
// lib/distributed-lock.ts
import { randomUUID } from "crypto";
import redis from "./redis";
 
export async function withLock<T>(
  lockName: string,
  fn: () => Promise<T>,
  options: { ttl?: number; retryDelay?: number; maxRetries?: number } = {}
): Promise<T | null> {
  const { ttl = 30, retryDelay = 200, maxRetries = 10 } = options;
  const token = randomUUID();
  const lockKey = `dlock:${lockName}`;
 
  // Try to acquire lock
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
 
    if (acquired) {
      try {
        // Extend lock automatically for long-running tasks
        const extender = setInterval(async () => {
          const script = `
            if redis.call("get", KEYS[1]) == ARGV[1] then
              return redis.call("expire", KEYS[1], ARGV[2])
            else
              return 0
            end
          `;
          await redis.eval(script, 1, lockKey, token, ttl);
        }, (ttl * 1000) / 3);
 
        const result = await fn();
        clearInterval(extender);
        return result;
      } finally {
        // Release lock only if we still own it
        const releaseScript = `
          if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
          else
            return 0
          end
        `;
        await redis.eval(releaseScript, 1, lockKey, token);
      }
    }
 
    // Wait before retrying
    await new Promise((r) => setTimeout(r, retryDelay));
  }
 
  // Could not acquire lock after all retries
  return null;
}

Uso:

typescript
// In a cron-triggered API route
export async function POST() {
  const result = await withLock("daily-report", async () => {
    // Only one instance runs this
    const report = await generateDailyReport();
    await sendReportEmail(report);
    return report;
  });
 
  if (result === null) {
    return NextResponse.json(
      { message: "Another instance is already processing" },
      { status: 409 }
    );
  }
 
  return NextResponse.json({ success: true });
}

El intervalo de extensión del bloqueo a ttl/3 es importante. Sin él, si tu tarea tarda más que el TTL del bloqueo, el bloqueo expira y otra instancia lo toma. El extensor mantiene el bloqueo vivo mientras la tarea se está ejecutando.

Monitoreo y depuración#

Redis es rápido hasta que deja de serlo. Cuando llegan los problemas, necesitas visibilidad.

Ratio de aciertos de caché#

La métrica más importante. Rastréala en tu aplicación:

typescript
// lib/cache-metrics.ts
import redis from "./redis";
 
const METRICS_KEY = "metrics:cache";
 
export async function recordCacheHit(): Promise<void> {
  await redis.hincrby(METRICS_KEY, "hits", 1);
}
 
export async function recordCacheMiss(): Promise<void> {
  await redis.hincrby(METRICS_KEY, "misses", 1);
}
 
export async function getCacheStats(): Promise<{
  hits: number;
  misses: number;
  hitRate: number;
}> {
  const stats = await redis.hgetall(METRICS_KEY);
  const hits = parseInt(stats.hits || "0", 10);
  const misses = parseInt(stats.misses || "0", 10);
  const total = hits + misses;
 
  return {
    hits,
    misses,
    hitRate: total > 0 ? hits / total : 0,
  };
}
 
// Reset metrics daily
export async function resetCacheStats(): Promise<void> {
  await redis.del(METRICS_KEY);
}

Un ratio de aciertos de caché saludable está por encima del 90%. Si estás por debajo del 80%, o tus TTLs son demasiado cortos, tus claves de caché son demasiado específicas, o tus patrones de acceso son más aleatorios de lo que pensabas.

Comando INFO#

El comando INFO es el panel de salud integrado de Redis:

bash
redis-cli INFO memory
# Memory
used_memory:1234567
used_memory_human:1.18M
used_memory_peak:2345678
used_memory_peak_human:2.24M
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23

Métricas clave a monitorear:

  • used_memory vs maxmemory: ¿Te estás acercando al límite?
  • mem_fragmentation_ratio: Por encima de 1,5 significa que Redis está usando significativamente más RSS que memoria lógica. Considera un reinicio.
  • evicted_keys: Si esto no es cero y no pretendías la evicción, te estás quedando sin memoria.
bash
redis-cli INFO stats

Vigila:

  • keyspace_hits / keyspace_misses: Ratio de aciertos a nivel de servidor
  • total_commands_processed: Rendimiento
  • instantaneous_ops_per_sec: Rendimiento actual

MONITOR (Usar con extrema precaución)#

MONITOR transmite cada comando ejecutado en el servidor Redis en tiempo real. Es increíblemente útil para depuración e increíblemente peligroso en producción.

bash
# NEVER leave this running in production
# It adds significant overhead and can log sensitive data
redis-cli MONITOR
1614556800.123456 [0 127.0.0.1:52340] "SET" "cache:user:123" "{\"name\":\"Ahmet\"}" "EX" "1800"
1614556800.234567 [0 127.0.0.1:52340] "GET" "cache:user:456"

Uso MONITOR para exactamente dos cosas: depurar problemas de nomenclatura de claves durante el desarrollo, y verificar que una ruta de código específica está golpeando Redis como se espera. Nunca por más de 30 segundos. Nunca en producción a menos que ya hayas agotado otras opciones de depuración.

Notificaciones del espacio de claves#

¿Quieres saber cuándo las claves expiran o se eliminan? Redis puede publicar eventos:

bash
# Enable keyspace notifications for expired and evicted events
redis-cli CONFIG SET notify-keyspace-events Ex
typescript
const subscriber = new Redis(/* config */);
 
// Listen for key expiration events
subscriber.subscribe("__keyevent@0__:expired", (err) => {
  if (err) console.error("Subscribe error:", err);
});
 
subscriber.on("message", (_channel, expiredKey) => {
  console.log(`Key expired: ${expiredKey}`);
 
  // Proactively regenerate important keys
  if (expiredKey.startsWith("cache:homepage")) {
    regenerateHomepageCache().catch(console.error);
  }
});

Esto es útil para el calentamiento proactivo de caché — en lugar de esperar a que un usuario active un cache miss, regeneras las entradas críticas en el momento en que expiran.

Análisis de memoria#

Cuando la memoria de Redis crece inesperadamente, necesitas encontrar qué claves están consumiendo más:

bash
# Sample 10 largest keys
redis-cli --bigkeys
# Scanning the entire keyspace to find biggest keys
[00.00%] Biggest string found so far '"cache:search:electronics"' with 524288 bytes
[25.00%] Biggest zset found so far '"leaderboard:global"' with 150000 members
[50.00%] Biggest hash found so far '"session:abc123"' with 45 fields

Para un análisis más detallado:

bash
# Memory usage of a specific key (in bytes)
redis-cli MEMORY USAGE "cache:search:electronics"
typescript
// Programmatic memory analysis
async function analyzeMemory(pattern: string): Promise<void> {
  let cursor = "0";
  const stats: Array<{ key: string; bytes: number }> = [];
 
  do {
    const [nextCursor, keys] = await redis.scan(
      cursor,
      "MATCH",
      pattern,
      "COUNT",
      100
    );
    cursor = nextCursor;
 
    for (const key of keys) {
      const bytes = await redis.memory("USAGE", key);
      if (bytes) {
        stats.push({ key, bytes: bytes as number });
      }
    }
  } while (cursor !== "0");
 
  // Sort by size descending
  stats.sort((a, b) => b.bytes - a.bytes);
 
  console.log("Top 20 keys by memory usage:");
  for (const { key, bytes } of stats.slice(0, 20)) {
    const mb = (bytes / 1024 / 1024).toFixed(2);
    console.log(`  ${key}: ${mb} MB`);
  }
}

Políticas de evicción#

Si tu instancia de Redis tiene un límite maxmemory (debería), configura una política de evicción:

bash
# In redis.conf or via CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lru

Políticas disponibles:

  • noeviction: Devuelve error cuando la memoria está llena (predeterminada, la peor para caché)
  • allkeys-lru: Expulsa la clave menos recientemente usada (mejor opción general para caché)
  • allkeys-lfu: Expulsa la clave menos frecuentemente usada (mejor si algunas claves se acceden en ráfagas)
  • volatile-lru: Solo expulsa claves con TTL establecido (útil si mezclas caché y datos persistentes)
  • allkeys-random: Evicción aleatoria (sorprendentemente decente, sin sobrecarga)

Para cargas de trabajo de caché puro, allkeys-lfu suele ser la mejor opción. Mantiene las claves frecuentemente accedidas en memoria incluso si no se han accedido recientemente.

Poniendo todo junto: Un módulo de caché para producción#

Aquí está el módulo de caché completo que uso en producción, combinando todo lo que hemos discutido:

typescript
// lib/cache.ts
import Redis from "ioredis";
 
const redis = new Redis({
  host: process.env.REDIS_HOST || "127.0.0.1",
  port: Number(process.env.REDIS_PORT) || 6379,
  password: process.env.REDIS_PASSWORD || undefined,
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    return Math.min(times * 200, 5000);
  },
});
 
// TTL tiers
const TTL = {
  STATIC: 86400,
  MODERATE: 1800,
  VOLATILE: 300,
  EPHEMERAL: 60,
  NOT_FOUND: 120,
} as const;
 
type TTLTier = keyof typeof TTL;
 
function ttlWithJitter(base: number, jitter = 0.1): number {
  const offset = base * jitter * (Math.random() * 2 - 1);
  return Math.max(1, Math.round(base + offset));
}
 
// Core cache-aside with stampede protection
async function get<T>(
  key: string,
  fetcher: () => Promise<T>,
  options: {
    tier?: TTLTier;
    ttl?: number;
    tags?: string[];
    swr?: { freshTtl: number; staleTtl: number };
  } = {}
): Promise<T> {
  const { tier = "MODERATE", tags } = options;
  const baseTtl = options.ttl ?? TTL[tier];
  const cacheKey = `c:${key}`;
 
  // Check cache
  const cached = await redis.get(cacheKey);
 
  if (cached !== null) {
    try {
      const parsed = JSON.parse(cached);
      recordHit();
      return parsed as T;
    } catch {
      await redis.del(cacheKey);
    }
  }
 
  recordMiss();
 
  // Acquire lock to prevent stampede
  const lockKey = `lock:${key}`;
  const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
 
  if (!acquired) {
    // Another process is fetching — wait briefly and retry cache
    await new Promise((r) => setTimeout(r, 150));
    const retried = await redis.get(cacheKey);
    if (retried) return JSON.parse(retried) as T;
  }
 
  try {
    const result = await fetcher();
    const ttl = ttlWithJitter(baseTtl);
 
    const pipeline = redis.pipeline();
    pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
 
    // Store tag associations
    if (tags) {
      for (const tag of tags) {
        pipeline.sadd(`tag:${tag}`, cacheKey);
        pipeline.expire(`tag:${tag}`, ttl + 3600);
      }
    }
 
    await pipeline.exec();
    return result;
  } finally {
    await redis.del(lockKey);
  }
}
 
// Invalidation
async function invalidate(...keys: string[]): Promise<void> {
  if (keys.length === 0) return;
  await redis.del(...keys.map((k) => `c:${k}`));
}
 
async function invalidateByTag(tag: string): Promise<number> {
  const keys = await redis.smembers(`tag:${tag}`);
  if (keys.length === 0) return 0;
 
  const pipeline = redis.pipeline();
  for (const key of keys) {
    pipeline.del(key);
  }
  pipeline.del(`tag:${tag}`);
  await pipeline.exec();
  return keys.length;
}
 
// Metrics
function recordHit(): void {
  redis.hincrby("metrics:cache", "hits", 1).catch(() => {});
}
 
function recordMiss(): void {
  redis.hincrby("metrics:cache", "misses", 1).catch(() => {});
}
 
async function stats(): Promise<{
  hits: number;
  misses: number;
  hitRate: string;
}> {
  const raw = await redis.hgetall("metrics:cache");
  const hits = parseInt(raw.hits || "0", 10);
  const misses = parseInt(raw.misses || "0", 10);
  const total = hits + misses;
 
  return {
    hits,
    misses,
    hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "N/A",
  };
}
 
export const cache = {
  get,
  invalidate,
  invalidateByTag,
  stats,
  redis,
  TTL,
};

Uso a lo largo de la aplicación:

typescript
import { cache } from "@/lib/cache";
 
// Simple cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
  tier: "VOLATILE",
  tags: ["entity:products"],
});
 
// With custom TTL
const config = await cache.get("app:config", fetchAppConfig, {
  ttl: 43200, // 12 hours
});
 
// After updating a product
await cache.invalidateByTag("entity:products");
 
// Check health
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);

Errores comunes que he cometido (para que tú no tengas que hacerlo)#

1. No establecer maxmemory. Redis usará felizmente toda la memoria disponible hasta que el SO lo mate. Siempre establece un límite.

2. Usar KEYS en producción. Bloquea el servidor. Usa SCAN. Aprendí esto cuando una llamada KEYS * desde un script de monitoreo causó 3 segundos de tiempo de inactividad.

3. Cachear de forma demasiado agresiva. No todo necesita ser cacheado. Si tu consulta de base de datos tarda 2ms y se llama 10 veces por minuto, el caché añade complejidad por un beneficio insignificante.

4. Ignorar los costos de serialización. Una vez cacheé un blob JSON de 2MB y me pregunté por qué las lecturas de caché eran lentas. La sobrecarga de serialización era mayor que la consulta de base de datos que se suponía debía ahorrar.

5. Sin degradación elegante. Cuando Redis se cae, tu aplicación debería seguir funcionando — solo más lenta. Envuelve cada llamada de caché en un try/catch que recurra a la base de datos. Nunca dejes que un fallo de caché se convierta en un error visible para el usuario.

typescript
async function resilientGet<T>(
  key: string,
  fetcher: () => Promise<T>
): Promise<T> {
  try {
    return await cache.get(key, fetcher);
  } catch (err) {
    console.error(`[Cache] Degraded mode for ${key}:`, err);
    return fetcher(); // Bypass cache entirely
  }
}

6. No monitorear las eviciones. Si Redis está expulsando claves, o estás infra-provisionado o estás cacheando demasiado. De cualquier manera, necesitas saberlo.

7. Compartir una instancia de Redis entre caché y datos persistentes. Usa instancias separadas (o al menos bases de datos separadas). Una política de evicción de caché que elimina tus entradas de cola de trabajos es un mal día para todos.

Conclusión#

El caché con Redis no es difícil, pero es fácil hacerlo mal. Empieza con cache-aside, añade jitter de TTL desde el primer día, monitorea tu ratio de aciertos y resiste la tentación de cachear todo.

La mejor estrategia de caché es la que puedes razonar a las 3 AM cuando algo se rompe. Mantenlo simple, mantenlo observable, y recuerda que cada valor cacheado es una mentira que le contaste a tus usuarios sobre el estado de tus datos — tu trabajo es mantener esa mentira lo más pequeña y breve posible.

Artículos relacionados