MySQL vs PostgreSQL para alta concurrencia: ¿quién choca primero y por qué?

¿Te fue útil?

Alta concurrencia es donde muere el marketing de bases de datos. Tu carga se ve bien a 200 peticiones/seg, luego añades “solo una” característica más, el tráfico se duplica y de pronto la base de datos es un sol pequeño y enfadado. La latencia se dispara, la CPU se queda al máximo y todos los equipos descubren que han estado usando la base de datos “temporalmente” como cola, caché, motor de búsqueda y oráculo de la verdad.

Esto no es una guerra de religiones. Es una expedición a encontrar el muro. MySQL y PostgreSQL escalan hasta concurrencias serias. Simplemente chocan contra muros distintos primero, por razones distintas, y el camino más corto a la estabilidad depende más de tu carga de trabajo y hábitos operativos que de las tablas de benchmarks de cualquiera.

Qué significa realmente “alta concurrencia” en producción

La gente dice “alta concurrencia” cuando en realidad se refiere a una de tres cosas:

  • Muchos clientes simultáneos (cientos a decenas de miles de conexiones activas).
  • Muchas transacciones simultáneas (un número menor de conexiones haciendo mucho trabajo, solapándose fuertemente).
  • Muchos conflictos simultáneos (todos quieren las mismas pocas filas, las mismas páginas de índice, los mismos contadores, la misma partición, la misma línea de caché).

El tercero es donde los sistemas van a llorar. Puedes comprar más núcleos para “muchas transacciones”. Puedes añadir un pooler para “muchos clientes”. Pero “muchos conflictos” es un problema de diseño que se disfraza de problema de capacidad.

Además: concurrencia no es rendimiento (throughput). Puedes tener bajo throughput y aún estar limitado por concurrencia si estás atascado en esperas de bloqueo, cambios de contexto, tormentas de fsync o una cola de hilos peleando por la misma estructura de datos compartida.

Posicionamiento opinado: dónde tiende a romperse cada motor

Si me obligas a generalizar (y lo estás haciendo), así es como suele desarrollarse en sistemas de producción reales:

PostgreSQL: chocas pronto contra el muro de conexiones/procesos y luego contra la limpieza MVCC si eres descuidado

  • Miles de conexiones directas pueden perjudicar rápido porque cada backend es un proceso con coste de memoria y planificación. Postgres puede manejar grandes cantidades de conexiones, pero hacerlo sin pooling es cómo gastas CPU en sobrecarga en vez de en consultas.
  • Transacciones largas causan peso muerto MVCC: versiones antiguas de filas se acumulan, los índices se inflan, autovacuum queda rezagado y eventualmente las consultas “simples” se ralentizan porque están escaneando un montón de basura.
  • Actualizaciones calientes pueden crear bloat y contención a nivel de página. “Es MVCC, así que las lecturas no bloquean escrituras” es verdad hasta que el sistema está ocupado limpiando lo que dejaste atrás.

MySQL (InnoDB): chocas contra contención de bloqueos y amplificación de I/O/escrituras, especialmente en filas calientes

  • Filas calientes e índices calientes aparecen como esperas de bloqueo, deadlocks y colapso de throughput. InnoDB es sólido, pero no es magia: “actualizar una fila contador” a alta concurrencia es básicamente un ataque de denegación de servicio que tú mismo programaste.
  • Vaciado de páginas sucias y presión de redo/log pueden dominar bajo escrituras intensas. Un flushing mal ajustado parece picos de latencia aleatorios y “¿por qué está ocupado el disco cuando la CPU está inactiva?”
  • Replicación bajo carga de escritura (según la topología) se convierte en el siguiente muro: el lag crece, el failover da miedo y descubres que tu app leía “con datos mayormente consistentes” y lo llamaba característica.

¿Quién choca primero? El motor que operes como si fuera el otro. Si ejecutas Postgres sin pooling y con transacciones largas, se agotará pronto. Si ejecutas MySQL con actualizaciones de filas calientes, índices secundarios gigantes y el optimismo de “el buffer pool nos salvará”, se agotará pronto.

Dos cosas son siempre ciertas: la base de datos es la primera acusada, y la base de datos es culpable más a menudo de lo que nadie quiere admitir.

Broma #1: Una base de datos bajo alta concurrencia es como un restaurante con un solo camarero y menús infinitos: técnicamente todos pueden pedir, prácticamente nadie come.

Hechos y contexto histórico que siguen importando

Algunas historias “antiguas” siguen siendo operativamente relevantes porque moldearon defaults, modelos mentales y herramientas.

  1. La genealogía de PostgreSQL se remonta a POSTGRES en UC Berkeley (años 80). La cultura de “correctitud primero” se nota en características como MVCC, SQL robusto y un planificador serio.
  2. El dominio temprano de MySQL (finales de los 90–2000s) vino por ser rápido y fácil para aplicaciones web, a menudo con MyISAM. Muchas historias de horror por concurrencia vienen de antes de InnoDB o de cambios de motor mal entendidos.
  3. InnoDB se convirtió en el predeterminado en MySQL 5.5 (2010). Si alguien aún habla de “bloqueos de tabla en MySQL” como verdad universal, o viaja en el tiempo o está ejecutando algo maldito.
  4. Autovacuum de Postgres apareció para hacer MVCC manejable a escala. No es limpieza opcional; es el recolector de basura para tu modelo de visibilidad de datos.
  5. La historia de replicación de MySQL incluye problemas con la replicación basada en sentencias, luego la basada en filas, luego la mixta. Cargas de escritura con no determinismo enseñaron humildad a muchos equipos.
  6. La instrumentación de wait events en Postgres (notablemente ampliada con los años) cambió cómo depuras contención: a menudo puedes nombrar el cuello de botella precisamente en vez de adivinar.
  7. Performance Schema de MySQL maduró hasta ser una herramienta real de observabilidad. Si no lo usas para diagnosticar contención, estás depurando con sensaciones.
  8. Ambas comunidades aprendieron lecciones duras sobre “más hilos” siendo una trampa: presión del planificador, contención de mutex y saturación de I/O no negocian.
  9. Los poolers de conexiones se volvieron práctica estándar para Postgres en entornos web de alta concurrencia, moldeando cómo se despliegan stacks modernos (pgBouncer es prácticamente mobiliario).

