Performance · 11 min de lectura

Estrategias de Caché Avanzadas: Prevención de Cache Stampede y Thundering Herd

Publicado el 2 de junio de 2026

Estrategias de Caché Avanzadas: Prevención de Cache Stampede y Thundering Herd

Todo desarrollador sabe cómo agregar una caché. Verificás si la clave existe, si está la devolvés, si no está consultás la base de datos y guardás el resultado. Simple. Y funciona — hasta que no funciona.

La caché naive basada en TTL tiene dos modos de fallo que solo aparecen a escala. No se ven en desarrollo, no se ven en staging, y tienen la costumbre de aparecer a las 11 de la noche el Black Friday. Cache Stampede y Thundering Herd son los nombres de lo que pasa cuando tu estrategia de caché se rompe exactamente cuando más la necesitás.

En este artículo cubro por qué suceden, cómo prevenirlos, y cómo diseñar una capa de caché que aguante la presión real de producción.

La Implementación Naive y Su Problema

async function getUser(id: string): Promise<User> {
  const cached = await redis.get(`user:${id}`);
  if (cached) return JSON.parse(cached);

  const user = await db.users.findById(id);
  await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 300);
  return user;
}

Esto funciona con bajo tráfico. Con alto tráfico, tiene un modo de fallo específico: cada clave tiene un tiempo de expiración fijo, y cuando ese TTL llega bajo carga concurrente, todos los requests en vuelo fallan simultáneamente.

Cache Stampede: El Miss Sincronizado

Cache Stampede (también llamado cache miss storm o dog-pile effect) sucede cuando una clave popular de caché expira mientras muchos requests concurrentes la están esperando. Todos verifican, todos fallan, todos golpean la base de datos simultáneamente.

T=0:299   10.000 requests concurrentes → todos golpean la caché → hit, devuelven resultado
T=0:300   10.000 requests concurrentes → la clave acaba de expirar → todos fallan → 10.000 queries al DB
           DB recibe 10.000 queries por datos idénticos
           DB colapsa o da timeout
           Los requests que esperan al DB también empiezan a dar timeout
           La caché sigue vacía porque el primer query al DB todavía no terminó
           Más requests llegando → más misses → más queries al DB

El problema se retroalimenta. La base de datos está saturada, la latencia sube, los requests en vuelo dan timeout antes de poder repoblar la caché, así que la próxima ola de requests también falla. Estás en un loop de retroalimentación positiva que no se resuelve hasta que el tráfico baje.

Solución 1: Probabilistic Early Expiration (XFetch)

La solución más limpia es expirar la clave antes de que expire realmente, con una probabilidad que aumenta a medida que la clave se acerca a su expiración. Esto es el algoritmo XFetch, publicado por investigadores de Akamai y ampliamente usado en producción.

La idea: no esperés a que el TTL llegue. Empezá a refrescar antes, de forma probabilística, para que ningún momento en particular dispare un miss sincronizado.

interface CacheEntry<T> {
  value: T;
  delta: number;   // tiempo que tardó en computar este valor (ms)
  expiry: number;  // timestamp absoluto de expiración (ms)
}

async function fetchWithPER<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttlSeconds: number,
  beta = 1.0
): Promise<T> {
  const raw = await redis.get(key);

  if (raw) {
    const entry: CacheEntry<T> = JSON.parse(raw);
    const now = Date.now();
    // Fórmula XFetch: recomputa si la sonda aleatoria supera la expiración ajustada
    const shouldRefresh = now - entry.delta * beta * Math.log(Math.random()) >= entry.expiry;

    if (!shouldRefresh) return entry.value;
    // Si shouldRefresh: este único request recomputa mientras los demás siguen
    // recibiendo el valor stale — sin bloquear ni generar stampede
  }

  const start = Date.now();
  const value = await fetcher();
  const delta = Date.now() - start;

  const entry: CacheEntry<T> = {
    value,
    delta,
    expiry: Date.now() + ttlSeconds * 1000,
  };

  await redis.set(key, JSON.stringify(entry), 'EX', ttlSeconds + 10);
  return value;
}

beta controla la agresividad. beta = 1 es el estándar. Valores más altos refrescan más temprano. La matemática: a medida que expiry - now se aproxima a 0, la probabilidad de que shouldRefresh devuelva true se aproxima a 1 — pero distribuye esa probabilidad a través de muchos requests en el tiempo, así que el refresh ocurre antes de la expiración sin que todos los requests refresquen simultáneamente.

Solución 2: Mutex Distribuido (Lock-Based)

Para casos donde PER es demasiado complejo o necesitás semántica estricta de un solo escritor, un lock distribuido asegura que solo un request reconstruya la caché mientras los demás esperan o devuelven datos stale.

