PostgreSQL vs Redis: cómo evitar que las estampidas de caché colapsen tu base de datos

¿Te fue útil?

La página es rápida en staging. En producción, es rápida… hasta que deja de serlo. Una clave de caché caduca, mil solicitudes se acumulan y de repente tu “base de datos de registro” también es tu base de arrepentimientos.
Postgres ahora está haciendo cardio a las 3 a.m. mientras Redis mira desde la grada como un portero que olvidó por qué el club está lleno.

Las estampidas de caché (también conocidas como thundering herds) son una de esas fallas que se sienten injustas: el sistema “funciona según lo diseñado”, y ese es el problema. Esta es una guía práctica para prevenir estampidas con Redis y mantener PostgreSQL vivo cuando la caché miente, caduca o alguien la vacía “solo por estar solucionando un problema”.

Cómo se ve una estampida de caché en producción

Una estampida de caché no es “la caché falló”. Un fallo de caché es un evento operativo normal. Una estampida es cuando un fallo de caché se sincroniza: muchos llamantes fallan la misma clave al mismo tiempo y todos acuden al almacén de respaldo juntos.
Es una falla coordinada sin reuniones.

Línea de tiempo típica

  • T-0: una clave caliente caduca, o un deploy cambia un namespace de caché, o Redis se reinicia y pierde datos.
  • T+1s: la latencia de las solicitudes se dispara; los hilos de la app empiezan a acumularse esperando a Postgres.
  • T+10s: Postgres alcanza la saturación de conexiones; la CPU sube; la cola de I/O crece; autovacuum ahora es tu segundo incendio.
  • T+30s: entran los reintentos upstream; el tráfico se multiplica; las cachés se convierten en rumor.
  • T+60s: empiezas a escalar nodos de app, lo que incrementa la concurrencia y empeora la situación.

Lo feo es que la mayoría de los sistemas están diseñados para fallar abierto: “si falla la caché, lee desde la BD”. Eso es correcto a pequeña escala.
A escala real, es un ataque de denegación de servicio que te aplicas a ti mismo, educadamente, con los dashboards de métricas abiertos.

Exactamente una cita, porque encaja: La esperanza no es una estrategia.General Gordon R. Sullivan

Chiste corto #1: Las estampidas de caché son como los donuts gratis de la oficina: una persona los menciona y de repente nadie recuerda “las restricciones del presupuesto”.

PostgreSQL vs Redis: roles, fortalezas y modos de fallo

PostgreSQL es la verdad (y mucha responsabilidad)

Postgres está diseñado para ser correcto: transacciones ACID, escrituras durables, índices, planificación de consultas, niveles de aislamiento y salvaguardas que priorizan la integridad sobre la velocidad.
No es una caché. Puede comportarse como una cuando tienes RAM y el working set cabe, pero las estampidas no se preocupan por tu buffer cache.

Postgres falla de maneras previsibles bajo estampidas:

  • Agotamiento de conexiones (demasiados clientes, demasiadas consultas concurrentes)
  • Saturación de CPU (muchas consultas idénticas y costosas con cachés frías)
  • Bloqueos de I/O (lecturas aleatorias y búsquedas en índices; checkpointer/wal; latencia de almacenamiento)
  • Amplificación de bloqueos (incluso lecturas pueden contender por metadatos o por efectos de cola)
  • Retraso de réplicas (las réplicas de lectura se ven golpeadas; el lag sube; eventualmente se leen datos obsoletos)

Redis es el molino de rumores (rápido, volátil y extremadamente útil)

Redis es un servidor de estructuras de datos en memoria. Es rápido, single-threaded por shard en el modo clásico (y aún efectivamente single-threaded para muchos comandos incluso en configuraciones más complejas), y diseñado para operaciones de baja latencia.
Tampoco es magia. Si tu protección contra estampidas depende de que Redis esté perfectamente disponible, has construido un punto único de fallo con dashboards más bonitos.

Redis falla de forma distinta:

  • Tormentas de expulsión si la política de memoria no está alineada con el tamaño de claves y TTLs
  • Picos de latencia durante persistencia (RDB/AOF fsync) o comandos lentos
  • Vaciados de caché (accidentales, operacionales o durante failover)
  • Contención de clave caliente (una sola clave recibe impacto; picos de CPU; colas de red)

La frontera de decisión: qué pertenece a cada uno

Pon esto en Postgres:

  • Datos de sistema de registro (system-of-record)
  • Rutas de escritura que requieren durabilidad y restricciones
  • Consultas ad-hoc complejas y analytics sobre el estado actual (con mesura)