Los muros: cuellos de botella comunes bajo concurrencia extrema

Muro #1: gestión de conexiones y cambio de contexto

PostgreSQL usa un modelo de proceso por conexión. Eso no es “malo”; es predecible y aislado. Pero si tienes miles de sesiones activas haciendo consultas pequeñas, el planificador del SO forma parte de tu plan de consulta. El overhead de memoria crece, shared buffers no son toda la historia y comienzas a pagar por la administración.

MySQL normalmente ejecuta un modelo de hilo por conexión (detalles de implementación varían según versión y configuración). También puede manejar grandes cuentas de conexiones, pero igualmente pagas en overhead de planificación, memoria por hilo y contención de mutex en estructuras internas cuando la concurrencia se vuelve patológica.

Consejo real en producción: no pidas a ninguno de los dos motores que cuide 20.000 conexiones mayormente inactivas. Pon un pooler delante, mantén la concurrencia activa acotada y haz que la app haga cola con educación.

Muro #2: contención de bloqueos y filas calientes

La forma más rápida de perder es crear un hotspot de una sola fila: contadores, actualizaciones “last_seen” en la misma fila de usuario, “tabla de secuencia global”, “conteo de inventario disponible” o una cola implementada como “update one row where status=ready limit 1”. Felicidades, construiste un generador de contención de bloqueos.

InnoDB usa bloqueos a nivel de fila, pero el comportamiento exacto depende del nivel de aislamiento y la ruta de acceso. Bajo REPEATABLE READ (default común), next-key locking puede bloquear rangos para algunos patrones, lo que sorprende a quienes pensaban “solo bloqueos por fila”. Índices secundarios calientes también pueden serializar inserts/updates.

PostgreSQL usa MVCC así que las lecturas no bloquean escrituras, pero las escrituras siguen bloqueando escrituras. Una fila caliente que se actualiza constantemente se convierte en un punto de serialización. Además, la contención en páginas de índice y el alto churn de UPDATE pueden crear bloat y presión de vacuum.

El punto central: la concurrencia colapsa cuando fuerzas acceso serializado. Las bases de datos pueden arbitrar, pero no pueden hacer que un recurso único sea paralelo.

Muro #3: limpieza MVCC e higiene de transacciones

Ambos motores implementan un comportamiento tipo MVCC, pero pagan el impuesto de limpieza de manera diferente.

MVCC de PostgreSQL mantiene versiones antiguas de filas en la tabla hasta que vacuum reclama espacio. Eso significa que transacciones de larga duración impiden la limpieza porque las snapshots antiguas deben permanecer válidas. Con tasas altas de escritura, esto se vuelve un desastre en cámara lenta: el bloat crece, la eficiencia de caché cae, los escaneos de índices se vuelven más pesados y autovacuum empieza a pelear cuesta arriba.

InnoDB almacena información de undo para proporcionar lecturas consistentes. Las transacciones largas significan que los logs de undo crecen y el purge puede retrasarse, lo cual puede afectar el rendimiento y, en casos extremos, la estabilidad. Mismo pecado, forma distinta: transacciones largas bajo carga de escritura son caras.

La alta concurrencia amplifica los problemas de higiene de transacciones. Puedes ignorar unas pocas transacciones descuidadas a baja carga. A alta carga se vuelven un cuello de botella y una estufa de calor.

Muro #4: amplificación de I/O y presión de checkpoints

Cuando una base de datos “se frena” aleatoriamente bajo carga, a menudo no es aleatorio. Es el subsistema de almacenamiento siendo requerido para trabajo sincrónico ahora mismo porque no lo programaste antes.

PostgreSQL hace checkpoints que escriben buffers sucios al disco. Configuraciones de checkpoints mal ajustadas pueden llevar a I/O de escritura en ráfagas: periodos tranquilos puntuados por “todo se vacía ahora” y picos de latencia. Añade generación intensa de WAL y obtienes un sistema que luce bien hasta que no lo está.

MySQL/InnoDB gestiona páginas sucias, logging redo y flushing en background. Flushing mal configurado y redo/logs pequeños pueden crear presión constante, donde las escrituras en primer plano esperan espacio o durabilidad.

Bajo alta concurrencia, la capa de almacenamiento se convierte en el campo de arbitraje. Si el disco subyacente no puede seguir el patrón de fsync y la amplificación de escrituras, ambas bases de datos “chocarán contra el muro”, solo con vocabularios de instrumentación distintos.

Muro #5: índices y amplificación de escrituras

