Empieza inocentemente: “Guardemos contadores en la base de datos y rechacemos solicitudes cuando los usuarios excedan un límite.” Dos semanas después estás mirando una latencia p95 creciente, filas calientes, esperas de bloqueo y un canal de incidentes lleno de mensajes como “¿por qué la BD está al 95% de CPU si el tráfico ni siquiera es tan alto?”
La limitación de tasas es una preocupación del plano de control. Tu base de datos es un caballo de batalla del plano de datos. Cuando la conviertes en policía del tráfico, le pides que corra la carrera y que la arbitre, además de vender bocadillos en las gradas.
La tesis: por qué “limitar en SQL” falla en producción
La limitación de tasas en el lado de la base de datos suena atractiva porque está centralizada y es transaccional. Puedes imponer límites “exactos” por usuario, por clave de API, por inquilino, por lo que sea. Y ya tienes una base de datos. Así que agregas una tabla como rate_limits, haces un UPDATE o un INSERT ... ON CONFLICT/ON DUPLICATE KEY, compruebas una ventana temporal y niegas si se supera el límite.
En producción, ese enfoque suele fallar por un puñado de razones repetibles:
- Creas un punto de serialización. La limitación es, por naturaleza, sobre estado compartido. El estado compartido dentro de un motor transaccional se convierte en contención. La contención se convierte en latencia en la cola. La latencia en la cola se convierte en timeouts. Los timeouts provocan reintentos. Los reintentos son una prueba de carga no planificada.
- Transformas “rechazos baratos” en “rechazos caros”. Un buen limitador rechaza rápido, antes de que tus sistemas caros hagan trabajo. Un limitador basado en BD pide al sistema caro (la BD) que haga trabajo para decidir si debe hacer trabajo.
- Amplificas los picos de tráfico. Los picos son precisamente cuando la limitación importa. Los picos también son cuando la contención en la BD es peor. Limitar dentro de la BD significa que golpeas la ruta de peor caso cuando necesitas el mejor comportamiento.
- Acoplas la disponibilidad de la aplicación a la disponibilidad de la BD. Si el limitador depende de viajes a la BD, entonces una degradación de la BD se convierte en una degradación de la API, incluso para endpoints que podrían degradarse con gracia.
- Obtienes resultados “correctos” a costa de la corrección en otros lugares. Tus consultas de negocio compiten con las consultas de policía. Cuando el limitador se pone caliente, roba CPU, caché de búfer y presupuesto de I/O al negocio.
Ésa es la realidad operacional. Aquí va el resumen seco y divertido: usar tu base de datos para limitar tasas es como usar la alarma de incendios para cocinar: técnicamente posible, pero te espera una mala experiencia.
Hechos interesantes y contexto histórico (para no repetir la historia)
- El modelo MVCC de PostgreSQL (de la genealogía del proyecto Postgres de los años 90) evita bloqueos de lectura para muchas cargas, pero la contención en escrituras sigue siendo contención; las actualizaciones calientes pueden seguir serializándose en la misma tupla/página.
- InnoDB (el motor por defecto en el mundo MariaDB/MySQL) está construido alrededor de índices agrupados; una clave primaria “caliente” significa que estás golpeando las mismas páginas del B-tree, lo que puede convertirse en contención de cerrojos y rotación del buffer pool.
- Los algoritmos de token bucket y leaky bucket aparecieron en la literatura de redes décadas antes de que existieran la mayoría de APIs web; fueron diseñados para routers, no para bases de datos OLTP.
- La adopción generalizada de pgbouncer vino de una realidad muy práctica: las conexiones PostgreSQL no son gratuitas, y “demasiados clientes” es un modo clásico de caída autoinfligida.
- La reputación temprana de MySQL por velocidad se debió en parte a que se enviaba con comportamientos por defecto más simples y menos características de seguridad; la gente luego reintrodujo complejidad a nivel de aplicación. El ciclo se repite con “vamos a limitar en SQL”.
- Los advisory locks en PostgreSQL son poderosos y fáciles de usar mal; son una primitiva de concurrencia, no una herramienta para modelar tráfico.
- Los sistemas a gran escala popularizaron “bulkheads” y “presupuestos” (pools separados y aislamiento de recursos por inquilino) porque la equidad perfecta es cara; el servicio predecible es el objetivo real.
- Las tormentas de reintentos no son una invención moderna; los sistemas distribuidos han estado reaprendiendo “los reintentos pueden ser dañinos” desde al menos la era temprana del RPC.
MariaDB vs PostgreSQL: qué cambia, qué no
Qué es igual: la contención es física
Ya corras MariaDB o PostgreSQL, un limitador implementado como “una fila por sujeto, actualízala en cada solicitud” crea un punto caliente. La base de datos debe coordinar esas actualizaciones. La coordinación es todo el trabajo de una base de datos, pero no es gratuita—especialmente en los niveles de QPS donde la limitación importa.
Qué difiere: cómo se manifiesta el dolor
PostgreSQL tiende a mostrar el problema como:
- Esperas de bloqueo a nivel de fila cuando múltiples transacciones actualizan la misma fila (visible vía
pg_locks,pg_stat_activity). - Alto volumen de WAL por actualizaciones incesantes, incluso si los datos “de negocio” no cambian mucho.
- Presión de autovacuum por el churn rápido; las actualizaciones frecuentes crean tuplas muertas que deben ser vacuumeadas, y vacuum necesita I/O.
- En casos extremos: CPU gastada en LWLocks o contención alrededor de buffers compartidos/actividad relacionada con checkpoints.
MariaDB (InnoDB) tiende a mostrar el problema como:
- Espera de bloqueos y deadlocks alrededor de la misma clave primaria o entradas de índice único (
SHOW ENGINE INNODB STATUSes tu amigo y tu enemigo). - Presión de undo log / purge por actualizaciones constantes, que puede convertirse en crecimiento de la lista de historial y degradación del rendimiento.
- Contención del buffer pool y páginas de índice calientes cuando se golpea el mismo rango de claves.
- Retraso de replicación si estás binlogeando cada actualización del limitador; tus réplicas se convierten en “consumidores de actualizaciones del limitador” en lugar de servir lecturas.
Diferentes paneles. Mismo problema raíz: el limitador ahora compite con tu carga por el rendimiento transaccional.
Cómo falla la limitación basada en BD: bloqueos, filas calientes y trabajo invisible
1) El problema de la fila caliente (aka “una fila para gobernarlas a todas”)
La mayoría de esquemas ingenuos usan una única fila por usuario/clave de API. Bajo tráfico con picos, muchos workers de la app actualizan la misma fila. La concurrencia colapsa en serialización. Tu “límite” se convierte en “la capacidad de la BD para actualizar esa fila por segundo”, que no es el límite que pretendías. Peor: no puedes aumentarlo sin aumentar el rendimiento de escritura de la BD.
2) Inflación de latencia en la cola (el asesino silencioso)
La limitación a menudo se ejecuta en cada solicitud. Así que incluso un pequeño aumento en la latencia del limitador se multiplica en tu tráfico. No solo añades 2 ms; añades 2 ms a todo, luego añades reintentos, luego añad es encolamiento. Obtienes una caída larga y lenta que parece “todo está un poco peor” hasta que de repente es catastrófica.
3) WAL/binlog y vacuum/purge: el impuesto del “trabajo invisible”
Cada actualización tiene efectos aguas abajo:
- PostgreSQL escribe WAL; la replicación consume WAL; los checkpoints vuelcan páginas; autovacuum limpia.
- InnoDB escribe redo/undo; los hilos de purge limpian; la replicación reproduce eventos de binlog.
Tu limitador ahora es una máquina de amplificación de escrituras. El sistema pasa más tiempo manteniendo el libro mayor que haciendo el negocio.
4) Acoplamiento de fallos: cuando la BD está enferma, tu API se enferma
Un limitador debería proteger tu base de datos de la sobrecarga. Si corre en la base de datos, has construido un cinturón de seguridad que solo funciona cuando el coche no se está estrellando. Bajo degradación de la BD, el limitador se vuelve más lento, lo que aumenta las solicitudes concurrentes, lo que degrada más la BD. Ese es un bucle de retroalimentación con un nombre financiero amigable: “tiempo de inactividad no planeado.”
5) “Pero vamos a shardear la tabla del limitador” (fase optimista)
Los equipos a menudo intentan arreglar filas calientes shardeando contadores en N filas y sumándolos. Eso reduce la contención de una sola fila pero introduce más lecturas, más complejidad y aún bastante carga de escritura. Además: sumar contadores en cada solicitud es una excelente forma de convertir un limitador en un motor de consultas.
Segunda verdad seca y divertida: un limitador SQL shardedo es la forma de convertir una fila caliente en varias filas ligeramente menos calientes, como distribuir un dolor de cabeza por toda la cabeza.
Guía de diagnóstico rápido: encuentra el cuello de botella en minutos
Cuando sospeches que la limitación “en la BD” está derritiendo cosas, no empieces reescribiendo algoritmos. Empieza probando dónde se va el tiempo.
Primero: confirma si el limitador está en la ruta crítica
- Revisa trazas de la aplicación: ¿hay una llamada a la BD por cada solicitud incluso cuando se rechaza?
- Compara latencias para solicitudes permitidas vs rechazadas. El rechazo debería ser más barato. Si es más caro, ya encontraste tu problema.
Segundo: identifica el recurso compartido que está contendiendo
- Esperas de bloqueo en la BD (bloqueos de fila, bloqueos de transacción).
- Saturación del pool de conexiones (hilos esperando conexiones).
- Presión en write-ahead logging o binlog (picos en volumen WAL/binlog).
- Encolamiento de I/O (alto await, buffers sucios, flush lento).
Tercero: determina si estás en un bucle de retroalimentación
- ¿Hay reintentos sobre 429/5xx que aumentan la carga?
- ¿Los timeouts de la app son más cortos que la recuperación de la BD tras picos?
- ¿El autoscaling está añadiendo más clientes, empeorando la contención?
Cuarto: elige la mitigación segura más rápida
- Mueve la limitación de tasas al borde (API gateway / ingress) para los endpoints más calientes.
- Aplica topes de conexión y encolamiento fuera de la BD (poolers, semáforos, colas de workers).
- Desactiva o evita temporalmente el limitador basado en BD si está causando una falla sistémica (con un límite compensatorio en otro lugar).
Idea parafraseada de Werner Vogels (CTO de Amazon): “Todo falla, todo el tiempo—diseña sistemas que lo esperen y sigan atendiendo.”
Tareas prácticas: comandos, salidas y las decisiones que impulsan
Estos son los chequeos que realizo cuando entro a un sistema donde alguien dice orgulloso “limitamos en SQL”. Cada tarea incluye: comando, salida de ejemplo, qué significa y la decisión que tomas.
Tarea 1: Comprobar si la base de datos está limitada por CPU o I/O (Linux)
cr0x@server:~$ mpstat -P ALL 1 3
Linux 6.5.0 (db01) 12/31/2025 _x86_64_ (16 CPU)
12:01:11 PM CPU %usr %nice %sys %iowait %irq %soft %steal %idle
12:01:12 PM all 72.10 0.00 17.22 2.10 0.00 0.88 0.00 7.70
12:01:13 PM all 76.55 0.00 16.40 1.90 0.00 0.70 0.00 4.45
12:01:14 PM all 74.80 0.00 18.00 2.40 0.00 0.60 0.00 4.20
Significado: Alto %usr+%sys, bajo %iowait: presión de CPU, probablemente contención o ejecución intensa de consultas.
Decisión: Si el limitador es intensivo en escrituras, enfócate en contención de bloqueos/WAL/binlog y actualizaciones calientes, no en discos primero.
Tarea 2: Comprobar latencia de disco y encolamiento (Linux)
cr0x@server:~$ iostat -xz 1 3
Linux 6.5.0 (db01) 12/31/2025 _x86_64_ (16 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
71.4 0.0 17.9 2.1 0.0 8.6
Device r/s w/s rkB/s wkB/s aqu-sz await svctm %util
nvme0n1 12.0 980.0 210.0 18340.0 5.20 5.10 0.25 26.0
Significado: Las escrituras son altas pero await es bajo; no hay saturación de I/O. La BD está haciendo muchas escrituras pequeñas (típico de actualizaciones de contadores).
Decisión: No compres discos más rápidos para arreglar un diseño de limitador. Arregla el diseño.
Tarea 3: Comprobar consultas activas de PostgreSQL y eventos de espera
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select pid, usename, state, wait_event_type, wait_event, left(query,80) as q from pg_stat_activity where state<>'idle' order by now()-query_start desc limit 8;"
pid | usename | state | wait_event_type | wait_event | q
------+---------+--------+-----------------+---------------+-----------------------------------------------
8121 | app | active | Lock | tuple | update rate_limits set count=count+1 where key=
8177 | app | active | Lock | tuple | update rate_limits set count=count+1 where key=
8204 | app | active | Lock | tuple | update rate_limits set count=count+1 where key=
8290 | app | active | IO | DataFileRead | select * from orders where tenant_id=$1 order by
Significado: Múltiples sesiones esperando por bloqueos de tupla para el mismo patrón de actualizaciones. Esas son tus filas del limitador actuando como un mutex.
Decisión: Deja de hacer actualizaciones por petición en SQL. Mueve el estado del limitador fuera de la BD o agrúpalo.
Tarea 4: Encontrar las consultas top por tiempo total (requiere pg_stat_statements)
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select calls, round(total_exec_time::numeric,1) as total_ms, round(mean_exec_time::numeric,3) as mean_ms, left(query,70) as q from pg_stat_statements order by total_exec_time desc limit 5;"
calls | total_ms | mean_ms | q
-------+-----------+---------+----------------------------------------------------------------------
92000 | 580000.2 | 6.304 | update rate_limits set count=count+1, reset_at=$1 where key=$2
41000 | 210000.7 | 5.122 | insert into rate_limits(key,count,reset_at) values($1,$2,$3) on conflict
8000 | 90000.4 | 11.250 | select * from orders where tenant_id=$1 order by created_at desc limit 50
Significado: Tus consultas del limitador dominan el tiempo total de ejecución. No son “pequeña sobrecarga”. Son la carga de trabajo.
Decisión: Trata esto como un error funcional, no como “tuning de rendimiento”. Elimina las escrituras del limitador de la ruta crítica de la BD.
Tarea 5: Comprobar la tasa de generación de WAL en PostgreSQL
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select now(), pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')) as wal_since_start;"
now | wal_since_start
------------------------------+----------------
2025-12-31 12:03:10.123+00 | 148 GB
Significado: Gran volumen de WAL en el tiempo de ejecución sugiere alto churn de escrituras. Los contadores del limitador son sospechosos principales.
Decisión: Si las réplicas se retrasan o el archivado se acumula, reduce el volumen de escrituras de inmediato (limitación en el borde, caché, actualizaciones por lotes).
Tarea 6: Comprobar la presión de vacuum para la tabla del limitador
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select relname, n_live_tup, n_dead_tup, last_autovacuum from pg_stat_user_tables where relname='rate_limits';"
relname | n_live_tup | n_dead_tup | last_autovacuum
------------+------------+------------+---------------------------
rate_limits| 120000 | 9800000 | 2025-12-31 11:57:42+00
Significado: Las tuplas muertas superan por mucho a las vivas: actualizaciones constantes. Vacuum está trabajando horas extras solo para limpiar las escrituras del limitador.
Decisión: Deja de actualizar esas filas por petición. Ajustar vacuum es un vendaje; la herida es el diseño.
Tarea 7: Comprobar deadlocks y esperas de bloqueo en MariaDB/InnoDB
cr0x@server:~$ mariadb -h db01 -u root -p -e "SHOW ENGINE INNODB STATUS\G" | sed -n '1,120p'
...
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 34588921, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 7123, OS thread handle 140377, query id 991233 app 10.0.3.41 updating
UPDATE rate_limits SET count=count+1, reset_at=FROM_UNIXTIME(173564...) WHERE k='tenant:913'
*** (2) TRANSACTION:
TRANSACTION 34588922, ACTIVE 0 sec starting index read
...
Significado: Deadlocks en la tabla del limitador. Tu “mecanismo de seguridad” es ahora fuente de rollbacks y reintentos.
Decisión: Elimina el limitador de InnoDB. Si debes mantener algo de estado, almacénalo en un sistema de baja contención (Redis/memcache) con expiración.
Tarea 8: Comprobar las consultas top en MariaDB (performance_schema habilitado)
cr0x@server:~$ mariadb -h db01 -u root -p -e "SELECT DIGEST_TEXT, COUNT_STAR, ROUND(SUM_TIMER_WAIT/1e12,1) AS total_s FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 3;"
DIGEST_TEXT COUNT_STAR total_s
UPDATE `rate_limits` SET `count` = `count` + ? WHERE `k` = ? 210344 980.2
INSERT INTO `rate_limits` (`k`,`count`,`reset_at`) VALUES (...) 50321 220.5
SELECT * FROM `orders` WHERE `tenant_id` = ? ORDER BY ... 21000 190.0
Significado: El limitador está en lo más alto del ranking. La BD está pasando la mayor parte de su tiempo gestionando el tráfico.
Decisión: Mueve la aplicación de políticas más temprano en la ruta de la solicitud. Deja la BD para los datos.
Tarea 9: Comprobar saturación de conexiones (PostgreSQL)
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select count(*) as total, sum(case when state='active' then 1 else 0 end) as active from pg_stat_activity;"
total | active
-------+--------
480 | 220
Significado: Muchas conexiones y muchas activas. Si no estás usando pooling correctamente, estás pagando overhead por conexión y aumentando la contención.
Decisión: Añade/verifica un pooler (pgbouncer) y limita la concurrencia por endpoint/por inquilino fuera de la BD.
Tarea 10: Comprobar salud del pool de pgbouncer
cr0x@server:~$ psql -h pgb01 -U pgbouncer -d pgbouncer -c "show pools;"
database | user | cl_active | cl_waiting | sv_active | sv_idle | maxwait
---------+------+-----------+------------+----------+---------+---------
app | app | 180 | 45 | 40 | 0 | 12.3
Significado: Clientes están esperando; los servidores están al máximo. La app está generando más trabajo concurrente de BD del que la BD puede manejar con seguridad.
Decisión: Impone límites de concurrencia en la app (semáforos), mueve la limitación al gateway y reduce viajes a la BD por solicitud.
Tarea 11: Comprobar efectividad del rate limiting en NGINX en el borde
cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -n "limit_req"
134: limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s;
201: limit_req zone=perip burst=40 nodelay;
Significado: La limitación en el borde está configurada. Si tu BD sigue sobrecargada, o los límites son muy altos, o están mal indexados, o se omiten para tráfico clave.
Decisión: Valida las claves (por clave de API/por inquilino, no por IP cuando hay NAT) y añade presupuestos por ruta.
Tarea 12: Verificar que la app no reintenta agresivamente 429/errores de bloqueo
cr0x@server:~$ rg -n "retry|backoff|429|Too Many Requests" /etc/myapp/config.yaml
118:retry:
119: max_attempts: 6
120: backoff: "fixed"
121: backoff_ms: 50
122: retry_on_status: [429, 500, 502, 503, 504]
Significado: Backoff fijo de 50ms con 6 intentos es generador de tormentas de reintentos. 429 es una señal para desacelerar, no para ejecutar una carrera contra el servidor.
Decisión: Deja de reintentar 429 automáticamente, o usa backoff exponencial con jitter y un tope estricto. Prefiere suavizado en el lado cliente.
Tarea 13: Identificar claves calientes en la tabla del limitador (ejemplo PostgreSQL)
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select key, count(*) as updates_last_min from rate_limit_audit where ts > now()-interval '1 minute' group by key order by updates_last_min desc limit 5;"
key | updates_last_min
--------------+------------------
tenant:913 | 18400
tenant:1442 | 10210
tenant:77 | 9800
Significado: Unos pocos inquilinos dominan el volumen de actualizaciones. Aquí es donde la equidad y el aislamiento importan más que los límites globales perfectos.
Decisión: Introduce presupuestos por inquilino y bulkheads fuera de la BD; considera pools de workers separados o colas por nivel.
Tarea 14: Comprobar retraso de replicación (réplica MariaDB)
cr0x@server:~$ mariadb -h db-rep01 -u root -p -e "SHOW SLAVE STATUS\G" | egrep "Seconds_Behind_Master|Slave_IO_Running|Slave_SQL_Running"
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
Seconds_Behind_Master: 187
Significado: La réplica está retrasada. Si las actualizaciones del limitador se replican, estás gastando el ancho de banda de replicación en la policía en lugar de en datos de negocio.
Decisión: Mantén el estado de limitación fuera del OLTP replicado, o al menos no lo replifiques (instancia/esquema separado con durabilidad distinta), y corrige en el borde.
Tres mini-historias corporativas desde las trincheras
Mini-historia 1: el incidente causado por una suposición equivocada
Un equipo SaaS quería límites por inquilino. El requisito de producto decía “límite estricto, exacto, no más de N solicitudes por minuto.” Ingeniería escuchó “transaccional.” Construyeron una tabla PostgreSQL con clave por tenant ID, la actualizaron en cada solicitud y usaron UPDATE ... RETURNING para decidir permitir/denegar. Pasó las pruebas de carga, porque las pruebas usaban inquilinos distribuidos uniformemente y una rampa moderada.
Entonces un cliente real ejecutó un script de migración que golpeó un único inquilino con alta concurrencia. La tabla del limitador se convirtió en la tabla más actualizada de la base de datos. Los síntomas fueron clásicos: la latencia p95 subió, luego el p99 se desplomó. La BD no estaba “caída”, simplemente estaba ocupada permanentemente esperando bloqueos. La aplicación caducó, reintentó y duplicó el tráfico. El comandante del incidente seguía preguntando por qué el limitador no protegió la BD de la sobrecarga. Silencio, luego la lenta realización: el limitador era la sobrecarga.
Lo arreglaron moviendo la limitación de primera línea al gateway (por clave de API) y usando una cola de trabajo por inquilino para los endpoints de migración costosos. El requisito de “exacto por minuto” se reescribió a algo que los adultos pueden operar: “imponer límites aproximados con ráfagas acotadas, priorizar endpoints clave.” Todos sobrevivieron. A nadie le encantó el postmortem, pero los gráficos fueron educativos.
Mini-historia 2: la optimización que salió mal
Otra compañía corría MariaDB y tenía una tabla del limitador con una fila por clave de API. Notaron esperas de bloqueo e intentaron “optimizar” manteniendo la fila del limitador pequeña, convirtiendo la clave a entero y empaquetando contadores en menos bytes. Luego añadieron un índice covering porque “los índices hacen las búsquedas rápidas.”
Lo que pasó: la actualización todavía tenía que modificar el registro del índice agrupado (primary key) y el índice secundario. Aumentaron la amplificación de escrituras y volvieron más caliente la página hot. La CPU subió, no bajó. Empezaron a aparecer deadlocks porque el patrón de acceso cambió sutilmente bajo concurrencia, y su lógica de reintentos convirtió cada deadlock en una pequeña ráfaga.
La solución real no fue filas más pequeñas. Fue cambiar la arquitectura: usar un contador distribuido en memoria con TTL para la policía, y dejar que MariaDB gestione escrituras durables de negocio. Mantuvieron un “consumo de cuota diario” en MariaDB para auditoría y facturación, actualizado de forma asincrónica. El limitador dejó de pelear con el flujo de checkout, lo cual se considera buena educación.
Mini-historia 3: la práctica aburrida pero correcta que salvó el día
Otro equipo ya había sufrido por “primitivas inteligentes en la BD.” Corrían PostgreSQL con pgbouncer y tenían una regla simple: la base de datos no hace control de admisión de solicitudes. La admisión ocurre antes de la BD y usa presupuestos: topes de concurrencia por ruta, equidad por inquilino y protección global de sobrecarga en el gateway.
Seguían registrando uso para facturación, pero lo hacían con agregación asincrónica. Las solicitudes emitían eventos a una cola; un worker los consolidaba cada minuto y escribía una fila resumen por inquilino. La base de datos veía una escritura por inquilino por minuto, no una por solicitud. Cuando el tráfico se disparó, la profundidad de la cola creció. Eso fue aceptable; no despertó a nadie a las 3 a.m.
Un día una campaña de marketing creó un pico. El gateway empezó a devolver 429 para un subconjunto de clientes abusivos, la app mantuvo latencias estables y la base de datos apenas lo notó. Su ticket de incidente fue una sola línea: “Aumento de 429 debido a tráfico de campaña; sin impacto al cliente.” Esto es el sueño: aburrido, predecible y un poco orgulloso.
Qué hacer en su lugar: patrones sensatos que escalan
Principio: rechaza temprano, barato e independientemente
El trabajo de tu limitador es proteger recursos caros. Eso significa que debe vivir delante de ellos y seguir siendo funcional cuando estos estén degradados.
Patrón 1: limitación en el borde (gateway/ingress)
Usa tu API gateway o controlador de ingress para imponer límites básicos:
- Por identificador cliente (clave de API, sujeto del JWT, ID de inquilino).
- Por ruta (endpoints de login y búsqueda no son lo mismo).
- Con ráfagas acotadas (token bucket) y respuestas por defecto sensatas (429 con retry-after).
Esto no necesita precisión global perfecta. Necesita velocidad y previsibilidad.
Patrón 2: límites de conexión y concurrencia (bulkheads)
La mayoría de “limitación en BD” es en realidad “necesitamos menos consultas concurrentes.” Resuelve eso directamente:
- Usa un pooler (pgbouncer para PostgreSQL, ProxySQL o pooling en la app para MariaDB).
- Limita la concurrencia por endpoint (ej., generación de informes costosos) con semáforos.
- Limita la concurrencia por inquilino para evitar vecinos ruidosos.
Esto es ingeniería del plano de control que realmente controla algo: trabajo concurrente.
Patrón 3: encola el trabajo costoso
Si el endpoint dispara actividad pesada en la BD, no dejes que la concurrencia HTTP dicte la concurrencia de la BD. Pon el trabajo pesado detrás de una cola. Deja que la profundidad de la cola absorba picos. Ejecuta workers con concurrencia fija y patrones de consulta conocidos. Tu base de datos te lo agradecerá quedándose viva.
Patrón 4: usa un almacén en memoria para contadores de ventana corta
Si realmente necesitas un contador compartido con TTL, usa un sistema diseñado para ello (Redis/memcached-like). Obtienes:
- Incrementos atómicos rápidos.
- Semántica de expiración sin drama de vacuum/purge.
- Aislamiento de la ruta de escritura OLTP de tu base de datos.
Sigue aplicando higiene: evita claves únicas calientes, incluye jitter en TTLs y usa particionado por inquilino si es necesario.
Patrón 5: almacena cuotas durables de forma asincrónica
La facturación y el cumplimiento a menudo requieren registros durables. Perfecto—almacénalos, pero fuera de banda:
- Emite eventos de uso.
- Agrega en buckets temporales (por minuto/hora/día).
- Escribe resúmenes en MariaDB/PostgreSQL.
La base de datos sigue siendo una base de datos, no un almacén de contadores de alta frecuencia.
Patrón 6: “presupuesta” la base de datos explícitamente
En lugar de “N solicitudes por minuto”, define presupuestos ligados al recurso escaso real:
- Presupuesto de concurrencia de consultas: consultas activas máximas por inquilino/servicio.
- Presupuesto de CPU: limitar endpoints costosos por tiempo de ejecución (cuando sea medible).
- Presupuesto de I/O: controlar exportaciones masivas y migraciones.
No puedes comprar equidad perfecta barato. Puedes comprar latencia predecible y sobrecarga sobrevivible. Elige sobrecarga sobrevivible.
Errores comunes: síntomas → causa raíz → corrección
Error 1: “Los 429 están altos pero la BD también está lenta”
Síntomas: Tasa de 429 en aumento, CPU de BD en aumento, esperas de bloqueo en aumento, p95 sube en todos los endpoints.
Causa raíz: Las rejections dependen de escrituras/bloqueos en la BD; el propio limitador es la carga.
Corrección: Mueve el limitador al gateway/almacén en memoria. Haz que la ruta de rechazo no dependa de la BD. Añade topes de concurrencia en la app.
Error 2: “Solo un inquilino está lento, pero todos sufren”
Síntomas: Un gran cliente provoca lentitud global. La BD muestra esperas de bloqueo en la tabla del limitador; los pools de conexión se saturan.
Causa raíz: Recursos compartidos del limitador o pool de BD compartido sin aislamiento por inquilino.
Corrección: Implementa bulkheads: límites de concurrencia por inquilino, pools de workers separados para endpoints pesados y encolamiento de trabajos pesados.
Error 3: “Añadimos índices a la tabla del limitador y empeoró”
Síntomas: Más CPU, más I/O de escritura, deadlocks aumentan, throughput general cae.
Causa raíz: Amplificación de escrituras: los índices secundarios deben mantenerse en cada actualización; las páginas calientes se vuelven más calientes.
Corrección: Elimina el diseño, no solo los índices. Si debes conservar una tabla, escribe con menos frecuencia (batch/rollup).
Error 4: “Autovacuum no da abasto / el purge se retrasa”
Síntomas: Las tuplas muertas en PostgreSQL se acumulan; la lista de historial en MariaDB crece; el rendimiento degrada con el tiempo.
Causa raíz: Actualizaciones de alta frecuencia a las mismas filas crean churn que el mantenimiento no puede amortizar.
Corrección: Elimina las actualizaciones por solicitud; usa contadores TTL en memoria; agrega escrituras agregadas.
Error 5: “Funciona en staging pero falla en producción”
Síntomas: Las pruebas de carga pasan; el tráfico real causa contención de bloqueos y latencia en cola.
Causa raíz: Staging carece de sesgo (inquilinos calientes), carece de reintentos, carece de ráfagas reales y a menudo carece de patrones de concurrencia reales.
Corrección: Prueba con distribuciones sesgadas, ráfagas, reintentos y concurrencia cliente realista. Además: deja de usar la BD como limitador.
Error 6: “Usamos advisory locks para implementar un limitador” (PostgreSQL)
Síntomas: El throughput colapsa bajo carga; sesiones se acumulan esperando locks advisory; bloqueos difíciles de depurar.
Causa raíz: Los advisory locks serializan el trabajo y son fáciles de convertir en un mutex global. Eso no es modelado de tráfico.
Corrección: Reemplaza con límites en el gateway y topes de concurrencia por inquilino. Usa advisory locks solo para coordinación rara, no para gating por solicitud.
Listas de verificación / plan paso a paso
Plan paso a paso: migrar fuera de la limitación basada en BD sin romperlo todo
- Inventaria qué se está limitando. Identifica endpoints y claves (por IP, por clave de API, por inquilino). Si no puedes explicarlo, no puedes operarlo.
- Mide el costo actual del limitador. Usa
pg_stat_statementso el performance_schema de MariaDB para cuantificar el tiempo gastado en sentencias del limitador. - Deja de reintentar 429 por defecto. Arregla clientes/middleware de la app. 429 debería reducir la carga, no multiplicarla.
- Añade limitación en el borde para los peores ofensores. Empieza con los 1–3 endpoints top por tasa de solicitudes o costo en BD.
- Añade topes de concurrencia por ruta en la aplicación. Limita el trabajo DB costoso directamente. A menudo es la mayor ganancia.
- Introduce una cola para tareas pesadas. Migraciones, exportaciones, generación de informes: si es costoso, pertenece a un worker.
- Mueve contadores de ventana corta a un almacén en memoria si es necesario. Usa contadores TTL y semántica token bucket fuera de la BD.
- Mantén un rollup durable en la BD para facturación/auditoría. Escribe una fila por inquilino por minuto/hora, no por solicitud.
- Implementa comportamiento de sobrecarga. Define qué sucede cuando el almacén del limitador no está disponible (¿fail open para endpoints de bajo riesgo? ¿fail closed para patrones abusivos?). Decide, documenta y pruébalo.
- Elimina la ruta antigua del limitador. Quitar con feature-flag es mejor que “lo dejamos por si acaso.” “Por si acaso” es cómo el código muerto te persigue después.
Lista operacional: proteger la base de datos
- Límites en el gateway para tráfico por cliente y por ruta.
- Pooling de conexiones configurado y monitorizado (activos, esperando, maxwait).
- Presupuestos explícitos de concurrencia por servicio y por inquilino.
- Timeouts alineados: gateway < app < timeout de sentencia en BD (con intención).
- Política de reintentos incluye backoff exponencial + jitter; no reintentos automáticos en 429.
- Paneles que monitoricen: esperas de bloqueo, retraso de replicación, tasa WAL/binlog, profundidad de colas, tasa de 429.
Preguntas frecuentes
1) ¿Es aceptable alguna vez limitar en la base de datos?
Solo para cargas de baja QPS y baja concurrencia en back-office donde la corrección importa más que la latencia, y donde una escritura del limitador no contendrá con el tráfico principal. Para APIs públicas o servicios internos de alta QPS, es una trampa.
2) ¿Cuál es “mejor” para limitación en BD: MariaDB o PostgreSQL?
Ninguna es “mejor” en el sentido que deseas. Ambas son excelentes bases de datos OLTP. Ambas sufrirán cuando las fuerzas a hacer actualizaciones compartidas de alta frecuencia. Los modos de fallo difieren en instrumentación y comportamiento de mantenimiento, no en el resultado.
3) ¿Y si necesito límites estrictos y globalmente consistentes?
Pregúntate por qué. La mayoría de requisitos “estrictos” son en realidad sobre control de costos o prevención de abuso. Usa un limitador distribuido diseñado para eso, o acepta límites aproximados con ráfagas acotadas y monitorización fuerte. Si realmente necesitas estrictitud, aceptas costos de coordinación—pon esa coordinación en un sistema construido para ello, no en tu BD OLTP.
4) ¿Puedo simplemente usar una instancia de BD separada para limitación?
Puedes, y es mejor que contaminar tu OLTP principal. Pero sigue siendo una base de datos haciendo coordinación de escrituras por petición. Probablemente redescubrirás los mismos límites de escala, solo en un fuego más pequeño y barato.
5) ¿Por qué “tablas de contadores con TTL” dañan PostgreSQL en particular?
Las actualizaciones frecuentes crean tuplas muertas y WAL. Autovacuum debe limpiarlas; si se queda atrás, el rendimiento degrada. Incluso si lo mantiene al día, estás gastando I/O y CPU en mantenimiento que no entrega valor de negocio.
6) ¿Por qué dañan MariaDB/InnoDB en particular?
InnoDB debe gestionar redo/undo, cerrojos de página y puntos calientes en índices agrupados. Bajo alta concurrencia verás esperas de bloqueo, deadlocks y presión de purge. El limitador se convierte en un hotspot de escrituras con daños colaterales.
7) ¿No es usar Redis para limitación solo mover el problema?
Lo mueve a un sistema diseñado para operaciones atómicas rápidas y TTL. Aún necesitas architecturar para claves calientes y picos, pero ya no estás castigando a tu base de datos transaccional y su maquinaria de durabilidad por cada solicitud.
8) ¿Cuál es la protección más efectiva para la BD?
Limitar la concurrencia hacia la base de datos. Los límites basados en solicitudes por segundo son indirectos; los topes de concurrencia son directos. Si evitas 500 consultas costosas concurrentes, la BD se mantiene en pie.
9) ¿Cómo manejo caídas del almacén del limitador (p. ej., Redis caído)?
Decide por endpoint: fail open para rutas de solo lectura de bajo riesgo, fail closed para rutas sensibles al abuso (login, búsquedas costosas). Implementa un circuito y un valor por defecto seguro. Pruébalo en horario laboral, no durante un incidente.
10) ¿Y la “limitación dentro de la BD” para servicios internos solamente?
El tráfico interno suele ser el peor: reintentos, fan-out, jobs por lotes y disposición a golpear el sistema. Usa presupuestos y bulkheads internamente también. A la BD no le importa si la sobrecarga es “interna”.
Conclusión: pasos prácticos siguientes
Si recuerdas una cosa: una base de datos no es un portero. Es la pista de baile. No la pongas a revisar identificaciones en la puerta mientras suena la música.
Pasos siguientes que realmente cambian resultados:
- Pruébalo con datos: identifica sentencias del limitador en
pg_stat_statementso digests de MariaDB y cuantifica su costo. - Quita la BD de la ruta de rechazo: implementa límites en el borde para endpoints top y deja de reintentar 429.
- Límita la concurrencia de la BD: pool de conexiones y aplica bulkheads por ruta y por inquilino.
- Encola trabajo pesado: evita que ráfagas HTTP se conviertan en ráfagas DB.
- Mantén la durabilidad donde corresponde: agrega uso en rollups y almacénalos en la BD de forma asincrónica.
Enviarás más rápido, dormirás más y tu base de datos volverá a hacer lo que hace bien: almacenar datos, no mediar la ambición humana.