Pon esto en Redis:

  • Datos derivados y proyecciones optimizadas para lectura
  • Rate limits, locks de corta duración, claves de idempotencia
  • Memoización compartida donde recomputar es aceptable
  • Primitivas de coordinación (con cuidado) como locks “single-flight”

La meta no es “Redis reemplaza a Postgres”. La meta es “Redis evita que Postgres vea el mismo problema miles de veces por segundo”.

Hechos interesantes y breve historia (porque el contexto ayuda)

  • Postgres empezó como POSTGRES a mediados de los 80 en UC Berkeley como sucesor de Ingres, con trabajo temprano en extensibilidad y reglas.
  • MVCC no es un truco de rendimiento; es un modelo de concurrencia. El MVCC de Postgres reduce bloqueos de lectura, pero también implica bloat y la existencia del vacuum.
  • Redis se creó en 2009 y se convirtió en la opción por defecto de “cache rápida + primitivas simples” para una generación de sistemas web.
  • El problema del “thundering herd” es anterior a la web; es un problema clásico de SO y sistemas distribuidos donde muchos despertadores se activan a la vez.
  • El jitter en TTL es un truco antiguo de primeros sistemas de cache: aleatorizar expiraciones para evitar invalidaciones sincronizadas.
  • Los CDNs popularizaron stale-while-revalidate como patrón de cache-control HTTP; la misma idea funciona en Redis con un poco de disciplina.
  • PgBouncer existe sobre todo porque las conexiones a Postgres son caras comparadas con muchos otros sistemas; demasiados clientes directos es una trampa conocida.
  • La persistencia de Redis es opcional por diseño; muchas implementaciones cambian durabilidad por velocidad, lo cual está bien hasta que alguien trata Redis como una base de datos.

Patrones que realmente evitan estampidas

1) Coalescencia de solicitudes (“single-flight”): una reconstrucción, muchos esperadores

Cuando una clave falla, no permitas que cada solicitud la reconstruya. Elige una solicitud para hacer el trabajo costoso; todos los demás esperan brevemente o reciben datos obsoletos.
Este es el control de estampidas más efectivo porque ataca el factor multiplicativo.

Puedes implementar coalescencia:

  • En proceso (funciona por instancia; no coordina entre la flota)
  • Distribuida con locks en Redis (coordina entre instancias; requiere timeouts cuidadosos)
  • A través de una cola de “cache builder” dedicada (más robusto, más piezas móviles)

2) Stale-while-revalidate: servir datos antiguos mientras se refrescan

Una caché con TTL estricto tiene un precipicio: al expirar, o tienes datos o no los tienes. stale-while-revalidate reemplaza ese precipicio por una pendiente:
mantiene un “fresh TTL” y un “stale TTL”. Durante la ventana stale, puedes servir el valor antiguo rápidamente mientras un trabajador lo refresca.

Este es el patrón que usas cuando te importa más la disponibilidad y la latencia que la frescura perfecta. La mayoría de las páginas de producto, bloques de recomendación y widgets “top N” califican.
“Saldo de cuenta” no.

3) Soft TTL + hard TTL: dos expiraciones, un sistema

Almacena metadata junto al valor en caché:

  • soft_ttl: después de esto, está bien refrescar en segundo plano
  • hard_ttl: después de esto, debes refrescar o fallar cerrado / degradar

Soft/hard TTL es una manera práctica de codificar la tolerancia de negocio a la obsolescencia sin depender de semánticas complejas de Redis.

4) TTL jitter: evitar expiraciones sincronizadas

Si un millón de claves fueron escritas por el mismo job con el mismo TTL, expirarán juntas. Eso no es “mala suerte”, es matemáticas.
Añade jitter: TTL = base ± aleatorio. Manténlo acotado.

Un buen jitter es lo bastante pequeño para no violar requisitos de producto y lo bastante grande para desincronizar reconstrucciones. Para un TTL de 10 minutos, ±60–120 segundos suele ser suficiente.

5) Cache negativa: cachear “no existe” y “permiso denegado”

Los resultados no encontrados siguen siendo resultados. Si un ID de usuario ausente o una página de producto inexistente provoca una consulta a la BD cada vez, atacantes (o clientes con bugs) pueden bombardearte con misses.
Cachea 404s y resultados “no rows” brevemente. Mantén el TTL corto para no ocultar registros recién creados.

6) Modelado de consultas amable con Postgres: reducir el coste por miss

Si cada miss de caché le cuesta a Postgres una consulta con múltiples joins y un sort, estás apostando tu uptime a que las cachés nunca fallen. Esa apuesta perderá.
Construye read models que sean baratos de calcular (o precomputados), y asegúrate de que los índices coincidan con tus patrones de acceso.

7) Backpressure y timeouts: fallar rápido antes de encolarte para siempre