Los índices son multiplicadores de concurrencia en ambos sentidos. Hacen las lecturas más baratas y las escrituras más caras. Bajo alta concurrencia, las escrituras costosas se vuelven fábricas de contención: más páginas tocadas, más latches, más churn de caché, más WAL/redo.

Los índices secundarios de MySQL incluyen la clave primaria, así que una primary key amplia hace que todos los índices secundarios sean más pesados. Los índices de Postgres almacenan la clave y un puntero a la fila; las actualizaciones que cambian columnas indexadas también crean tuplas muertas en índices. Mecánicas distintas, mismo resultado: si indexas todo “por si acaso”, tu throughput de escritura eventualmente pedirá el divorcio.

Si necesitas escrituras de alta concurrencia, debes ser agresivamente escéptico con cada índice. Haz que la aplicación lo gane.

Muro #6: replicación y lag bajo carga de escritura

La replicación es donde los problemas de concurrencia se vuelven problemas organizacionales. Puedes tolerar algo de lag hasta el primer incidente donde alguien lee datos obsoletos y toma una decisión de negocio con ellos.

La replicación en streaming de PostgreSQL es física y basada en WAL. Es sólida, pero las réplicas aplican WAL; bajo alto churn de escrituras, el lag crece si el apply no puede seguir. Los conflictos en hot standby pueden cancelar consultas en réplicas si bloquean la recuperación, lo que aparece como “mi réplica aleatoriamente mata consultas”.

La replicación de MySQL depende del setup: replicación asíncrona clásica, semi-sync, group replication, etc. El lag es común en cargas intensas de escritura, y el apply multihilo en réplicas ayuda pero no es gratuito. Cuanto más paralelo sea el workload, más cuidadoso debes ser con el diseño de esquema y patrones de transacción para permitir paralelismo en apply.

En cualquier caso: si tu plan de escalado es “solo añadir réplicas de lectura”, primero comprueba si tu carga es realmente de lecturas o simplemente está bloqueada por escrituras y bloqueos.

Guion de diagnóstico rápido

El objetivo es identificar el recurso limitante en menos de 15 minutos. No perfeccionar el sistema. No ganar una discusión. Detener la hemorragia.

Primero: confirma si estás limitado por CPU, I/O o bloqueos

  • Limitado por CPU: uso alto de CPU, cola de runnable crece, tiempos de consulta aumentan de forma uniforme.
  • Limitado por I/O: alta utilización del disco, picos de latencia de fsync o escritura, checkpoints/flushing correlacionan con los bloqueos.
  • Limitado por bloqueos: muchas sesiones “activas” pero no consumiendo CPU; están esperando.
  • Limitado por conexiones: alto conteo de conexiones, mucho cambio de contexto, presión de memoria, agotamiento de pooles.

Segundo: encuentra la razón de espera principal, no la consulta principal

En alta concurrencia, la lista de “consultas lentas” suele mentir. Lo lento es la cola. Identifica en qué espera todo el mundo: un bloqueo, un buffer pin, flush de WAL, espacio en redo log, una página de índice específica, un sync del filesystem.

Tercero: identifica el hotspot (tabla, índice, fila o ruta de código)

Una vez que conoces la clase de espera, encuentras el hotspot. La solución suele ser una de:

  • reducir la contención (shardear el hotspot, cambiar el algoritmo, evitar actualizaciones de filas calientes)
  • reducir trabajo (eliminar índices, agrupar escrituras, cachear lecturas, evitar transacciones inútiles)
  • aumentar capacidad (almacenamiento más rápido, más memoria, más CPU) — solo después de saber qué estás alimentando
  • añadir aislamiento (pooler, cola, control de presión)

Cuarto: hace un cambio que reduzca la presión de concurrencia

Los cambios más rápidos para estabilizar suelen ser aburridos: bajar el máximo de transacciones activas, habilitar pooling, reducir timeouts de la app, dividir jobs enormes, y detener transacciones largas.

Broma #2: Añadir más servidores de aplicación a una base de datos bloqueada es como gritar en un atasco de tráfico—más fuerte no significa más rápido.

Tareas prácticas (comandos, salidas, decisiones)

Estas son las tareas que realmente ejecuto durante incidentes. Cada una incluye qué significa la salida y qué decisión tomar a partir de ella. Mezcla y combina para MySQL o PostgreSQL según qué esté ardiendo.

Tarea 1: Comprobar carga del sistema y saturación de CPU (Linux)

cr0x@server:~$ uptime
 14:07:21 up 32 days,  6:12,  2 users,  load average: 22.44, 19.10, 13.02

Significado: Promedio de carga muy por encima del número de núcleos sugiere cola de runnable o esperas de I/O no interrumpibles.

Decisión: Si la carga es alta y la CPU también, busca consultas caras. Si la carga es alta y la CPU es modesta, sospecha esperas de bloqueo o I/O.

Tarea 2: Identificar espera de I/O y presión de swap

cr0x@server:~$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
12  3      0  52124  43120 918420    0    0   112  9840 9400 21000 35 10 35 20  0
18  5      0  49812  42980 915220    0    0   120 11240 9900 24000 30 12 28 30  0

Significado: Alto wa indica CPU esperando disco. Alto cs también puede indicar demasiados hilos/procesos activos.

Decisión: Si wa es consistentemente alto, muévete rápido a diagnósticos de flushing/checkpoint de la base de datos y latencia de almacenamiento.