async function fetchWithLock<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttlSeconds: number
): Promise<T> {
  const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);

  const lockKey = `lock:${key}`;
  const lockToken = crypto.randomUUID();

  // SET NX EX atómico — solo tiene éxito para el primer requester
  const acquired = await redis.set(lockKey, lockToken, 'NX', 'EX', 10);

  if (acquired) {
    try {
      const value = await fetcher();
      await redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
      return value;
    } finally {
      // Liberar solo si todavía somos el dueño del lock (check-and-delete atómico via Lua)
      await redis.eval(
        `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`,
        1, lockKey, lockToken
      );
    }
  }

  // Otro proceso está reconstruyendo — esperar y reintentar
  await sleep(50);
  const refreshed = await redis.get(key);
  if (refreshed) return JSON.parse(refreshed);

  // Timeout del lock: fallback a consultar directamente (válvula de seguridad)
  return fetcher();
}

El script Lua para la liberación es crítico. Sin él, podrías eliminar un lock que fue re-adquirido por otro proceso después de que el tuyo expiró — una race condition rara pero catastrófica cuando ocurre.

Cuándo usar lock en lugar de PER: cuando el cómputo es costoso (>500ms), cuando no podés tolerar ni lecturas breves de datos stale, o cuando los datos cambian frecuentemente y PER constantemente dispararía refreshes.

Solución 3: Stale-While-Revalidate

La directiva HTTP stale-while-revalidate tiene un análogo server-side. Devolvé el valor stale inmediatamente mientras refrescás de forma asíncrona en el fondo. Sin espera, sin contención de locks, solo una breve ventana de datos levemente desactualizados.

interface SWREntry<T> {
  value: T;
  freshUntil: number;  // servir fresco hasta este timestamp
  staleUntil: number;  // servir stale hasta este timestamp (luego sí expiró)
}

async function fetchWithSWR<T>(
  key: string,
  fetcher: () => Promise<T>,
  freshTTL: number,    // segundos para servir fresco
  staleTTL: number     // segundos adicionales para servir stale mientras se refresca
): Promise<T> {
  const raw = await redis.get(key);
  const now = Date.now();

  if (raw) {
    const entry: SWREntry<T> = JSON.parse(raw);

    if (now < entry.freshUntil) {
      return entry.value; // todavía fresco, servir directamente
    }

    if (now < entry.staleUntil) {
      // Stale pero no expirado — devolver inmediatamente y refrescar en el fondo
      refreshInBackground(key, fetcher, freshTTL, staleTTL);
      return entry.value;
    }
  }

  // Verdaderamente expirado — hay que esperar datos frescos
  return refreshAndStore(key, fetcher, freshTTL, staleTTL);
}

function refreshInBackground<T>(key: string, fetcher: () => Promise<T>, freshTTL: number, staleTTL: number): void {
  // Fire and forget — usar un lock para evitar refreshes concurrentes en el fondo
  const lockKey = `bgrefresh:${key}`;
  redis.set(lockKey, '1', 'NX', 'EX', 5).then(acquired => {
    if (acquired) refreshAndStore(key, fetcher, freshTTL, staleTTL);
  });
}

Este es el patrón más amigable para el usuario: los clientes nunca esperan un cache miss, y los datos solo están desactualizados brevemente (la ventana entre el inicio del refresh en el fondo y su completado).

Thundering Herd: El Problema del Reinicio

Thundering Herd es similar pero opera a un nivel diferente. Describe lo que pasa cuando una gran cantidad de procesos que estaban dormidos despiertan simultáneamente para competir por un recurso — o cuando toda tu capa de caché se vacía de golpe.

El escenario clásico: tu instancia de Redis se reinicia después de un deploy. Todas las claves desaparecen. Tu aplicación recibe tráfico normal, pero ahora cada request falla, golpea la base de datos simultáneamente, y la base de datos — que estaba sirviendo cómodamente 200 qps a través de la caché — de repente recibe 50.000 qps de queries directos.

Jitter en el TTL

La mitigación más simple: nunca establecer un TTL fijo. Agregar varianza aleatoria para que las claves expiren en momentos diferentes en lugar de en olas sincronizadas.

function jitteredTTL(baseTTL: number, jitterFraction = 0.2): number {
  const jitter = baseTTL * jitterFraction;
  return Math.floor(baseTTL + (Math.random() * jitter * 2 - jitter));
}

// En lugar de: redis.set(key, value, 'EX', 300)
// Usar:        redis.set(key, value, 'EX', jitteredTTL(300))
// Las claves ahora expiran entre 240s y 360s — sin ola de expiración sincronizada

Esto solo elimina una categoría significativa de problemas de stampede que vienen de operaciones batch (cachear resultados de un cron job, calentar cachés al inicio) donde de otra forma estarías seteando miles de claves con TTLs idénticos.