El asesino de estampidas que ya tienes son los timeouts. El problema es que la mayoría de los sistemas los pone demasiado altos e inconsistentes.
Tu app debería dejar de esperar mucho antes de que Postgres esté totalmente abajo; de lo contrario construyes un backlog que se convierte en multiplicador del outage.

Quieres:

  • Timeouts más cortos en rutas de reconstrucción de caché que en lecturas interactivas
  • Límites de concurrencia por clave o por endpoint
  • Presupuestos de reintentos (reintentos limitados, backoff exponencial, jitter)

8) Proteger Postgres con pools y control de admisión

Las estampidas a menudo aparecen como “demasiadas conexiones” porque cada instancia de app abre más conexiones intentando ayudar.
Usa PgBouncer, limita la concurrencia en la capa de app, y considera pools separados para:

  • Lecturas interactivas
  • Reconstrucciones de caché en background
  • Jobs por lotes

9) Calienta la caché, pero hazlo como adulto

El cache warming funciona cuando está acotado y es medible. Falla cuando es un job ingenuo de “recalcular todo ahora”.
Un warmer seguro:

  • Prioriza las claves más calientes
  • Corre con un límite estricto de concurrencia
  • Se detiene cuando Postgres está bajo estrés
  • Usa los mismos controles anti-estampida que las lecturas de producción

Chiste corto #2: “Simplemente precalentaremos toda la caché” es el equivalente infraestructural de decir que “simplemente pagarán toda la deuda técnica este sprint”.

Guía de diagnóstico rápido

Cuando la latencia se dispara y tu dashboard parece arte moderno, no empieces por debatir arquitectura. Empieza por probar a dónde se va el tiempo.
El diagnóstico más rápido es una secuencia disciplinada.

Primero: ¿Redis está faltando, lento o vacío?

  • Comprueba la latencia y la tasa de comandos de Redis.
  • Confirma que la tasa de aciertos no se esté colapsando.
  • Busca eventos de expulsión o reinicio.
  • Identifica las claves calientes principales (o patrones) que causan misses.

Segundo: ¿Postgres está saturado o solo encolado?

  • Revisa conexiones activas vs max.
  • Revisa eventos de espera (locks, I/O, CPU).
  • Localiza las consultas repetidas principales; ve si se correlacionan con misses de caché.
  • Revisa el lag de réplicas si las lecturas van a réplicas.

Tercero: ¿la aplicación está multiplicando el problema?

  • Reintentos, timeouts, circuit breakers: ¿están sensatos?
  • ¿Hay coalescencia de solicitudes o un lock, o estás fan-out de reconstrucciones?
  • ¿Tu pool de conexiones está causando encolamiento (hilos esperando conexiones a la BD)?

Puntos de decisión

  • Si Redis está bien pero Postgres se está derritiendo, probablemente tienes baja tasa de aciertos, un namespace expirado o un camino caliente nuevo que evita la caché.
  • Si Redis está lento, arregla Redis primero; una caché lenta puede ser peor que no tener caché porque añade latencia y aun así fuerza trabajo a la BD.
  • Si ambos están bien pero la latencia de la app es alta, puede que tengas agotamiento del pool de hilos o llamadas downstream acumulándose.

Tareas prácticas: comandos, salidas y decisiones (12+)

Estos son chequeos de grado de producción. Cada tarea incluye: un comando, salida de ejemplo, qué significa y la decisión que tomas a partir de ello.
Las salidas son representativas; tus números serán distintos. El punto es la forma de los datos y la acción que impulsa.

Task 1: Verify Redis is up and measure instantaneous latency

cr0x@server:~$ redis-cli -h 127.0.0.1 -p 6379 --latency -i 1
min: 0, max: 3, avg: 0.45 (1000 samples)
min: 0, max: 12, avg: 1.10 (1000 samples)

Qué significa: Redis responde, pero picos máximos (12ms) pueden dañar la latencia de cola si tu SLO es estricto.

Decisión: Si max/avg sube durante incidentes, investiga comandos lentos, persistencia fsync o vecinos ruidosos antes de culpar a Postgres.

Task 2: Check Redis evictions and memory pressure

cr0x@server:~$ redis-cli INFO memory | egrep 'used_memory_human|maxmemory_human|mem_fragmentation_ratio'
used_memory_human:9.82G
maxmemory_human:10.00G
mem_fragmentation_ratio:1.62

Qué significa: Básicamente estás en el techo, con fragmentación. Las expulsiones son probables y la latencia puede aumentar.

Decisión: O subes maxmemory, reduces tamaños de claves o cambias la política de expulsión. No finjas que una caché llena es “está bien”.

Task 3: Confirm Redis eviction policy and whether it matches your cache design