Tarea 3: Medir latencia de almacenamiento rápidamente (Linux)

cr0x@server:~$ iostat -x 1 3
Device            r/s     w/s   r_await   w_await  aqu-sz  %util
nvme0n1         120.0   980.0     1.20    18.50    9.40   97.0

Significado: Alto w_await y %util cerca de 100% indica que el dispositivo está saturado en escrituras.

Decisión: Trata esto como un cuello de botella de almacenamiento hasta demostrar lo contrario. Reduce la tasa de escrituras (batching, menos índices, menos eventos fsync) o mejora el almacenamiento.

Tarea 4: Contar conexiones y encontrar los mayores consumidores (PostgreSQL)

cr0x@server:~$ psql -XAt -c "select state, count(*) from pg_stat_activity group by 1 order by 2 desc;"
active|412
idle|1870
idle in transaction|37

Significado: Muchas conexiones inactivas sugiere que necesitas pooling. Cualquier idle in transaction no trivial es una señal roja.

Decisión: Si idle es enorme, pon pgBouncer (transaction pooling para muchas apps OLTP) y limita conexiones en el servidor. Si existe idle-in-transaction bajo carga, busca esos clientes primero.

Tarea 5: Encontrar en qué esperan las sesiones de Postgres

cr0x@server:~$ psql -X -c "select wait_event_type, wait_event, count(*) from pg_stat_activity where state='active' group by 1,2 order by 3 desc limit 10;"
 wait_event_type |  wait_event  | count
-----------------+--------------+-------
 Lock            | transactionid |   120
 IO              | DataFileRead  |    64
 LWLock          | buffer_content|    38

Significado: Tu concurrencia está limitada por clases de espera (locks de transactionid, I/O DataFileRead, LWLocks de buffer_content).

Decisión: Apilamientos de transactionid suelen significar actualizaciones de filas calientes o muchas comprobaciones de FK. DataFileRead indica misses de caché o escaneos inflados. buffer_content sugiere contención en shared buffers/páginas.

Tarea 6: Identificar la cadena de bloqueo en Postgres

cr0x@server:~$ psql -X -c "select blocked.pid as blocked_pid, blocker.pid as blocker_pid, blocked.query as blocked_query, blocker.query as blocker_query from pg_stat_activity blocked join pg_stat_activity blocker on blocker.pid = any(pg_blocking_pids(blocked.pid)) where blocked.state='active' limit 5;"
 blocked_pid | blocker_pid | blocked_query              | blocker_query
------------+-------------+---------------------------+-------------------------------
      18421 |       17210 | update counters set v=v+1 | update counters set v=v+1

Significado: Un clásico apilamiento de actualizaciones en una fila caliente.

Decisión: Para de usar patrones de “update misma fila”. Usa contadores shardados, logs de eventos append-only con agregación, o mueve a una caché con flush periódico.

Tarea 7: Comprobar salud de autovacuum y tuplas muertas (PostgreSQL)

cr0x@server:~$ psql -X -c "select relname, n_live_tup, n_dead_tup, last_autovacuum from pg_stat_user_tables order by n_dead_tup desc limit 5;"
 relname  | n_live_tup | n_dead_tup |     last_autovacuum
----------+------------+------------+---------------------------
 events   |   52000000 |   18000000 | 2025-12-29 12:11:03+00
 orders   |    9000000 |    2100000 | 2025-12-29 13:40:21+00

Significado: Muchas tuplas muertas implican bloat y presión de vacuum; las lecturas se vuelven más lentas y las cachés menos efectivas.

Decisión: Si autovacuum no puede seguir, ajusta autovacuum por tabla, reduce churn de actualizaciones y elimina transacciones largas que bloqueen vacuum.

Tarea 8: Encontrar transacciones de larga duración (PostgreSQL)

cr0x@server:~$ psql -X -c "select pid, now()-xact_start as xact_age, state, left(query,80) from pg_stat_activity where xact_start is not null order by xact_age desc limit 5;"
  pid  | xact_age |        state        | left
-------+----------+---------------------+----------------------------------------
 20311 | 01:12:09 | idle in transaction | SELECT * FROM customer WHERE id=$1

Significado: Una transacción de horas inactiva está reteniendo vacuum y aumentando riesgo de bloat.

Decisión: Mátala si es seguro, luego arregla la app. Añade timeouts (idle_in_transaction_session_timeout) y asegura que los clientes no mantengan transacciones durante llamadas a servicios externos.

Tarea 9: Comprobar comportamiento de checkpoints en Postgres

cr0x@server:~$ psql -X -c "select checkpoints_timed, checkpoints_req, checkpoint_write_time, checkpoint_sync_time, buffers_checkpoint from pg_stat_bgwriter;"
 checkpoints_timed | checkpoints_req | checkpoint_write_time | checkpoint_sync_time | buffers_checkpoint
------------------+-----------------+-----------------------+----------------------+-------------------
              128 |             412 |               983210  |               210992 |           8420012

Significado: Muchas checkpoints solicitadas (checkpoints_req) sugiere presión de WAL forzando checkpoints, lo que a menudo correlaciona con picos de escritura y acantilados de latencia.

Decisión: Considera ajustar checkpoints y tamaño de WAL, y reducir amplificación de escrituras (índices, patrones de update). También verifica que el almacenamiento pueda manejar escrituras sostenidas.