Promise Coalescing (En el Proceso)

Para stampedes que suceden en la capa de aplicación — antes de que lleguen a Redis — colapsar requests concurrentes para la misma clave en una única promesa en vuelo.

const inFlight = new Map<string, Promise<unknown>>();

async function fetchCoalesced<T>(
  key: string,
  fetcher: () => Promise<T>
): Promise<T> {
  if (inFlight.has(key)) {
    return inFlight.get(key) as Promise<T>;
  }

  const promise = fetcher().finally(() => inFlight.delete(key));
  inFlight.set(key, promise);
  return promise;
}

Esto no reemplaza el lock a nivel Redis — opera por proceso. En un cluster de 20 instancias de Node.js, todavía obtenés 20 queries concurrentes al DB en un cold miss. Pero elimina el stampede dentro de cada proceso, reduciendo el multiplicador por el número de requests concurrentes por instancia. Combinado con un mutex de Redis, tenés protección completa.

Caché en Capas: L1 + L2

Para datos de alta lectura que cambian con poca frecuencia, una caché de dos niveles reduce los round-trips a Redis y protege contra picos de latencia en Redis.

import NodeCache from 'node-cache';

const l1 = new NodeCache({ stdTTL: 30, checkperiod: 10 }); // en proceso, TTL 30s

async function getWithLayeredCache<T>(
  key: string,
  fetcher: () => Promise<T>,
  redisTTL = 300
): Promise<T> {
  // L1: memoria en proceso (microsegundos)
  const l1Hit = l1.get<T>(key);
  if (l1Hit !== undefined) return l1Hit;

  // L2: Redis (sub-milisegundo local, ~1ms red)
  const l2Hit = await redis.get(key);
  if (l2Hit) {
    const value = JSON.parse(l2Hit) as T;
    l1.set(key, value);
    return value;
  }

  // Origen: base de datos
  const value = await fetcher();
  await redis.set(key, JSON.stringify(value), 'EX', redisTTL);
  l1.set(key, value);
  return value;
}

El TTL de L1 debe ser mucho más corto que el de L2 — 30s vs 300s — para limitar lecturas stale en deployments donde múltiples instancias tienen cachés L1 divergentes. Para datos que raramente cambian (config, feature flags, lookups estáticos), este patrón puede eliminar más del 90% del tráfico a Redis.

Invalidación de Caché: La Parte Difícil

La cita famosa — “solo hay dos cosas difíciles en ciencias de la computación: la invalidación de caché y nombrar cosas” — es un chiste, pero señala un problema real.

La invalidación orientada a eventos es más confiable que depender solo del TTL para datos mutables. Cuando un usuario se actualiza, publicá un evento de invalidación y eliminá la clave directamente en lugar de esperar que el TTL expire.

// En tu UserService
async updateUser(id: string, data: Partial<User>): Promise<User> {
  const user = await db.users.update(id, data);

  // Invalidar inmediatamente — no esperar al TTL
  await redis.del(`user:${id}`);

  // Si tenés un sistema pub/sub, difundir a otras instancias
  await pubsub.publish('cache:invalidate', { key: `user:${id}` });

  return user;
}

Para sistemas distribuidos donde múltiples servicios cachean los mismos datos, la invalidación write-through vía un message bus (Kafka, Redis Pub/Sub) asegura que todos los nodos limpien sus cachés de forma síncrona. Esto es preferible a depender de la convergencia por TTL entre servicios.

Mi Perspectiva Personal

Cache Stampede y Thundering Herd son el tipo de problemas que te hacen sentir un idiota cuando finalmente los entendés. La solución a “demasiados queries a la base de datos” es una caché. La solución a “la caché está causando demasiados queries a la base de datos” es una caché más inteligente. Parece circular.

El patrón al que recurro primero en producción es Stale-While-Revalidate con un lock en el fondo. Da la mejor experiencia de usuario (sin espera en lecturas stale), previene refreshes duplicados en el fondo, y es fácil de razonar. PER es matemáticamente elegante pero más difícil de explicar en un code review — y en producción, el código que podés explicarle a tu equipo generalmente es el que sobrevive.

Lo que he visto salir mal más frecuentemente en sistemas reales no es elegir el algoritmo equivocado — es no agregar jitter. Es una solución tan barata que pensarías que todos lo hacen por defecto. No lo hacen. Y entonces un deploy a las 2 AM calienta la caché de forma uniforme, todas las claves expiran 300 segundos después exactamente al mismo momento, y alguien recibe un alerta a las 2:05 preguntándose por qué la base de datos está en llamas.

El jitter en el TTL es una línea de código. Agregalo en todos lados. Solo te vas a agradecer a vos mismo.