cr0x@server:~$ redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"

Qué significa: Con noeviction, las escrituras fallarán bajo presión. Tu aplicación puede interpretar fallos como misses y provocar una estampida hacia la BD.

Decisión: Para cargas de caché, prefiere una política de expulsión como allkeys-lru o volatile-ttl según tu estrategia de claves, y maneja los misses con gracia.

Task 4: Spot hot keys by sampling Redis commands

cr0x@server:~$ redis-cli MONITOR
OK
1735588430.112345 [0 10.2.3.14:52144] "GET" "product:pricing:v2:sku123"
1735588430.112612 [0 10.2.3.22:49018] "GET" "product:pricing:v2:sku123"
1735588430.113001 [0 10.2.3.19:58820] "GET" "product:pricing:v2:sku123"

Qué significa: Una clave está siendo golpeada. Si expira, lo sentirás en todas partes.

Decisión: Añade coalescencia por clave y stale-while-revalidate para esa clase de claves; considera dividir la clave o cachear por segmento.

Task 5: Check Postgres connection pressure

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select count(*) as total, sum(case when state='active' then 1 else 0 end) as active from pg_stat_activity;"
 total | active
-------+--------
  480  |    210
(1 row)

Qué significa: Tienes muchas sesiones; muchas están activas. Si max_connections es ~500, estás cerca del límite.

Decisión: Si estás cerca del límite durante picos, mueve clientes detrás de PgBouncer y limita concurrencia en rutas de reconstrucción.

Task 6: Identify what Postgres is waiting on (locks, I/O, CPU)

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select wait_event_type, wait_event, count(*) from pg_stat_activity where state='active' group by 1,2 order by 3 desc;"
 wait_event_type |     wait_event      | count
-----------------+---------------------+-------
 IO              | DataFileRead        |    88
 Lock            | relation            |    31
 CPU             |                     |    12
(3 rows)

Qué significa: Las lecturas se estan deteniendo en disco y hay algo de contención por locks. Esto es consistente con una tormenta de misses.

Decisión: Reduce lecturas repetidas y costosas (coalescencia/stale), y revisa indexing. Para las esperas por locks, encuentra las consultas que bloquean.

Task 7: Find the blocking query chain

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select a.pid as blocked_pid, a.query as blocked_query, b.pid as blocking_pid, b.query as blocking_query from pg_stat_activity a join pg_stat_activity b on b.pid = any(pg_blocking_pids(a.pid)) where a.state='active';"
 blocked_pid |         blocked_query          | blocking_pid |        blocking_query
------------+--------------------------------+--------------+------------------------------
      9123  | update inventory set qty=qty-1 |         8871 | vacuum (analyze) inventory
(1 row)

Qué significa: Una operación de mantenimiento está bloqueando una escritura, lo que puede cascadear en reintentos y más carga.

Decisión: Si esto ocurre durante la respuesta a un incidente, considera pausar o reprogramar el mantenimiento; luego aborda por qué las actualizaciones de inventario están en el hot path de lecturas cacheadas.

Task 8: Identify the most expensive repeating queries during the stampede

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select calls, total_time, mean_time, left(query,120) as query from pg_stat_statements order by total_time desc limit 5;"
 calls | total_time | mean_time |                         query
-------+------------+-----------+---------------------------------------------------------
 92000 |  812340.12 |      8.83 | select price, currency from pricing where sku=$1 and...
 48000 |  604100.55 |     12.59 | select * from product_view where sku=$1

Qué significa: Las mismas consultas se ejecutan decenas de miles de veces. Esa es la firma de una tormenta de misses de caché.

Decisión: Añade coalescencia de solicitudes alrededor de esas claves de caché, y considera almacenar el modelo de lectura completo en Redis para evitar la consulta más pesada.

Task 9: Check replica lag if reads go to replicas

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select now() - pg_last_xact_replay_timestamp() as replica_lag;"
 replica_lag
-------------
 00:00:17.412
(1 row)

Qué significa: 17 segundos de lag pueden convertir “miss de caché, leer réplica” en “miss, leer obsoleto, luego invalidar y reintentar”.

Decisión: Durante carga alta, deja de enrutar lecturas críticas a réplicas retrasadas; prefiere caché obsoleta sobre réplicas con lag para datos no críticos.

Task 10: Inspect PgBouncer pool saturation

cr0x@server:~$ psql -h 127.0.0.1 -p 6432 -U pgbouncer -d pgbouncer -c "show pools;"
 database | user  | cl_active | cl_waiting | sv_active | sv_idle | sv_used | maxwait
----------+-------+-----------+------------+-----------+---------+---------+---------
 appdb    | app   |       120 |        380 |        80 |       0 |      80 |    12.5