Tarea 10: Ver hilos/conexiones y consultas en ejecución (MySQL)

cr0x@server:~$ mysql -e "SHOW GLOBAL STATUS LIKE 'Threads_%';"
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| Threads_cached    | 120   |
| Threads_connected | 1850  |
| Threads_running   | 210   |
+-------------------+-------+

Significado: Threads_running es la presión de concurrencia real. Cientos de hilos en ejecución pueden significar contención de CPU, esperas de bloqueo o ambas.

Decisión: Si los hilos en ejecución son muchos y la latencia alta, encuentra en qué esperan (bloqueos, I/O, redo). Si connected es enorme, arregla pooling y timeouts.

Tarea 11: Comprobar esperas de bloqueo y deadlocks en InnoDB

cr0x@server:~$ mysql -e "SHOW ENGINE INNODB STATUS\G" | sed -n '1,80p'
=====================================
2025-12-29 14:09:56 INNODB MONITOR OUTPUT
=====================================
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 8912231, ACTIVE 0 sec updating or deleting
...

Significado: Deadlocks bajo carga son relativamente normales; frecuentes deadlocks son olor a diseño (filas calientes, orden de locks inconsistente, índices faltantes).

Decisión: Si los deadlocks se disparan, prioriza rediseñar la consulta/ruta y ordenar transacciones consistentemente. Añade/ajusta índices para evitar locks de rango por escaneos completos.

Tarea 12: Identificar esperas de bloqueo en MySQL vía Performance Schema

cr0x@server:~$ mysql -e "select object_schema, object_name, count_star, sum_timer_wait/1000000000000 as seconds_waited from performance_schema.table_lock_waits_summary_by_table order by sum_timer_wait desc limit 5;"
+--------------+-------------+-----------+----------------+
| object_schema| object_name  | count_star| seconds_waited |
+--------------+-------------+-----------+----------------+
| appdb        | counters     |  1203321  |  842.21        |
| appdb        | orders       |   214220  |  190.44        |
+--------------+-------------+-----------+----------------+

Significado: Qué tablas están causando esperas de bloqueo, cuantificado. “counters” es prácticamente una confesión.

Decisión: Arregla el hotspot primero. Ninguna cantidad de ajuste del buffer pool escala un contador de una sola fila.

Tarea 13: Comprobar señales de presión en redo log de MySQL

cr0x@server:~$ mysql -e "SHOW GLOBAL STATUS LIKE 'Innodb_log_waits';"
+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| Innodb_log_waits | 1842  |
+------------------+-------+

Significado: Innodb_log_waits no nulo y en aumento significa que las transacciones esperan por espacio/flush del redo log.

Decisión: Investiga el dimensionamiento del redo log y la configuración de flush; reduce volumen de escrituras; confirma latencia de almacenamiento. Este es un clásico muro de escrituras en alta concurrencia.

Tarea 14: Ver las declaraciones MySQL por latencia total (Performance Schema)

cr0x@server:~$ mysql -e "select digest_text, count_star, round(sum_timer_wait/1000000000000,2) as total_s, round(avg_timer_wait/1000000000000,6) as avg_s from performance_schema.events_statements_summary_by_digest order by sum_timer_wait desc limit 3;"
+-------------------------------------------+------------+---------+---------+
| digest_text                               | count_star | total_s | avg_s   |
+-------------------------------------------+------------+---------+---------+
| UPDATE counters SET v = v + ? WHERE id=?  |  9202211   |  912.11 | 0.000099|
| SELECT * FROM orders WHERE user_id = ?    |   821220   |  410.44 | 0.000500|
+-------------------------------------------+------------+---------+---------+

Significado: El UPDATE “pequeño” domina la latencia total porque se ejecuta millones de veces y genera contención.

Decisión: Reduce la frecuencia, agrupa, shardea o rediseña. No persigas microoptimización en el SELECT mientras el UPDATE es el cuchillo.

Tarea 15: Comprobar señales rápidas de bloat en índices de Postgres (aproximado)

cr0x@server:~$ psql -X -c "select relname, pg_size_pretty(pg_relation_size(relid)) as table_sz, pg_size_pretty(pg_total_relation_size(relid)) as total_sz from pg_catalog.pg_statio_user_tables order by pg_total_relation_size(relid) desc limit 5;"
 relname | table_sz | total_sz
---------+----------+----------
 events  | 58 GB    | 110 GB
 orders  | 12 GB    | 28 GB

Significado: Un tamaño total mucho mayor que el tamaño de la tabla sugiere índices pesados y/o bloat. No es prueba, pero sí un fuerte olor.

Decisión: Investiga patrones de actualización, autovacuum y necesidad de índices. Considera reindexar/ventanas de mantenimiento si el bloat está confirmado y perjudica la tasa de aciertos de caché.

Tarea 16: Comprobar lag básico de replicación (Postgres)

cr0x@server:~$ psql -X -c "select client_addr, state, write_lag, flush_lag, replay_lag from pg_stat_replication;"
 client_addr |   state   | write_lag | flush_lag | replay_lag
-------------+-----------+-----------+-----------+------------
 10.0.2.18    | streaming | 00:00:00.2| 00:00:01.1| 00:00:03.8

Significado: El replay lag que crece bajo concurrencia indica que las réplicas no pueden aplicar WAL lo suficientemente rápido, a menudo por I/O o CPU.

