Imagina que la caché vence justo cuando entra mucha gente a tu app. Las tres copias de tu API buscan el mismo dato, no lo encuentran y salen a consultar la base de datos al tiempo. ¿Cómo haces para que una sola lo cargue y las demás esperen?
Armamos un ejemplo que puedes ejecutar: un catálogo en SQLite, una caché compartida en Redis y tres procesos de API. La consulta sí llega a una base de datos; no es una espera inventada para que la gráfica se vea bonita. Aquí está el código en GitHub.
Escriben la lógica de la API: mirar la caché, decidir quién carga el dato y devolverlo.
Comparte la respuesta entre procesos y les ayuda a elegir uno solo para hacer la consulta.
Guarda el catálogo del ejemplo. Así podemos contar cuántas consultas llegaron realmente a la base.
Levanta Redis en tu equipo para que puedas repetir la prueba.
¿Qué pasa cuando vence una clave popular?
Piensa en la lista de productos destacados de una tienda. La guardas en Redis por 30 segundos. Mientras está ahí, la API responde rápido. Pero si vence y llegan cien solicitudes casi al mismo tiempo, todas pueden intentar armar de nuevo la misma lista.
A eso se le llama estampida de caché. La documentación de Redis describe este problema. De pronto la base recibe un montón de consultas iguales; nadie le preguntó si quería protagonizar el pico de tráfico. Lo que falta es decidir quién hace la consulta y cómo esperan los demás.
Lo probamos: ¿cuántas consultas llegaron a la BD?
Creamos un catálogo de prueba en SQLite con 5.000 productos y 250.000 líneas de pedido. La consulta pide los 20 productos activos más vendidos de los últimos 30 días. Luego enviamos 100 solicitudes, repartidas entre tres procesos, cuando la caché estaba vacía. Contamos las lecturas dentro de la función que ejecuta SQL.
| Medida | Resultado local |
|---|---|
| Procesos de API | 3 |
| Solicitudes a la misma clave vacía | 100 |
| Respuestas idénticas | Sí |
| Lecturas ejecutadas contra SQLite | 1 |
Resultado: una lectura a SQLite para esa ráfaga. Los datos del catálogo son generados, pero Redis y SQLite sí se ejecutan. Es una prueba local: no mide la latencia de producción ni promete una sola lectura en cualquier fallo. Puedes ver el resultado guardado.
El código: una consulta, los demás esperan
La función get_or_load primero mira si el dato ya está en Redis. Si no, intenta tomar un bloqueo corto para esa clave con SET ... NX PX. El proceso que gana consulta SQLite y guarda la respuesta. Los otros esperan esa respuesta compartida.
def get_or_load(self, key: str, loader):
cache_key = f"{self.namespace}:data:{key}"
lock_key = f"{self.namespace}:lock:{key}"
deadline = time.monotonic() + self.wait_s
while time.monotonic() < deadline:
cached = self.client.get(cache_key)
if cached is not None:
return json.loads(cached)
token = secrets.token_hex(16)
if self.client.set(lock_key, token, nx=True, px=self.lock_ms):
try:
cached = self.client.get(cache_key)
if cached is not None:
return json.loads(cached)
value = loader()
payload = json.dumps(value, separators=(",", ":"))
published = self.client.eval(
_PUBLISH, 2, lock_key, cache_key,
token, payload, self.ttl_s
)
if published:
return value
finally:
self.client.eval(_RELEASE, 1, lock_key, token)
time.sleep(0.02)
raise TimeoutError(f"No cached value for {key}")
¿Y si el proceso ganador se demora? El bloqueo dura cinco segundos en este ejemplo. Si vence antes de que termine la consulta, otro proceso puede tomar uno nuevo. Para que el primero no borre el bloqueo ajeno ni publique un dato tarde, cada bloqueo lleva un token. Este pequeño script en Redis comprueba el token antes de guardar y liberar:
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[2], ARGV[2], 'EX', ARGV[3])
redis.call('DEL', KEYS[1])
return 1
end
return 0
En GitHub están la implementación completa, la prueba de integración, el Redis local y los pasos para ejecutarlo. Es una base útil, pero antes de llevarla a tu producto debes definir qué hacer con los errores, cómo medirla y qué tan frescos deben estar los datos.
Pruébalo en tu equipo
Clona el repositorio, entra a este ejemplo y ejecuta los comandos. La prueba falla si las 100 respuestas no son iguales o si SQLite recibe más de una consulta en esta ejecución.
cd examples/cache-stampede
docker compose up -d
python3 -m venv .venv
. .venv/bin/activate
pip install -r requirements.txt
python run_integration.py
python verify_edge_cases.py
docker compose down
Hay una segunda prueba para el caso incómodo: el bloqueo vence mientras alguien sigue cargando. Comprueba que el proceso anterior no publique encima del nuevo y que los demás dejen de esperar cuando corresponde. GitHub Actions ejecuta ambas pruebas en cada cambio y pull request.
En tu API cambiarías la consulta a SQLite por tu lectura real. La clave debe incluir todo lo que cambia la respuesta: dos catálogos distintos no pueden compartir el mismo resultado. Antes y después, mide fallos de caché, consultas a la base, tiempo de espera, timeouts y errores de Redis.
Antes de usarlo en producción
Cuando cambian los datos: después de guardar en la base, puedes borrar la caché. Pero ojo: una lectura vieja podría empezar antes del cambio y publicar después. Si necesitas frescura estricta, considera claves versionadas o control de generaciones. Un DEL por sí solo no resuelve todas las carreras.
Cuánto dura el bloqueo: si vence antes de que termine la consulta, otro proceso puede volver a consultar. El token evita que el primero publique tarde, pero no garantiza siempre una sola lectura. Si la carga tarda mucho, necesitarás renovar el bloqueo con cuidado o elegir otra estrategia.
Qué pasa si algo falla: nadie debería esperar para siempre. En este ejemplo hay un tiempo límite y puede salir un TimeoutError. Tu API debe decidir qué responder y registrarlo para poder investigarlo. Si Redis falla y todas las solicitudes van directo a la base, la estampida puede volver.
Qué datos meter en caché: encaja bien con datos que puedes reconstruir, como un catálogo público. Para saldos, pagos o inventario, primero define qué consistencia necesitas. Redis no debería convertirse por accidente en tu fuente de verdad.
Entonces, ¿cuándo usar esta solución?
Si muchas solicitudes chocan con la misma clave vencida, haz que una sola cargue el dato y las demás compartan la respuesta. Aquí funcionó: 100 solicitudes, tres procesos, una lectura a SQLite.
Si tu problema es que muchas claves distintas vencen a la vez, repartir sus tiempos de expiración puede ayudar más. Redis explica ambas situaciones. Mira primero qué está pasando en tu tráfico y mide el cambio antes de darlo por resuelto.