(1 row)

Qué significa: Los clientes están esperando (380). Las conexiones al servidor están limitadas y totalmente usadas. Estás haciendo cola en el pool.

Decisión: No aumentes los tamaños de pool a ciegas. Añade control de admisión a rutas de reconstrucción; aumenta la capacidad DB solo después de reducir la amplificación de la estampida.

Task 11: Confirm Redis keyspace hit/miss trend

cr0x@server:~$ redis-cli INFO stats | egrep 'keyspace_hits|keyspace_misses'
keyspace_hits:182334901
keyspace_misses:44211022

Qué significa: Las misses son altas. Si la tasa de misses subió de golpe tras un deploy, probablemente cambiaste el formato de clave, TTL o serialización.

Decisión: Roolback de cambios de namespace de claves o añade lecturas retrocompatibles para claves antiguas; implementa rollout escalonado para cambios de esquema de caché.

Task 12: Check for Redis persistence stalls (AOF) contributing to latency

cr0x@server:~$ redis-cli INFO persistence | egrep 'aof_enabled|aof_last_write_status|aof_delayed_fsync'
aof_enabled:1
aof_last_write_status:ok
aof_delayed_fsync:137

Qué significa: Eventos de fsync retrasado indican que el SO/almacenamiento no da abasto. La latencia de Redis se disparará y los clientes pueden hacer timeout y caer al fallback de BD.

Decisión: Considera cambiar la política de fsync de AOF para carga de caché, o mueve Redis a almacenamiento más rápido / separa vecinos ruidosos. También ajusta timeouts de cliente para evitar tormentas de fallback a la BD.

Task 13: Find whether the application is retrying too aggressively

cr0x@server:~$ grep -R "retry" -n /etc/app/config.yaml | head
42:  retries: 5
43:  retry_backoff_ms: 10
44:  retry_jitter_ms: 0

Qué significa: 5 reintentos con backoff de 10ms y sin jitter golpeará a las dependencias durante incidentes.

Decisión: Reduce reintentos, añade backoff exponencial y jitter, e introduce un presupuesto de reintentos por clase de petición.

Task 14: Inspect top Postgres tables for cache-miss-driven I/O

cr0x@server:~$ psql -h 127.0.0.1 -U postgres -d appdb -c "select relname, heap_blks_read, heap_blks_hit from pg_statio_user_tables order by heap_blks_read desc limit 5;"
   relname    | heap_blks_read | heap_blks_hit
--------------+----------------+---------------
 pricing      |        9203812 |      11022344
 product_view |        7112400 |      15099112
(2 rows)

Qué significa: Muchas lecturas de disco en unas pocas tablas sugieren que tu working set no está en caché, o tu patrón de acceso es lo bastante aleatorio para fallar buffers.

Decisión: Reduce trabajo DB por petición (coalescencia/stale), añade índices o rediseña la proyección cacheada para que sea más barata.

Tres mini-historias corporativas desde las trincheras

Mini-historia 1: El incidente causado por una suposición equivocada

Una empresa de tipo retail tenía una “caché de precios” en Redis con TTL de 5 minutos. El equipo supuso que porque Redis era “in-memory”, estaba efectivamente siempre ahí y siempre rápido.
Su camino cache-aside se veía limpio: GET, si miss entonces consultar Postgres, luego SETEX. Simple. Demasiado simple.

Una tarde, Redis se reinició durante una ventana de mantenimiento rutinaria. Volvió rápido, pero el dataset estaba efectivamente frío. Los servidores de app interpretaron eso como una ola masiva de misses y fueron directos a Postgres.
El tráfico no era inusual. La caché fue la parte inusual: pasó de “mayoría hits” a “mayoría misses” en segundos.

Postgres no murió inmediatamente. Se encoló. Las conexiones subieron. Los pools de PgBouncer se llenaron. Los hilos de app quedaron bloqueados esperando una conexión, y los timeouts dispararon reintentos.
La suposición equivocada no fue “Redis es rápido.” La suposición equivocada fue “los misses de caché son eventos independientes.” No lo eran; estaban sincronizados por el reinicio.

La solución no fue exótica. Añadieron stale-while-revalidate con soft TTL y hard TTL, y coalescencia de solicitudes por clave usando un lock corto en Redis.
Después de eso, un reinicio de Redis causó páginas más lentas durante unos minutos, no un incidente de base de datos. Fue menos dramático, que es el mayor elogio en operaciones.

Mini-historia 2: La optimización que salió mal