Decisión: Si las réplicas están detrás y sirven lecturas, estás sirviendo verdad antigua. O arreglas la capacidad de réplica, reduces el churn o diriges lecturas críticas al primario.

Tres microhistorias corporativas desde las trincheras de la concurrencia

Microhistoria 1: El incidente causado por una suposición errónea

Una compañía SaaS mediana migró un servicio de analítica de clientes de MySQL a PostgreSQL. El equipo tenía buenas razones: mejores características SQL, mejor soporte JSON para sus payloads y un planificador que manejaba mejor sus informes ad hoc. Hicieron pruebas de carga. Parecía bien.

Luego ocurrió el primer evento de “tráfico real”: una campaña de marketing más una integración de partner. La concurrencia se disparó y la base de datos empezó a hacer timeouts. La CPU no estaba terrible y el almacenamiento no se derritió. Pero la app estaba atascada en un pánico lento.

La suposición errónea fue simple: “Postgres puede manejar muchas conexiones, así que abriremos una por petición como antes”. Tenían miles de conexiones de corta duración thrashing. La sobrecarga de autenticación, la churn de procesos, la fragmentación de memoria y el cambio de contexto se convirtieron en un impuesto por cada petición.

Peor aún, los límites de transacción eran descuidados. Algunas peticiones abrían una transacción, hacían un SELECT, llamaban a un servicio externo y luego hacían un UPDATE. Bajo carga, esas sesiones idle-in-transaction se fueron acumulando y vacuum quedó atrás. El sistema no solo estaba lento; empeoraba lentamente.

La solución fue poco glamorosa: pgBouncer en modo pooling por transacción, un tope sensato de conexiones en el servidor y timeouts estrictos para transacciones inactivas. Luego refactorizaron el camino “llamar servicio externo mientras mantengo una transacción”. La concurrencia se estabilizó y el mismo hardware manejó más trabajo. La base de datos no se volvió “más rápida”. El sistema dejó de comportarse tontamente.

Microhistoria 2: La optimización que salió mal

Una plataforma de comercio electrónico en MySQL (InnoDB) tenía un objetivo de rendimiento: reducir lecturas en la página de producto durante ventas pico. Un ingeniero bienintencionado añadió una columna view_count a la tabla products y la actualizaba en cada vista. Una fila por producto. ¿Qué podría salir mal?

Todo, en cámara lenta al principio. En días normales funcionaba. Durante eventos de venta, los productos populares fueron machacados. La base de datos empezó a mostrar aumento de esperas de bloqueo y deadlocks. Los reintentos de la app se multiplicaron. El reintento extra aumentó la carga de escritura. La carga de escritura aumentó la presión de flush. La presión de flush aumentó la latencia. La latencia aumentó timeouts. Los timeouts aumentaron reintentos. Ya sabes.

El equipo inicialmente persiguió “tuning de MySQL”: tamaño de buffer pool, concurrencia de hilos, perillas de flushing. Obtuvieron mejoras marginales pero no estabilidad. El hotspot era el diseño: una actualización de alta frecuencia y altamente contesa sobre un pequeño conjunto de filas. Habían creado un mutex global disfrazado de característica.

La solución real: dejar de actualizar la misma fila por cada vista. Migraron a una tabla de eventos append-only (o incluso un sistema de contadores externo) y agregaron de forma asíncrona. De pronto los deadlocks se calmaron, el lag de replicación se redujo y la base de datos dejó de hacer cardio.

La lección no fue “nunca almacenes contadores”. Fue: si la escritura está más caliente que el objeto contado, necesitas un patrón de escritura que escale con la concurrencia, no contra ella.

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

Un equipo de servicios financieros ejecutaba PostgreSQL para OLTP y tenía un job semanal que hacía actualizaciones pesadas para conciliación. El job era lento pero predecible. Tenían una práctica que parecía paranoica: monitoreaban autovacuum por tabla, ajustaban settings de vacuum/analyze en relaciones con mucho churn, y tenían una política estricta que cualquier transacción más antigua que un umbral disparaba una alerta.

Una semana, un despliegue de un microservicio introdujo un bug: fuga de conexiones más un commit faltante en una ruta de error. Las sesiones se amontonaron “idle in transaction” manteniendo snapshots. El job batch empezó, generó tuplas muertas y autovacuum no pudo reclamarlas. Las latencias de consulta subieron, pero no al instante—lo suficiente para levantar sospechas.

El ingeniero on-call no empezó mirando gráficos de CPU. Ejecutó dos consultas: eventos de espera activos y edad de transacción más larga. La transacción más larga tenía horas. Ese fue el arma humeante. Mataron a los peores culpables, mitigaron la fuga revirtiendo, y el sistema se recuperó antes de que el bloat fuera una limpieza de varios días.

Este es el tipo de victoria de la que nadie se jacta en conferencias. También es cómo salvas tus fines de semana. Guardarraíles aburridos—timeouts, alertas sobre edad de transacción, verificación rutinaria de vacuum—evitan que problemas de concurrencia se vuelvan existenciales.

Errores comunes: síntomas → causa raíz → arreglo

1) “La CPU está baja pero todo es lento”

Síntomas: alta latencia de peticiones, CPU baja, muchas sesiones activas.

Causa raíz: contención de bloqueos o stalls de I/O; hilos/procesos esperando en vez de ejecutar.

