Saltar al contenido

Request coalescing: frenar el thundering herd en Node.js

Cuando una entrada de caché expira bajo carga, decenas de peticiones compiten por reconstruirla: deduplicarlas convierte la estampida en una sola llamada.

5 min de lectura
Múltiples solicitudes concurrentes convergiendo en una sola llamada a la base de datos de origen a través de una capa de coalescing

Cuando un valor en caché expira bajo tráfico alto, todas las solicitudes que llegan en los siguientes milisegundos fallan el caché al mismo tiempo. Cada una dispara la misma consulta a la base de datos, la misma llamada a una API externa, el mismo cómputo costoso, y todas producen el mismo resultado. Este es el problema del thundering herd, y es engañosamente fácil pasarlo por alto hasta que el tráfico es lo bastante alto como para que importe.

La solución no es un lock distribuido ni un bucle de espera y reintento. Es el inflight request coalescing: deduplicar las solicitudes concurrentes en curso para un mismo recurso, de modo que solo se ejecute una llamada al origen, y cada llamador en espera comparta el valor resuelto.

Por qué las soluciones obvias no funcionan

El primer intento más común es un mutex o un lock de Redis. Adquirir el lock, poblar el caché, liberar. Cada otra solicitud espera o devuelve datos obsoletos. Esto funciona, pero introduce estado distribuido, timeouts de lock y un modo de fallo adicional: ¿qué pasa cuando quien tiene el lock se cae a mitad de la población del caché?

El siguiente intento suele ser "simplemente extender el TTL". Pero ajustar el TTL es una apuesta: cambia frescura por evitar la estampida, en lugar de resolver la causa raíz. De todos modos vas a toparte con esto cuando despliegues una instancia fría o vacíes el caché explícitamente.

El problema real es más simple: múltiples llamadores están haciendo el mismo trabajo de forma concurrente cuando uno solo bastaría. Arregla eso directamente.

Inflight Request Coalescing con un Promise Map

El event loop de un solo hilo de Node.js hace que este patrón resulte especialmente limpio. Un Map que va de la clave del recurso a la Promise en curso es todo lo que necesitas. Cualquier llamador que llegue mientras ya hay una búsqueda en curso recibe la misma promesa. Todos se resuelven juntos cuando termina la llamada al origen.

tstypescript
// ❌ Every concurrent caller fires its own query
async function getUser(id: string): Promise<User> {
  const cached = await cache.get(`user:${id}`);
  if (cached) return cached;
 
  const user = await db.query("SELECT * FROM users WHERE id = $1", [id]);
  await cache.set(`user:${id}`, user, 60);
  return user;
}
 
// ✅ Concurrent callers share one in-flight request
const inflight = new Map<string, Promise<User>>();
 
async function getUser(id: string): Promise<User> {
  const cached = await cache.get(`user:${id}`);
  if (cached) return cached;
 
  const key = `user:${id}`;
  if (inflight.has(key)) return inflight.get(key)!;
 
  const promise = db
    .query("SELECT * FROM users WHERE id = $1", [id])
    .then(async (user) => {
      await cache.set(key, user, 60);
      return user;
    })
    .finally(() => inflight.delete(key));
 
  inflight.set(key, promise);
  return promise;
}

El bloque finally es fundamental. Elimina la entrada en curso tanto si la llamada al origen tiene éxito como si falla. Si solo la borras cuando hay éxito, un error transitorio bloqueará de forma permanente a todos los llamadores futuros para esa clave.

Construir un Coalescer reutilizable

Repetir el patrón Map + finally en línea en cada función de obtención de datos es ruidoso. Extráelo a una utilidad que envuelva cualquier función asíncrona:

tstypescript
type Fetcher<T> = () => Promise<T>;
 
class RequestCoalescer {
  private readonly inflight = new Map<string, Promise<unknown>>();
 
  async dedupe<T>(key: string, fetcher: Fetcher<T>): Promise<T> {
    if (this.inflight.has(key)) {
      return this.inflight.get(key) as Promise<T>;
    }
 
    const promise = fetcher().finally(() => this.inflight.delete(key));
    this.inflight.set(key, promise);
    return promise;
  }
 
  get size(): number {
    return this.inflight.size;
  }
}
 
// Usage
const coalescer = new RequestCoalescer();
 