Un equipo B2B SaaS se puso agresivo con la “frescura”. Acortaron TTLs de 10 minutos a 30 segundos en un conjunto de widgets de dashboard porque ventas quería actualizaciones más rápidas.
También introdujeron un job background que “calentaba” la caché recomputando claves populares cada 25 segundos. En papel, significaba menos misses y datos más frescos.

En la práctica, el job de warmer y el tráfico real se alinearon. Ambos golpeaban el mismo conjunto de claves aproximadamente al mismo tiempo. Con TTLs cortos, las claves expiraban con frecuencia; con el job de warmer, estaban constantemente siendo reconstruidas.
Habían creado una mini-estampida permanente. No un pico dramático, sino una carga elevada persistente que hizo frágil a todo el resto del trabajo de Postgres.

El síntoma fue sigiloso: no grandes picos de misses, solo latencia media de consultas aumentada y esperas por locks ocasionales. Autovacuum empezó a quedarse detrás porque el sistema estaba lo bastante ocupado como para que el mantenimiento nunca tuviera un momento tranquilo.
Los ingenieros persiguieron “consultas lentas” y “malos planes” durante semanas, porque el sistema no estaba visiblemente en llamas. Estaba simplemente demasiado caliente siempre.

La solución fue dejar de reconstruir con cadencia fija y pasar a invalidación basada en eventos para el pequeño subconjunto de claves que realmente necesitaban frescura. Para todo lo demás: TTLs más largos, jitter y refresh soft TTL solo cuando se accede.
También impusieron límites duros en la concurrencia del warmer y lo hicieron retroceder cuando la latencia de Postgres subía.

La frescura no es gratis. TTLs cortos son un impuesto que pagas para siempre, no una tarifa única que negocias con producto.

Mini-historia 3: La práctica aburrida pero correcta que salvó el día

Una compañía de medios corría Postgres con PgBouncer y tenía una regla estricta: las reconstrucciones background y los jobs por lotes usan pools y usuarios DB separados.
No era glamoroso. Era una hoja de cálculo de límites y un par de archivos de configuración que nadie quería tocar.

Durante un pico de tráfico desencadenado por una noticia de última hora, su cluster de Redis empezó a mostrar latencia elevada debido a un vecino ruidoso en los hosts subyacentes.
La tasa de aciertos de caché cayó y los servidores de app empezaron a caer más al fallback a Postgres de lo habitual. Aquí es donde la mayoría de los sistemas se estrellan.

En cambio, el pool separation de PgBouncer realizó su trabajo silencioso. El pool interactivo se mantuvo usable porque el pool background se saturó primero. Los jobs de reconstrucción se encolaron, no las peticiones de usuario.
El sitio se volvió más lento, pero se mantuvo arriba. Los editores siguieron publicando, los usuarios siguieron leyendo y el incidente permaneció un incidente en lugar de un titular.

Después, ajustaron Redis, arreglaron la aislamiento de hosts y mejoraron timeouts de cliente. Pero lo que más importó ese día fue la disciplina aburrida de control de admisión y separación de pools.
La fiabilidad es a menudo negarse a ser ingenioso en los lugares equivocados.

Errores comunes: síntomas → causa raíz → solución

1) Síntoma: aumento súbito de QPS en Postgres justo después de un deploy

Causa raíz: cambio de namespace de claves de caché (prefijo/version bump), que efectivamente invalida la caché global.

Solución: versiona las claves gradualmente, lee ambas namespaces durante el rollout, o precalienta con límites estrictos de concurrencia más coalescencia.

2) Síntoma: Redis está “up” pero aumentan timeouts de la app y la carga en BD sube

Causa raíz: picos de latencia en Redis (fsync de persistencia, comando lento, saturación de CPU); los clientes hacen timeout y caen al fallback de BD.

Solución: arregla la latencia de Redis primero; aumenta ligeramente timeouts de cliente (no infinitamente), añade hedging con cuidado e implementa lecturas stale para que el fallback a BD no sea la reacción por defecto.

3) Síntoma: “demasiadas conexiones” a Postgres durante picos de tráfico

Causa raíz: ausencia de pooling o pooling inefectivo; instancias de app abren más conexiones bajo presión; rutas de reconstrucción comparten pool con lecturas interactivas.

Solución: desplegar PgBouncer, limitar pools, separar pools/usuarios para reconstrucciones background, añadir límites de concurrencia en la app.

4) Síntoma: tasa de aciertos de caché decente, pero la BD aún se derrite en límites de expiración

Causa raíz: TTLs sincronizados para claves calientes; una cohorte expira a la vez y se produce la estampida.

Solución: TTL jitter; soft TTL con refresh en background; coalescencia por clave.

5) Síntoma: aumentan errores “lock wait timeout” durante misses de caché