Arreglo: identifica eventos de espera (Postgres) o esperas de lock/redo (MySQL). Elimina patrones de filas calientes, repara índices faltantes que causan locks de rango, reduce amplificación de escrituras y limita la concurrencia vía pooling/backpressure.

2) Autovacuum de PostgreSQL “misteriosamente” no puede seguir

Síntomas: crecimiento de tamaño de tabla/índice, empeoramiento de hit rates de caché, aumento de tiempos de consulta con el paso de los días.

Causa raíz: transacciones de larga duración reteniendo snapshots; tablas con muchas actualizaciones sin umbrales de autovacuum ajustados.

Arreglo: eliminar/evitar transacciones largas, establecer timeouts, afinar autovacuum por tabla y reducir churn de UPDATE (usar INSERT-only con compactación/aggregación cuando sea posible).

3) Deadlocks en MySQL se disparan tras un release

Síntomas: aumento de logs de deadlock, tormentas de reintentos, colapso de throughput.

Causa raíz: nueva ruta de código cambió el orden de locks o introdujo actualizaciones de filas calientes; índice faltante provoca un scan con locks más amplios.

Arreglo: hacer consistente el orden de locks en transacciones; añadir índices adecuados; reducir contención shardando hotspots; limitar reintentos con jitter y caps.

4) “Añadir réplicas no ayudó”

Síntomas: primario aún sobrecargado, réplicas con lag, latencia de lectura inconsistente.

Causa raíz: la carga es limitada por escrituras/bloqueos, no por lecturas; las réplicas no pueden aplicar cambios lo suficientemente rápido.

Arreglo: arreglar primero la ruta de escritura (índices, batching, contención). Dirige solo lecturas seguras/tolerantes a lag a réplicas. Mejora I/O de réplicas si apply es el limitante.

5) Agotamiento del pool de conexiones causa fallos en cascada

Síntomas: timeouts de app, aumento de errores, la base de datos muestra tormentas de conexiones.

Causa raíz: tamaño de pool demasiado pequeño relativo a la latencia; o peor, pool demasiado grande que sobrecarga la BD; faltan timeouts y clientes quedan pegados sosteniendo conexiones.

Arreglo: establece un tope estricto de conexiones DB, usa pooling, añade timeouts de consulta y transacción, y aplica backpressure en el borde.

6) “Ajustamos memoria pero sigue lento”

Síntomas: caches grandes configuradas, pero muchas lecturas desde disco y stalls bajo concurrencia.

Causa raíz: bloat, planes malos por parametrización, o conjunto de trabajo mayor que la memoria por demasiados índices o filas grandes.

Arreglo: reduce bloat e índices innecesarios; mide ratios de hit de caché; arregla patrones de consultas y estadísticas; considera particionado o archivado.

Listas de verificación / plan paso a paso

Plan paso a paso: elegir entre MySQL y PostgreSQL para OLTP de alta concurrencia

  1. Define el modelo de contención: ¿hay hotspots (contadores, inventario, colas) o mayormente filas independientes?
  2. Define la forma de transacción: ¿cortas y frecuentes, o largas y complejas? Si son comunes las transacciones largas, planifica controles y timeouts desde el día uno.
  3. Decide la estrategia de conexiones: si es Postgres, asume que el pooling es requerido. Si es MySQL, igualmente usa pool; no trates la BD como un servidor de sockets.
  4. Mide la amplificación de escritura: cuenta índices, ancho de fila y tasa de actualización esperada. Si es write-heavy, sé implacable con los índices.
  5. Elige nivel de aislamiento intencionalmente: los valores por defecto existen por razones históricas, no porque sean los mejores para tu carga.
  6. Diseña para backpressure: tu app debe degradarse con gracia cuando la BD esté saturada. Sin esto, cualquiera de las dos bases fallará “dramáticamente”.
  7. Planifica semánticas de replicación: ¿qué lecturas pueden tolerar lag? Si la respuesta es “ninguna”, tu arquitectura debe reflejar eso.
  8. Operativiza mantenimiento: vacuum y bloat (Postgres), purge y presión de redo (MySQL), backups, failovers y cambios de esquema bajo carga.

Checklist: estabilizar una base de datos limitada por bloqueos durante un incidente

  • Confirma que las esperas de bloqueo sean la clase dominante de espera (Postgres wait events / MySQL lock wait summaries).
  • Identifica la tabla/índice contendido y el patrón de consulta exacto.
  • Reduce temporalmente la concurrencia: baja el conteo de workers de la app, reduce consumidores de colas, ajusta tamaño de pool o descarga carga.
  • Evita tormentas de reintentos: limita reintentos, añade jitter y falla rápido en rutas no críticas.
  • Aplica mitigaciones específicas: añade índice faltante, cambia el orden de la transacción, desactiva una funcionalidad no esencial que escribe.
  • Tras estabilizar: rediseña el hotspot (contadores shardados, rediseño de colas, eventos append-only, particionado).

Checklist: prevenir que el bloat MVCC de Postgres mate la concurrencia

  • Alerta sobre sesiones idle in transaction y edad máxima de transacción.
  • Revisa semanalmente tablas con mayor churn: tuplas muertas, frecuencia de vacuum, crecimiento de tabla.
  • Ajusta autovacuum por tabla donde el churn es alto; no dependas de defaults globales.
  • Prefiere patrones INSERT-only + compactación/aggregación periódica para streams de eventos con muchas escrituras.
  • Mantén transacciones cortas; no mantengas snapshots mientras llamas a sistemas externos.

