¿Qué es un cache stampede (thundering herd) y cómo se previene cuando expira una clave caliente?
Cuando una sola entrada de caché popular expira, miles de peticiones pueden caer sobre tu base de datos en el mismo instante — así se evita esa avalancha antes de que empiece.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Qué es un cache stampede (thundering herd) y cómo se previene cuando expira una clave caliente?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#El escenario
Una sola clave de Redis — product:42, homepage:featured, user:session-count — se lee miles de veces por segundo y se escribe una vez cada 60 segundos. Esa asimetría es exactamente la razón por la que la cacheaste: una lectura barata reemplaza una consulta cara a la base de datos, una y otra vez. Entonces el TTL llega a cero. En el mismo milisegundo, cada uno de esos miles de lectores recibe un fallo de caché (cache miss), y cada uno decide, de forma independiente, hacer lo "obvio": ir a recalcular el valor desde la base de datos. Lo que era una proporción de 1000:1 entre lecturas y escrituras se convierte, de golpe, en 1000 escrituras idénticas y simultáneas contra tu fuente de verdad — suficiente, muchas veces, para tumbar la base de datos, lo que a su vez hace fallar el siguiente intento de repoblar la caché, y la caída sobrevive al pico de tráfico que la causó.
Esto es un cache stampede, también llamado thundering herd (estampida/manada tronante). No lo causa un bug en tu lógica de caché en sentido estricto — el código normalmente hace exactamente lo que se le pidió (if not cached: compute and store). El problema es arquitectónico: nada coordina a los muchos llamadores que descubren el fallo en el mismo instante, así que todos pagan el costo de recalcular de forma independiente en lugar de una sola vez.
#Cómo se desarrolla
La secuencia siguiente muestra el modo de falla con tres clientes por claridad — en producción es lo que sea que tu concurrencia pico de peticiones sea para esa clave, que para una clave verdaderamente caliente puede ser del orden de miles.
Fíjate en las últimas tres líneas: incluso la recuperación es derrochadora. Tres (o tres mil) procesos pagan cada uno el costo completo de la consulta, y luego cada uno paga un costo de escritura, para llegar exactamente al mismo valor. La caché hizo su trabajo perfectamente durante 59.999 segundos y luego, por un instante, activamente empeoró las cosas más de lo que estarían sin ninguna caché — sin caché, la carga se habría repartido de forma uniforme en vez de llegar como un pico sincronizado.
#Mitigación 1: Lock de vuelo único (mutex)
La solución que corresponde exactamente al diagrama anterior: que solo un llamador recalcule realmente el valor, y que todos los demás esperen a que termine o sirvan algo obsoleto (stale) mientras tanto. Redis ofrece una primitiva atómica exactamente para esto — SET clave valor NX PX <ms> establece la clave solo si aún no existe, y la auto-expira después de ms milisegundos, así un worker que se cae nunca puede quedarse con el lock para siempre.
import timeimport jsonimport redisr = redis.Redis(host="localhost", port=6379, decode_responses=True)LOCK_TTL_MS = 5000 # red de seguridad: tiempo máximo que un rebuild puede tener el lockCACHE_TTL_SECONDS = 60 # TTL normal del valor cacheadoRETRY_DELAY_MS = 50def compute_expensive_value(key: str) -> str:# Sustituto de una consulta lenta a la BD, una agregación o una llamada externa.time.sleep(0.3)return json.dumps({"key": key, "computed_at": time.time()})def get_or_recompute(key: str, max_wait_ms: int = 2000) -> str:cached = r.get(key)if cached is not None:return cachedlock_key = f"lock:{key}"waited = 0while waited < max_wait_ms:# NX: solo el primer llamador gana el lock. PX: se auto-expira# incluso si este proceso muere a mitad del rebuild, así nunca# quedamos bloqueados para siempre.acquired = r.set(lock_key, "1", nx=True, px=LOCK_TTL_MS)if acquired:try:value = compute_expensive_value(key)r.set(key, value, ex=CACHE_TTL_SECONDS)return valuefinally:r.delete(lock_key)# Alguien más ya está reconstruyendo esta clave — espera un poco y# revisa si terminó, en vez de también golpear la base de datos.time.sleep(RETRY_DELAY_MS / 1000)waited += RETRY_DELAY_MScached = r.get(key)if cached is not None:return cached# Nadie terminó dentro de nuestro presupuesto de espera. Recalculamos# nosotros mismos en vez de bloquear al llamador indefinidamente — un# fallback degradado, no un cuelgue.return compute_expensive_value(key)
El finally: r.delete(lock_key) importa tanto como el SET ... NX — sin él, un rebuild exitoso mantendría el lock hasta que LOCK_TTL_MS expire de todas formas, serializando innecesariamente el siguiente rebuild legítimo. El ciclo de reintentos es lo que convierte "1000 peticiones golpean la base de datos" en "1 petición golpea la base de datos, 999 peticiones consultan Redis un par de veces y luego obtienen el valor fresco" — consultar Redis es órdenes de magnitud más barato que volver a ejecutar la consulta.
#Mitigación 2: Expiración temprana probabilística
El enfoque de lock es reactivo — solo actúa una vez que el fallo ya ocurrió. La expiración temprana probabilística (el algoritmo a veces se llama XFetch) es proactiva: en lugar de esperar a un vencimiento estricto, cada lector cerca del final de la vida de una clave la recalcula antes de tiempo, con una probabilidad que aumenta cuanto más te acercas al vencimiento real. Como solo una pequeña fracción de los lectores dispara un recálculo temprano en un momento dado, la manada nunca llega a formarse — el valor se refresca gracias a un lector con suerte (o mala suerte) bastante antes de que el TTL hubiera causado un fallo sincronizado.
La fórmula usa cuánto tardó el último cálculo (delta) como indicador de qué tan caro es refrescar en este momento, y un parámetro ajustable beta para controlar qué tan agresivamente se refresca antes de tiempo:
import mathimport randomimport timedef get_with_early_recompute(key: str, ttl_seconds: int, beta: float = 1.0):stored = r.hgetall(f"xf:{key}") # {"value", "expires_at", "delta"}now = time.time()if stored:expires_at = float(stored["expires_at"])delta = float(stored["delta"]) # cuánto tardó el último cálculo# Recalcula antes de tiempo con probabilidad creciente conforme# "now" se acerca a expires_at, en vez de esperar a un fallo duro.if now - delta * beta * math.log(random.random()) < expires_at:return stored["value"]start = time.time()value = compute_expensive_value(key)delta = time.time() - startr.hset(f"xf:{key}", mapping={"value": value,"expires_at": now + ttl_seconds,"delta": delta,})r.expire(f"xf:{key}", ttl_seconds * 2)return value
Como random.random() devuelve un valor en (0, 1), -log(random.random()) es un número positivo con distribución exponencial — pequeño la mayor parte del tiempo, ocasionalmente grande. Multiplicarlo por delta * beta significa: las claves baratas de calcular casi nunca se recalculan antes de tiempo, las claves caras empiezan a refrescarse bastante antes del vencimiento, y el recálculo se reparte en el tiempo entre lectores en vez de sincronizarse justo en el límite del TTL.
#Mitigación 3: Stale-while-revalidate
Una tercera opción evita el problema en lugar de coordinarse alrededor de él: seguir sirviendo el valor obsoleto mientras exactamente una tarea de fondo lo refresca, usando dos TTLs en vez de uno. Un TTL "suave" corto marca el valor como pendiente de refresco; un TTL "duro" más largo es la red de seguridad real, pasado el cual el valor se elimina por completo. Entre ambos, cada lector obtiene un acierto de caché instantáneo (el valor obsoleto), y un único job de fondo — no el camino de la petición — hace el recálculo. Es la misma idea que usan navegadores y CDNs mediante la directiva stale-while-revalidate de Cache-Control, solo que aplicada a tu propia capa de caché en vez de a HTTP.
#Cómo elegir una estrategia
| Enfoque | Latencia extra en el miss | Funciona entre muchas instancias de la app | Obsolescencia | Complejidad |
|---|---|---|---|---|
| Lock de vuelo único | Un llamador paga el costo completo; los demás esperan brevemente | Sí, si el lock vive en Redis (no en el proceso) | Ninguna — siempre fresco | Baja–media |
| Expiración temprana probabilística | Ninguna — el recálculo ocurre antes del vencimiento real | Sí, de forma natural (no requiere coordinación) | Ninguna en la práctica | Baja |
| Stale-while-revalidate | Ninguna — el valor obsoleto se sirve al instante | Sí | Acotada por el TTL suave | Media |
| No hacer nada | N/A | N/A | N/A | Gratis hasta que deja de serlo |
🧠 Check your understanding
0/3 · 0/3 answered1. ¿Qué diferencia a un cache stampede del tráfico normal de fallos de caché?
2. En el patrón de lock de vuelo único, ¿contra qué protege establecer el lock con un TTL en milisegundos (PX)?
3. La expiración temprana probabilística (XFetch) recalcula un valor cacheado...