Causa raíz: consultas de reconstrucción incluyen escrituras o lecturas pesadas con locks; vacuum/DDL colisiona; reintentos amplifican la contención.

Solución: aisla escrituras de las reconstrucciones de lectura; evita patrones con muchos locks; reduce reintentos; agenda mantenimiento; asegura índices que eviten scans de larga duración.

6) Síntoma: la memoria de Redis alcanza el máximo y las claves churnean; la carga DB se vuelve espinosa

Causa raíz: desacople entre política de expulsión y uso, valores sobredimensionados o cardinalidad sin límites de claves (por ejemplo, cachear por explosión de parámetros por petición).

Solución: limita la cardinalidad, comprime o almacena proyecciones más pequeñas, elige una política de expulsión alineada con el uso de TTL y monitoriza expulsiones como señal de primera clase.

7) Síntoma: después de un failover de Redis, todo se vuelve más lento aunque Redis ya volvió

Causa raíz: arranque en frío de la caché; tormenta de reconstrucción; sin coalescencia; la app usa reconstrucción síncrona en el camino de solicitud.

Solución: stale-while-revalidate, soft TTL y cola de reconstrucción en background. Haz que los arranques en frío sean sobrevivibles.

8) Síntoma: réplicas de lectura se retrasan durante picos y la lógica de invalidación de caché se descontrola

Causa raíz: lag de réplicas + lógica “validar contra BD”; la estampida causa lag; el lag provoca más invalidaciones/reintentos.

Solución: no valides lecturas cacheadas calientes contra réplicas con lag; prefiere servir caché obsoleta con estaleness acotada y enruta lecturas críticas al primario solo cuando sea necesario.

Listas de verificación / plan paso a paso

Plan paso a paso: endurecer un sistema cache-aside contra estampidas

  1. Inventario de claves calientes: identifica endpoints y claves principales por tasa de solicitud e impacto del miss.
  2. Define presupuesto de obsolescencia: por clase de clave, decide qué obsolescencia es aceptable (segundos/minutos/horas).
  3. Implementa coalescencia de solicitudes: por clave, asegura que solo un builder se ejecute a la vez en toda la flota.
  4. Añade soft TTL + hard TTL: sirve stale durante la ventana soft; deja de servir tras el hard TTL salvo que degrades explícitamente.
  5. Añade TTL jitter: desincroniza expiraciones en claves calientes y cohortes.
  6. Añade cache negativa: cachea no-encontrado y resultados vacíos brevemente.
  7. Separa pools: lecturas interactivas vs reconstrucciones vs jobs por lotes; haz cumplir con config de PgBouncer y usuarios DB.
  8. Pon reconstrucciones detrás de control de admisión: limita concurrencia y usa backpressure cuando suba la latencia de Postgres.
  9. Arregla reintentos: aplica presupuestos de reintentos; backoff exponencial; jitter; sin bucles infinitos.
  10. Observa las señales correctas: tasa de aciertos de caché, tasa de reconstrucción, esperas por locks, clientes esperando en PgBouncer, expulsiones de Redis, eventos de espera de Postgres.
  11. Prueba modos de falla: simula reinicio de Redis, vaciado de caché y cambio de namespace de claves en staging con concurrencia realista.
  12. Escribe el runbook: incluye “deshabilitar reconstrucciones”, “servir solo stale” y procedimientos para “reducir carga”.

Checklist operacional: antes de desplegar un cambio de clave de caché

  • ¿El nuevo formato de clave convive con el anterior durante el rollout?
  • ¿Hay una concurrencia máxima de reconstrucción por clase de clave?
  • ¿Está enabled stale-while-revalidate para claves no críticas?
  • ¿Tienes jitter en TTL habilitado por defecto?
  • ¿Puedes deshabilitar rápidamente reconstrucción-on-miss y servir stale?
  • ¿Están las expulsiones de Redis, latencia y eventos de espera de Postgres en el mismo dashboard?

Checklist de emergencia: durante una estampida en vivo

  • Deja de empeorar las cosas: reduce reintentos y deshabilita warmers agresivos.
  • Activa el modo “servir stale” si está disponible; extiende TTLs en claves calientes si es seguro.
  • Limita la concurrencia de reconstrucción; aisla el pool de reconstrucción del pool interactivo.
  • Si Redis es el cuello de botella, estabiliza Redis primero; si no, solo harás que todo vaya a Postgres.
  • Si Postgres está saturado, reduce carga: rate limit endpoints que reconstruyen, degrada funcionalidades no críticas y protege paths de login/checkout.

Preguntas frecuentes

1) ¿Puede PostgreSQL ser la caché si simplemente añado más RAM?

A veces. Pero es una trampa como estrategia primaria. El buffer cache de Postgres ayuda para lecturas repetidas, pero las estampidas introducen concurrencia y encolamiento que la RAM no soluciona:
conexiones, CPU para planificar/ejecutar y picos de I/O para páginas no residentes. Usa RAM, pero también usa coalescencia y control de admisión.

2) ¿Es Redis la única forma de prevenir estampidas de caché?

No. Puedes hacer single-flight en proceso, usar una cola de mensajes para serializar reconstrucciones o precomputar proyecciones en un almacén separado.
Redis es popular porque ya suele estar presente y provee primitivas de coordinación. La clave es controlar el fan-out de reconstrucción, no la marca de la herramienta.

3) ¿Debería usar un lock distribuido de Redis para reconstruir cachés?

Sí, pero mantenlo de corta duración y trátalo como una pista de coordinación, no como un mecanismo de corrección. Usa un TTL en el lock y maneja la pérdida del lock de forma segura.
Tu reconstrucción debe ser idempotente y el sistema debe tolerar dobles reconstrucciones ocasionales. La meta es “mayoritariamente uno”, no “perfectamente uno”.

4) ¿Qué TTL debería usar?

La respuesta honesta: la que permita tu presupuesto de obsolescencia, más suficiente margen para evitar reconstrucciones constantes. TTLs más largos reducen la presión de reconstrucción.
Añade soft TTL para mantener frescura aceptable y hard TTL para prevenir servir datos ancestrales.

5) ¿Por qué los reintentos empeoran las estampidas?

Los reintentos convierten una falla parcial en carga amplificada. Cuando Postgres está lento, cada petición con timeout puede generar múltiples consultas adicionales.
Añade jitter y backoff exponencial, limita los reintentos y prefiere servir caché stale sobre “intentar de nuevo inmediatamente”.

6) ¿Cómo sé si sufro una estampida vs una regresión real de la BD?

Las estampidas tienen una firma: consultas idénticas repetidas se disparan en pg_stat_statements, aumentan los misses de caché y la latencia empeora bruscamente alrededor de límites de TTL o resets de caché.
Una regresión de BD se parece más a que una consulta cambió de plan o una tabla se hinchó. Lo confirmas correlacionando hit/miss de caché con las consultas principales y eventos de espera.

7) ¿Es “stale-while-revalidate” seguro?

Es seguro cuando la semántica de negocio lo permite. No es seguro para lecturas críticas de corrección como saldos, permisos o commits de inventario.
Para esos casos, usa lecturas transaccionales desde Postgres (y cachea con invalidación explícita), o diseña un read model dedicado con reglas de actualización fuertes.

8) ¿Y sobre cachear resultados de consultas en Postgres (materialized views)?

Las materialized views y tablas de resumen pueden reducir el cómputo por petición, pero no resuelven las estampidas por sí solas. Si la capa de caché sigue expirando y desencadena reconstrucciones, puedes estampidar el refresh de la materialized view.
Son mejores como parte de un sistema: modelo de lectura barato en Postgres, más caché en Redis, más coalescencia y servir stale.

9) ¿Necesito réplicas de lectura para sobrevivir a estampidas?

Las réplicas ayudan cuando la carga es estable y tienes escalado de lectura predecible. Durante estampidas, las réplicas pueden retrasarse y convertirse en un nuevo modo de fallo.
Arregla las estampidas en la capa de caché primero. Luego usa réplicas para escala en estado estable, no como tu freno de emergencia principal.

Conclusión: próximos pasos que puedes hacer esta semana

Las estampidas de caché no son una maldición misteriosa de sistemas distribuidos. Son lo que ocurre cuando “miss en caché → ir a BD” puede escalar linealmente con el tráfico.
Tu trabajo es romper esa linealidad.

Si no haces nada más esta semana, haz estas tres cosas:

  1. Añade coalescencia de solicitudes para tus claves más calientes (incluso un lock en Redis con TTL es mejor que un todos-contra-todos).
  2. Implementa stale-while-revalidate para cualquier dato que pueda estar ligeramente obsoleto, y añade un hard TTL para prevenir valores zombis.
  3. Protege Postgres con separación de pools y control de admisión para que el trabajo de reconstrucción no pueda dejar sin recursos a las lecturas interactivas.

Luego mide: tasa de aciertos, tasa de reconstrucción, esperas por locks, clientes esperando en PgBouncer, latencia de Redis y eventos de espera de Postgres. El dashboard no evitará incidentes, pero evitará que adivines.
Y adivinar es como las estampidas se convierten en outages.

← Anterior
Tablas responsivas para documentación técnica que no fallan en producción
Siguiente →
Monitorización de latencia en ZFS: los gráficos que detectan el desastre temprano

Deja un comentario