Checklist: prevenir tormentas de locks en MySQL InnoDB

  • Identifica y elimina actualizaciones de filas calientes y “contadores globales”.
  • Asegura que cláusulas WHERE críticas estén indexadas para evitar bloqueos amplios y scans.
  • Mantén transacciones cortas; evita batches grandes que mantengan locks por mucho tiempo.
  • Vigila esperas en redo log y comportamiento de flushing bajo escrituras sostenidas.
  • Valida el comportamiento de lag de replicación durante pruebas de carga; no lo descubras en producción.

Preguntas frecuentes

1) ¿PostgreSQL es “peor” a alta concurrencia porque usa procesos?

No. Es peor ante patrones de conexión descuidados. Con pooling y recuentos de sesión sensatos, Postgres maneja concurrencia transaccional muy bien. Sin pooling, pagas overhead del SO innecesariamente.

2) ¿MySQL es “mejor” porque es más rápido?

A veces es más rápido para patrones OLTP simples y puede ser muy eficiente. Pero “más rápido” se desmorona bajo contención de locks y amplificación de escrituras. Bajo actualizaciones de filas calientes, MySQL te castigará rápida y ruidosamente.

3) ¿Cuál choca primero a 10.000 conexiones?

Sin pooling: usualmente Postgres se quejará antes debido al overhead por proceso por conexión. Con pooling: ambos pueden sobrevivir, y el muro se moverá a locks, I/O o diseño de consultas.

4) ¿Por qué sentencias UPDATE “simples” se convierten en el mayor consumidor de latencia?

Porque no son simples bajo contención. Un UPDATE de 0.1ms ejecutado diez millones de veces con esperas de locks se convierte en tu coste dominante. Frecuencia más contención vence a la sofisticación siempre.

5) ¿Pueden las réplicas de lectura solucionar problemas de alta concurrencia?

Sólo si eres realmente lectura-bound y tus lecturas toleran lag en réplicas. Si estás limitado por locks o escrituras, las réplicas no arreglarán el cuello de botella del primario y pueden añadir complejidad operativa.

6) ¿Cuál es la mejor práctica única para Postgres bajo alta concurrencia?

Usar un pooler de conexiones y hacer cumplir transacciones cortas con timeouts. Si haces eso, evitas los muros tempranos más comunes y mantienes saludable la limpieza MVCC.

7) ¿Cuál es la mejor práctica única para MySQL bajo alta concurrencia?

Eliminar hotspots y asegurar que los índices soporten tus queries de escritura. También vigila esperas en redo log; si el trabajo de durabilidad bloquea las escrituras en primer plano, verás un colapso de concurrencia.

8) ¿Es mala idea usar aislamiento SERIALIZABLE para concurrencia?

No inherentemente, pero es caro y puede aumentar reintentos/fallos por serialización bajo contención. Úsalo cuando lo necesites y diseña alrededor de reintentos deliberadamente. No lo “actives por seguridad” y esperes lo mejor.

9) ¿Cómo sé si debo escalar hardware o rediseñar consultas?

Si las esperas están dominadas por un hotspot de lock único o por escaneos inducidos por bloat, rediseña primero. Si estás saturando almacenamiento con escrituras sostenidas e inevitables y ya redujiste amplificación, entonces escala hardware.

10) ¿Cuál es una métrica que predice fiable un próximo incidente?

Outliers en la edad de transacciones. Una transacción larga puede envenenar la concurrencia reteniendo limpieza (Postgres) o purge (InnoDB) y manteniendo locks más tiempo del esperado.

Próximos pasos (qué hacer el lunes por la mañana)

Si estás decidiendo entre MySQL y PostgreSQL para un sistema de alta concurrencia, elige según tus modos de fallo esperados y tu disciplina operativa:

  • Si puedes imponer pooling e higiene de transacciones, PostgreSQL es una buena opción por defecto con excelente observabilidad y profundidad SQL.
  • Si tu carga es OLTP sencilla con disciplina estricta de esquema/índices, MySQL/InnoDB puede ser brutalmente eficiente—hasta que introduces hotspots y finges que están bien.

Luego haz la configuración aburrida que previene heroicidades futuras:

  1. Implementa pooling de conexiones y aplica un tope estricto de conexiones a la BD.
  2. Añade timeouts: timeout de consulta, timeout de transacción y timeout para idle-in-transaction (o equivalentes lado app).
  3. Construye dashboards para esperas (Postgres wait events; MySQL lock/redo waits), no solo para CPU.
  4. Ejecuta una prueba de carga que incluya hotspots: contadores, colas y jobs batch. Si no pruebas las partes feas, la producción lo hará.
  5. Haz un playbook para hotspots: contadores shardados, eventos append-only, rediseño de colas y estrategias de particionado.

Un principio operativo para recordar: parafraseada idea de John Allspaw: la fiabilidad viene de diseñar sistemas que esperan fallos y responden con gracia, no de pretender que los fallos no ocurrirán.

Elige tu base de datos. Luego ejecútala como si realmente lo hicieras.

← Anterior
Ubuntu 24.04 tmpfs/ramdisk desbocado: evita que devore RAM (sin romper aplicaciones)
Siguiente →
Debian 13: “Text file busy” — por qué fallan los despliegues y cómo solucionarlo de forma segura (caso #57)

Deja un comentario