async function getProduct(id: string): Promise<Product> {
  const cached = await cache.get(`product:${id}`);
  if (cached) return cached;
 
  return coalescer.dedupe(`product:${id}`, async () => {
    const product = await db.query(
      "SELECT * FROM products WHERE id = $1",
      [id],
    );
    await cache.set(`product:${id}`, product, 120);
    return product;
  });
}

El coalescer mantiene estado, así que inyéctalo en lugar de instanciarlo dentro de cada función. Lo habitual es tener una única instancia por servicio.

Manejar errores sin envenenar a los llamadores

Hay un bug sutil en las implementaciones ingenuas: si la llamada al origen lanza una excepción, todos los llamadores coalescidos reciben el mismo rechazo. Normalmente eso está bien, todos obtienen un error preciso. Pero si guardas en caché la propia promesa rechazada, los llamadores futuros que lleguen después del fallo seguirán recibiendo una promesa rechazada, aunque ya no haya ninguna llamada al origen en curso.

El bloque finally del ejemplo anterior maneja esto correctamente: la entrada en curso siempre se elimina al completarse, sin importar el resultado. Los llamadores futuros empiezan de cero.

!

No guardes en caché Promise.reject(...) en el Map de solicitudes en curso. Guarda en caché solo las promesas que están activamente en ejecución. La limpieza de finally garantiza que se cumpla este invariante.

Lo que sí podrías querer guardar en caché de forma deliberada es un resultado negativo de corta duración, para evitar bombardear un recurso que falla de forma consistente. Eso es un problema aparte (circuit breaking), y mezclarlo con el coalescing complica la implementación.

Acotar el Map de solicitudes en curso

En condiciones normales, el tamaño del Map se mantiene minúsculo: una entrada por cada clave de recurso distinta que se está obteniendo en ese momento. Pero si es posible un espacio de claves patológico (IDs controlados por un atacante, cursores de paginación sin límite), vale la pena añadir un límite de tamaño:

tstypescript
class RequestCoalescer {
  private readonly inflight = new Map<string, Promise<unknown>>();
  private readonly maxSize: number;
 
  constructor(maxSize = 1000) {
    this.maxSize = maxSize;
  }
 
  async dedupe<T>(key: string, fetcher: Fetcher<T>): Promise<T> {
    if (this.inflight.has(key)) {
      return this.inflight.get(key) as Promise<T>;
    }
 
    if (this.inflight.size >= this.maxSize) {
      // Fall through: let this caller fetch independently
      return fetcher();
    }
 
    const promise = fetcher().finally(() => this.inflight.delete(key));
    this.inflight.set(key, promise);
    return promise;
  }
}

Superar el límite provoca una degradación al comportamiento sin coalescing, en lugar de rechazar la solicitud. La protección contra el thundering herd se degrada con elegancia en lugar de fallar de forma abrupta.

Dónde aplica este patrón

El request coalescing es útil en cualquier lugar donde el trabajo sea costoso e idempotente. El caso de uso canónico es la población de caché, pero el mismo patrón aplica a:

EscenarioDiseño de la clave
Estampida de caché al expirarresource:id
Llamadas paralelas a la API para el mismo recurso de origenendpoint:params-hash
Generación de miniaturas o assets bajo demandaasset:id:size
Hidratación de sesión desde un almacén remotosession:token
Evaluaciones de feature flags contra un servicio de configuración remotoflags:context-hash

La restricción es que la operación debe ser segura de compartir: mismas entradas, misma salida, sin efectos secundarios que deban ejecutarse por cada llamador.

Puntos clave

  1. El thundering herd es un bug de concurrencia, no un bug de caché. Ajustar el TTL retrasa el problema; el coalescing lo elimina.
  2. Un Map<string, Promise<T>> es toda la implementación. Sin dependencias externas, sin estado distribuido, sin timeouts de lock.
  3. Limpia siempre con finally. Borrar la entrada en curso solo en caso de éxito deja una píldora envenenada permanente para los llamadores futuros después de cualquier fallo.
  4. Mantén el coalescer como un singleton inyectable. Instanciarlo por función significa que no hay deduplicación entre llamadores concurrentes en distintos puntos de llamada.
  5. Degrada con elegancia cuando el Map está lleno. Vuelve al comportamiento sin coalescing en lugar de rechazar las solicitudes directamente.
  6. Instrumenta el tamaño del Map de solicitudes en curso. Un Map persistentemente grande indica una dependencia de origen lenta o un problema en el espacio de claves que vale la pena investigar.
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX