La mayoría de los debates “MySQL vs PostgreSQL” son cosplay: benchmarks sintéticos, opiniones de culto y un tipo que leyó la mitad de un post en 2014. Mientras tanto, tu sitio se queda sin respuesta porque una sola consulta pasó a ser un escaneo de tabla completo después de un deploy que parecía inocuo. A los clientes no les importa qué base de datos “gana”. Les importa que el checkout funcione y que las páginas de administración no tarden una eternidad.
Esta es la elección orientada a producción: qué base de datos fallará de qué manera, qué verás cuando falle y qué haces a las 2 a.m. cuando estás de guardia y tu pager tiene opiniones.
La tesis: elige el cuello de botella con el que prefieres vivir
Si tu sitio es una aplicación web típica—tablas CRUD, sesiones de usuarios, pedidos, páginas tipo búsqueda y algunos jobs en segundo plano—puedes ejecutar MySQL o PostgreSQL con mucho éxito. La diferencia honesta no es “cuál es más rápido.” Es:
- ¿Qué modo de fallo es más probable en tu organización? (SQL malo, migraciones defectuosas, esquemas descuidados, mala higiene de índices?)
- ¿Qué flujo operativo encaja con tu equipo? (replicación, failover, backups, migraciones, análisis de consultas)
- ¿Qué cuello de botella estás dispuesto a vigilar? (bloat/vacuum, contención de locks, lag de replicación, presión del buffer pool, picos de conexiones)
Mi configuración por defecto con opinión para una nueva “BD de sitio web” en 2025: PostgreSQL, a menos que tengas una razón concreta para no usarlo. No “porque siempre usamos MySQL.” Una razón válida sería: “ya operamos MySQL a gran escala, tenemos failover probado, tenemos gente que sabe depurar InnoDB, y nuestra app no necesita las características en las que Postgres destaca.”
¿Cuándo recomiendo MySQL? Cuando la empresa ya tiene músculo operacional en MySQL, cuando la carga es mayormente lecturas/escrituras simples con patrones previsibles, y cuando quieres menos bordes afilados en cuento a conteo de conexiones (no porque MySQL sea mágicamente inmune, sino porque la sobrecarga por conexión de Postgres castiga más rápido el pooling descuidado).
¿Cuándo recomiendo Postgres? Cuando la corrección de datos y la semántica sensata importan, cuando haces más que “SELECT por clave primaria”, cuando anticipas consultas tipo analytics, y cuando quieres un planificador y un ecosistema de índices que recompensen un buen modelado.
Broma #1: Elegir una base de datos por popularidad es como elegir un paracaídas por el color—perfecto hasta que realmente lo necesites.
Hechos e historia interesantes (porque explican los bordes afilados)
Esto no es trivia por el gusto de la trivia. Explica por qué existen ciertos comportamientos y valores por defecto.
- PostgreSQL surgió de Postgres (años 80) en UC Berkeley; desde el principio lo moldearon ideas académicas como MVCC y extensibilidad.
- El éxito temprano de MySQL fue “rápido y simple” para cargas web—especialmente cuando no necesitabas transacciones. Ese legado todavía aparece en su ecosistema y en cómo se usa.
- InnoDB se convirtió en el motor de almacenamiento predeterminado (para MySQL) porque la web demandó transacciones y recuperación ante fallos; el MySQL moderno es esencialmente “InnoDB con SQL encima”.
- Postgres implementó MVCC sin undo logs en el mismo estilo que InnoDB; en lugar de eso, las versiones antiguas de filas permanecen hasta que vacuum las limpia. Esa es la raíz del bloat y del tuning de vacuum.
- La replicación de MySQL priorizó históricamente la simplicidad (basada en binlog) y la compatibilidad amplia; por eso está en todas partes, incluso en stacks que “los montamos en 2012 y todavía funcionan”.
- Las extensiones de Postgres son una cultura de primera clase (p. ej.: tipos de índices personalizados, búsqueda de texto, lenguajes procedurales). No es solo “una base de datos”, es una plataforma.
- El soporte JSON divergió filosóficamente: JSONB de Postgres es altamente indexable; el JSON de MySQL es utilizable y mejora, pero suele empujar a la gente hacia el drift de esquemas semiestructurados más pronto.
- El planificador de consultas de Postgres es famoso por ser opinado y a veces se equivoca de formas sorprendentes; la solución suele ser estadísticas e índices, no plegarias.
- El ecosistema de MySQL tiene múltiples ramas y vendors (builds comunitarios, ofertas comerciales, forks). Genial para opciones; también genial para “¿qué comportamiento exacto tenemos?” confusión.
Qué significa realmente “BD para sitios web” (y qué no)
La mayoría de los sitios no son OLTP puro. Son una mezcla desordenada:
- Rutas calientes: login, comprobación de sesiones, páginas de producto, operaciones de carrito. Sensibles a latencia.
- Jobs en segundo plano: envío de correos, indexado, sincronizaciones periódicas. Sensibles al throughput.
- Admin/reportes: “muéstrame pedidos por estado” con diez filtros y ordenamiento. Sensibles al planificador.
- Búsqueda y feeds: paginación, ordenación por tiempo, “recomendados”. Sensibles a índices.
- Picos aleatorios: campañas de marketing, crawlers, cron desbocado. Sensibles a conexiones.
Ambas bases de datos pueden manejar esto. El truco es que fallan de manera diferente:
- MySQL tiende a castigarte con contención por locks, sorpresas de replicación y “estaba bien hasta que no” por saturación del buffer pool o del I/O.
- Postgres tiende a castigarte con tormentas de conexiones, deuda de vacuum, bloat y consultas que pasan de 50 ms a 50 s cuando las estadísticas se degradan.
No eliges una base de datos; eliges un conjunto de tareas operativas. Escoge las tareas que tu equipo realmente hará.
Cuellos de botella reales: dónde duele cada uno
1) Bloqueos y concurrencia: “¿por qué todo está esperando?”
Postgres usa MVCC y locks a nivel de fila. Las lecturas normalmente no bloquean escrituras, y las escrituras no bloquean lecturas, hasta que te topas con locks explícitos, checks de foreign key, transacciones largas o cambios de esquema. Cuando falla, suele ser: una transacción larga que impide al vacuum avanzar y crea una fila de consultas bloqueadas.
MySQL/InnoDB también usa MVCC, pero su comportamiento de locking (gap locks, next-key locks) puede sorprender bajo ciertos niveles de aislamiento y patrones de acceso. El modo de fallo común: una consulta que “debería tocar una fila” bloquea un rango debido a una elección de índice, y de repente has creado un atasco.
2) Planificación de consultas e indexado: “¿por qué esta consulta se volvió tonta?”
Postgres brilla cuando tienes consultas complejas, varios joins, índices parciales e índices funcionales. Pero exige buenas estadísticas y evolución disciplinada del esquema. Cuando autovacuum/analyze se quedan atrás, los planes se pudren. Lo verás como scans secuenciales repentinos u órdenes de join malos.
MySQL puede ser extremadamente rápido en caminos de acceso simples y con indexación predecible. Pero el optimizador históricamente tenía más “trampas” alrededor de tablas derivadas, subconsultas y ordenación de joins complejos. En la práctica: los equipos acaban reescribiendo consultas o desnormalizando antes.
3) Replicación y failover: “la app dice committed, pero la réplica de lectura discrepa”
Ambas tienen replicación madura. Ambas pueden hacerte daño.
- MySQL la replicación es ubicua y bien entendida. El dolor clásico es el lag de replicación con ráfagas de escritura y la complejidad operativa de topologías multi-origen o cambios si no planificaste.
- Postgres la replicación por streaming es robusta, pero debes ser explícito sobre trade-offs síncronos vs asíncronos. El dolor suele ser “asumimos que las réplicas son legibles para todo” y luego descubres conflictos en hot standby o lag bajo consultas de larga duración.
4) Comportamiento del motor de almacenamiento: caché de buffers, I/O y amplificación de escrituras
MySQL/InnoDB quiere un buffer pool grande y bien dimensionado. Cuando es demasiado pequeño o tu working set crece, empiezas a thrash. Verás I/O de lectura subir, picos de latencia y un servidor que parece “bien” en CPU pero está muriendo en almacenamiento.
Postgres depende mucho del page cache del SO además de shared_buffers. Puede rendir de manera excelente, pero es más sensible al bloat de tablas e índices. El bloat significa que lees páginas llenas de tuplas muertas y entradas de índice obsoletas. Tu subsistema de almacenamiento paga por tus pecados.
5) Mantenimiento: vacuum vs purge, y qué significa “aburrido”
Postgres requiere vacuum. Autovacuum es bueno, pero “bueno” no es “configúralo y olvídate.” Si haces actualizaciones/borrados frecuentes y no ajustas vacuum, eventualmente chocarás contra un muro: tablas hinchadas, consultas lentas y posibles emergencias de transaction ID wraparound.
MySQL hace purge internamente (undo logs) y no tiene un equivalente exacto a vacuum. Eso son menos perillas, pero no es gratis: aún gestionas índices, fragmentación y pagas por decisiones de esquema pobres. Además, operaciones grandes de ALTER TABLE y el comportamiento de DDL online pueden convertirse en su propio tipo de fin de semana problemático.
6) Manejo de conexiones: Postgres castiga el pooling descuidado
Postgres usa un modelo process-per-connection en muchas configuraciones; demasiadas conexiones significan sobrecarga de memoria, cambio de contexto y dolor. Casi siempre querrás un pooler (como pgbouncer) para apps web con tráfico en ráfagas.
El modelo de threads de MySQL y sus valores por defecto típicos pueden tolerar más conexiones antes de colapsar, pero “tolerar” no es “seguro.” Si permites que la app abra miles de conexiones porque “funcionó en staging”, eventualmente estarás en staging en producción.
7) Características que afectan realmente la arquitectura del sitio
- Postgres: extensiones potentes, tipos de índice ricos, mejores constraints, funciones de ventana avanzadas y SQL avanzado, excelente indexado JSONB, semántica más estricta en general.
- MySQL: ecosistema excelente, amplio soporte de hosting, patrones operativos comunes, rendimiento muy fuerte para muchos patrones OLTP sencillos.
Guía de diagnóstico rápido (primero/segundo/tercero)
Si tu sitio está lento o se agota el tiempo de espera, no empieces cambiando configuración al azar. Empieza determinando en qué clase de cuello de botella estás. Aquí está la triage más rápida que funciona en incidentes reales.
Primero: ¿la base de datos espera por CPU, I/O o locks?
- CPU saturada: busca consultas caras (planes malos), índices faltantes, reportes fuera de control o spills por hash/aggregations.
- I/O saturado: busca misses de buffer/cache, escaneos completos, bloat o regresión en latencia de almacenamiento.
- Bloqueos: busca queries bloqueadas, transacciones largas, DDL o una fila/tabla caliente.
- Conexiones: si el conteo de conexiones se dispara, todo lo demás puede parecer “bien” mientras la app se derrite.
Segundo: identifica las 1–3 consultas principales por tiempo total, no solo la más lenta
La consulta más lenta suele ser un caso aislado. El cuello de botella suele ser “esta consulta de 20 ms se ejecuta 10.000 veces por minuto”, o “esta consulta de 200 ms ahora corre 500 copias concurrentes”.
Tercero: valida el plan y la ruta del índice
En Postgres: EXPLAIN (ANALYZE, BUFFERS). En MySQL: EXPLAIN más performance_schema y métricas handler. Buscas: scans inesperados, orden de joins malo, tablas temporales, filesorts o lecturas masivas de buffers.
Cuarto: revisa la replicación y las suposiciones de lectura/escritura de la app
Muchos tickets de “db lento” son en realidad “lag de réplica + suposición read-after-write.” Esto no es filosófico. Es literalmente un usuario que hace click en “guardar” y luego ve datos antiguos.
Quinto: detén la hemorragia, luego arregla la causa raíz
Parar la hemorragia suele ser: matar la peor consulta, deshabilitar un reporte, añadir un índice faltante o desviar tráfico de una réplica enferma. Luego arreglas esquema y código con manos calmadas.
Cita (idea parafraseada), John Allspaw: La fiabilidad viene de cómo respondes al fallo, no de fingir que el fallo no ocurrirá.
Tareas prácticas: comandos, salidas y la decisión que tomas
A continuación hay tareas prácticas que realmente ejecuto durante incidentes o trabajo de performance. Cada una incluye un comando, un fragmento de salida plausible, qué significa y qué decisión tomas a continuación.
Task 1 (Postgres): ver quién está ejecutando qué y quién está esperando
cr0x@server:~$ psql -X -c "SELECT pid, usename, state, wait_event_type, wait_event, now()-query_start AS age, left(query,80) AS query FROM pg_stat_activity WHERE datname='appdb' ORDER BY age DESC LIMIT 10;"
pid | usename | state | wait_event_type | wait_event | age | query
------+--------+--------+-----------------+---------------+---------+--------------------------------------------------------------------------------
8123 | app | active | Lock | transactionid | 00:05:12| UPDATE orders SET status='paid' WHERE id=$1
7991 | app | active | | | 00:01:44| SELECT * FROM orders WHERE user_id=$1 ORDER BY created_at DESC LIMIT 50
7902 | app | idle | Client | ClientRead | 00:00:33|
Qué significa: El PID 8123 está esperando un lock de transactionid desde hace 5 minutos. Eso suele ser una transacción larga en otra parte que mantiene algo abierto, o un patrón de contención de filas calientes.
Decisión: Encuentra el bloqueador (siguiente tarea), luego decide si matarlo, arreglar la lógica de la app o añadir un índice/ajustar patrones de aislamiento.
Task 2 (Postgres): encontrar la consulta que bloquea
cr0x@server:~$ psql -X -c "SELECT blocked.pid AS blocked_pid, blocker.pid AS blocker_pid, now()-blocker.query_start AS blocker_age, left(blocker.query,120) AS blocker_query FROM pg_stat_activity blocked JOIN pg_locks bl ON bl.pid=blocked.pid JOIN pg_locks kl ON kl.locktype=bl.locktype AND kl.database IS NOT DISTINCT FROM bl.database AND kl.relation IS NOT DISTINCT FROM bl.relation AND kl.page IS NOT DISTINCT FROM bl.page AND kl.tuple IS NOT DISTINCT FROM bl.tuple AND kl.virtualxid IS NOT DISTINCT FROM bl.virtualxid AND kl.transactionid IS NOT DISTINCT FROM bl.transactionid AND kl.classid IS NOT DISTINCT FROM bl.classid AND kl.objid IS NOT DISTINCT FROM bl.objid AND kl.objsubid IS NOT DISTINCT FROM bl.objsubid AND kl.pid != bl.pid JOIN pg_stat_activity blocker ON blocker.pid=kl.pid WHERE NOT bl.granted;"
blocked_pid | blocker_pid | blocker_age | blocker_query
-------------+-------------+-------------+-------------------------------------------------------------
8123 | 7701 | 00:12:09 | BEGIN; UPDATE users SET last_seen=now() WHERE id=$1; -- no COMMIT yet
Qué significa: Una transacción ha estado abierta 12 minutos y está bloqueando a otras. Probablemente es un bug de la app, un worker atascado o una conexión mantenida abierta durante una llamada de red.
Decisión: Matar el PID bloqueador 7701 si es seguro; luego arreglar el código para evitar transacciones largas; añadir timeouts; auditar el scope de transacciones.
Task 3 (Postgres): inspeccionar la salud del vacuum y el riesgo de bloat
cr0x@server:~$ psql -X -c "SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, last_autoanalyze FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;"
relname | n_live_tup | n_dead_tup | last_autovacuum | last_autoanalyze
-------------+------------+------------+------------------------+------------------------
events | 24000000 | 9800000 | 2025-12-29 01:12:43+00 | 2025-12-29 00:55:21+00
sessions | 3100000 | 1200000 | 2025-12-28 22:41:10+00 | 2025-12-28 22:40:58+00
orders | 900000 | 12000 | 2025-12-29 01:10:03+00 | 2025-12-29 01:10:02+00
Qué significa: La tabla events tiene un gran recuento de tuplas muertas. Si las consultas en esa tabla se ralentizan con el tiempo, el bloat es un sospechoso principal.
Decisión: Ajustar autovacuum para esa tabla, considerar particionar, reducir churn de actualizaciones y comprobar si los índices también están hinchados.
Task 4 (Postgres): confirmar un mal plan con EXPLAIN ANALYZE
cr0x@server:~$ psql -X -c "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id=42 ORDER BY created_at DESC LIMIT 50;"
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.00..2451.11 rows=50 width=512) (actual time=1832.504..1832.573 rows=50 loops=1)
Buffers: shared hit=102 read=9180
-> Seq Scan on orders (cost=0.00..98010.22 rows=1999 width=512) (actual time=0.041..1829.721 rows=950000 loops=1)
Filter: (user_id = 42)
Rows Removed by Filter: 899000
Planning Time: 0.412 ms
Execution Time: 1832.711 ms
Qué significa: Scan secuencial con lecturas enormes. El servidor leyó ~9k buffers desde disco. Esto grita “índice faltante” (probablemente (user_id, created_at)).
Decisión: Añadir un índice y volver a comprobar. Si el índice existe, revisar estadísticas y si la consulta coincide con el orden del índice.
Task 5 (Postgres): comprobar uso de índices y encontrar índices no usados
cr0x@server:~$ psql -X -c "SELECT relname AS table, indexrelname AS index, idx_scan, pg_size_pretty(pg_relation_size(indexrelid)) AS size FROM pg_stat_user_indexes ORDER BY idx_scan ASC, pg_relation_size(indexrelid) DESC LIMIT 10;"
table | index | idx_scan | size
---------+--------------------------+----------+---------
events | events_payload_gin | 0 | 312 MB
orders | orders_status_created_at | 2 | 148 MB
users | users_email_key | 9012 | 42 MB
Qué significa: Índices grandes con casi cero scans pueden ser imanes de bloat y amplificadores de escritura. Pero no los elimines a ciegas—algunos son para consultas administrativas raras o constraints.
Decisión: Confirmar con logs de consultas y búsqueda en el código; si realmente no se usan, eliminarlos para acelerar escrituras y vacuum.
Task 6 (MySQL): ver sesiones activas y si están esperando locks
cr0x@server:~$ mysql -e "SHOW FULL PROCESSLIST;"
Id User Host db Command Time State Info
221 app 10.0.1.12:55342 appdb Query 120 Waiting for row lock UPDATE orders SET status='paid' WHERE id=12345
238 app 10.0.1.15:49810 appdb Query 15 Sending data SELECT * FROM orders WHERE user_id=42 ORDER BY created_at DESC LIMIT 50
250 app 10.0.1.16:51011 appdb Sleep 300 NULL
Qué significa: Tienes esperas por locks y también conexiones en sleep. “Waiting for row lock” más una cola creciente significa contención, normalmente por un patrón de índice o una transacción mantenida abierta.
Decisión: Identificar el bloqueador vía tablas de lock de InnoDB, luego arreglar el scope de la transacción y el indexado.
Task 7 (MySQL): encontrar la cadena de espera de locks de InnoDB
cr0x@server:~$ mysql -e "SELECT * FROM information_schema.INNODB_TRX\G"
*************************** 1. row ***************************
trx_id: 54100912
trx_state: LOCK WAIT
trx_started: 2025-12-29 01:20:01
trx_mysql_thread_id: 221
trx_query: UPDATE orders SET status='paid' WHERE id=12345
*************************** 2. row ***************************
trx_id: 54100901
trx_state: RUNNING
trx_started: 2025-12-29 01:05:43
trx_mysql_thread_id: 199
trx_query: UPDATE orders SET shipping_label=... WHERE id=12345
Qué significa: El thread 199 está ejecutando una transacción desde las 01:05. Eso no es normal para una petición web. Probablemente es un job de la app que mantiene la transacción mientras hace algo lento (llamada a API, subida de archivo, bucle de reintentos).
Decisión: Matar la transacción ofensora si es seguro; arreglar la aplicación para commitear antes; añadir timeouts; asegurarse de que las consultas usan el índice correcto para reducir la huella de locks.
Task 8 (MySQL): comprobar el lag de replicación rápido
cr0x@server:~$ mysql -e "SHOW SLAVE STATUS\G"
Seconds_Behind_Master: 87
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
Replica_SQL_Running_State: Waiting for dependent transaction to commit
Last_SQL_Error:
Qué significa: La réplica está ~87 segundos detrás. Las lecturas desde ella estarán desfasadas. “Waiting for dependent transaction to commit” puede indicar ráfagas de escritura o bloqueo en el hilo de apply de la réplica.
Decisión: Redirigir lecturas críticas al primario temporalmente; reducir la amplificación de escritura; buscar transacciones grandes; ajustar la paralelización de la réplica si aplica.
Task 9 (Postgres): comprobar lag de replicación y estado de replay
cr0x@server:~$ psql -X -c "SELECT client_addr, state, sync_state, write_lag, flush_lag, replay_lag FROM pg_stat_replication;"
client_addr | state | sync_state | write_lag | flush_lag | replay_lag
-------------+-----------+------------+-----------+-----------+------------
10.0.2.21 | streaming | async | 00:00:01 | 00:00:02 | 00:00:24
Qué significa: El replay lag es de 24 segundos. Si tu app lee desde réplicas, verás anomalías read-after-write a menos que lo gestiones en código.
Decisión: Implementar enrutamiento read-your-writes (stickiness) o cambiar a replicación síncrona para el subconjunto de tráfico que lo necesite.
Task 10 (Postgres): detectar tormentas de conexiones y la necesidad de un pooler
cr0x@server:~$ psql -X -c "SELECT state, count(*) FROM pg_stat_activity GROUP BY state ORDER BY count(*) DESC;"
state | count
--------+-------
idle | 420
active | 85
null | 0
Qué significa: Cientos de conexiones idle significan que tu app o pool está manteniendo conexiones abiertas. Eso es sobrecarga de memoria y puede convertirse en una bomba de forks cuando el tráfico suba.
Decisión: Poner un pooler real, establecer límites máximos sensatos y ajustar tamaños de pool en la aplicación.
Task 11 (Linux): confirmar si estás limitado por I/O
cr0x@server:~$ iostat -x 1 3
Device r/s w/s rkB/s wkB/s await svctm %util
nvme0n1 820.0 210.0 51200.0 18400.0 9.80 0.45 89.0
Qué significa: %util cerca del 90% y await ~10ms indica que el disco está ocupado y las solicitudes se están encolando. Si las consultas sensibles a latencia se disparan al mismo tiempo, estás limitado por I/O.
Decisión: Reducir lecturas (arreglar scans, bloat), aumentar memoria/caché para mejorar el hit ratio, o pasar a almacenamiento más rápido. También comprobar si un backup, vacuum o job batch está saturando el I/O.
Task 12 (Postgres): ver la ratio de aciertos en caché y lecturas de buffers para detectar dolor
cr0x@server:~$ psql -X -c "SELECT datname, blks_hit, blks_read, round(blks_hit*100.0/nullif(blks_hit+blks_read,0),2) AS hit_pct FROM pg_stat_database WHERE datname='appdb';"
datname | blks_hit | blks_read | hit_pct
---------+-----------+-----------+---------
appdb | 190224455 | 9221440 | 95.38
Qué significa: 95% de aciertos es decente, pero si cae durante picos o si una consulta causa lecturas enormes, lo notarás. “Hit rate” no es un trofeo; es una pista.
Decisión: Si las lecturas suben, investigar bloat e índices faltantes. Si el hit rate es bajo en general, revisar dimensionamiento de memoria y patrones de consultas.
Task 13 (MySQL): comprobar efectividad del InnoDB buffer pool
cr0x@server:~$ mysql -e "SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';"
Variable_name Value
Innodb_buffer_pool_read_requests 184455912
Innodb_buffer_pool_reads 2219441
Qué significa: Misses del buffer pool = Innodb_buffer_pool_reads. Si los misses suben rápido durante tráfico, el working set no cabe o las consultas están escaneando.
Decisión: Añadir índices, parar escaneos y dimensionar el buffer pool adecuadamente. Si el dataset superó la RAM, planear réplicas de lectura, sharding o mejores patrones de caché.
Task 14 (Ambos): comprobar crecimiento de espacio en disco y la trayectoria “ups nos quedamos sin espacio”
cr0x@server:~$ df -h /var/lib
Filesystem Size Used Avail Use% Mounted on
/dev/nvme0n1p2 1.8T 1.6T 120G 94% /var/lib
Qué significa: 94% lleno no es “está bien.” Es un preludio de incidente. Las bases de datos se comportan mal cuando no pueden extender archivos, no pueden hacer checkpoints cómodamente o no pueden escribir datos temporales.
Decisión: Liberar espacio inmediatamente (logs, backups antiguos, archivos temporales), luego arreglar el motor de crecimiento: bloat, tablas fuera de control, retención faltante o índices sobredimensionados.
Task 15 (Postgres): comprobar la edad de transacciones y riesgo de wraparound
cr0x@server:~$ psql -X -c "SELECT datname, age(datfrozenxid) AS xid_age FROM pg_database ORDER BY xid_age DESC;"
datname | xid_age
----------+---------
appdb | 145000000
template1| 4300000
Qué significa: Alta edad de XID significa que el vacuum no está congelando tuplas lo suficientemente rápido. Si se acerca a umbrales peligrosos, te enfrentarás a autovacuum de emergencia y colapso de rendimiento.
Decisión: Investigar por qué vacuum no puede seguir el ritmo (transacciones largas, autovacuum mal ajustado, tablas gigantes) y arreglar antes de que sea existencial.
Task 16 (MySQL): capturar los digests de consultas principales (performance_schema)
cr0x@server:~$ mysql -e "SELECT DIGEST_TEXT, COUNT_STAR, ROUND(SUM_TIMER_WAIT/1000000000000,2) 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
SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT ? 120000 980.12
UPDATE users SET last_seen = NOW() WHERE id = ? 900000 410.33
SELECT * FROM products WHERE status = ? ORDER BY updated_at DESC LIMIT ? 80000 155.20
Qué significa: El mayor consumidor de tiempo total es la primera consulta, incluso si es “rápida” por llamada. Se ejecuta constantemente.
Decisión: Indexarla, cachearla o reducir las llamadas. El tiempo total vence a la “consulta más lenta” en la mayoría de los incidentes web.
Tres mini-historias corporativas (anonimizadas, plausibles y dolorosamente familiares)
Mini-historia 1: el incidente causado por una suposición errónea (las réplicas de lectura son “prácticamente lo mismo”)
Una compañía SaaS mediana tenía una arquitectura limpia: base de datos primaria para escrituras y una réplica de lectura para “todo lo demás”. Los desarrolladores trataron la réplica como intercambiable. Una petición escribía una fila y luego la leía—desde la conexión que el ORM asignara.
Funcionó durante meses. El tráfico creció. Las escrituras se volvieron en ráfagas: emails de marketing, jobs en background y una nueva función que actualizaba contadores más frecuentemente de lo que alguien admitía. El lag de réplica pasó de “milisegundos” a “a veces segundos”. Nadie lo notó porque la mayoría de las páginas toleraban datos desfasados.
Luego vino el incidente: los usuarios actualizaron datos de facturación, vieron un toast de “Guardado!”, recargaron la página y vieron la dirección antigua. Los tickets de soporte se encendieron. Los ingenieros culparon caché, luego CDN, luego “el frontend”. Los gráficos de la base de datos parecían “bien” porque la CPU no estaba saturada y la latencia solo se elevó levemente.
La causa raíz fue aburrida: inconsistencia read-after-write debido a replicación asíncrona con lag. La suposición equivocada fue fatal solo cuando el proceso del negocio exigió corrección en la UI.
La solución no fue heroica. Implementaron stickiness: después de una escritura, las lecturas de ese usuario se pinchan al primario por una ventana corta (o hasta que expire el token de sesión). También hicieron visible el lag de replicación en los dashboards y enrutar ciertos endpoints (facturación, auth) siempre al primario. Después de eso, el “incidente DB” se convirtió en una restricción de diseño, no en una sorpresa.
Mini-historia 2: la optimización que salió mal (indexar todo y luego pagarlo por siempre)
Una plataforma de e-commerce decidió “anticiparse al rendimiento”. Un desarrollador senior añadió índices para todas las combinaciones de filtros concebibles en la UI de administración: estado, rango de fechas, país, proveedor de pago, SKU, código de campaña. El esquema parecía un erizo.
Las consultas de lectura fueron rápidas. Todos se felicitaron. Luego llegó Black Friday. Las escrituras se ralentizaron, no un poco. Las latencias de checkout subieron. La CPU estaba bien. El almacenamiento de la base de datos empezó a thrash. El lag de replicación saltó. La plataforma sobrevivió, pero en modo “no podemos desplegar por dos días”.
En el postmortem encontraron lo obvio que nadie quería decir: cada insert/update debía mantener una pila de índices raramente usados. La ruta de escritura estaba pagando renta por fantasías administrativas. Peor aún, el vacuum/mantenimiento (Postgres) o la gestión de páginas en background (MySQL) tenían más trabajo. El sistema se volvió “lecturas rápidas, todo lo demás lento”, y el cuello de botella del negocio era el checkout.
La solución fue crueldad disciplinada. Mantuvieron un conjunto pequeño de índices de alto valor ligados a consultas reales y eliminaron el resto. Para reportes administrativos movieron consultas pesadas a una réplica con SLOs más laxos y construyeron tablas resumen para reportes comunes. El rendimiento mejoró y, más importante: la base de datos dejó de ser un cristal frágil.
Mini-historia 3: la práctica aburrida pero correcta que salvó el día (simulacros de restauración)
Un sitio con mucho contenido ejecutaba Postgres. Nada sofisticado: un primario, una réplica, backups nocturnos. El equipo no era famoso por los procesos. Pero un ingeniero insistió en un simulacro trimestral de restauración, como el hilo dental de la infraestructura.
Tenían una checklist: obtener el último base backup, restaurarlo en un entorno aislado, reproducir WAL hasta un punto en el tiempo, ejecutar consultas de sanity y verificar que la app pueda arrancar contra él. Lo hacían cuando nadie estaba en llamas, lo que lo hacía sentir como trabajo extra. También fue la única razón por la que supieron que sus backups eran reales.
Meses después, una migración eliminó accidentalmente una columna usada por un job en background. El job reintentó, falló, reintentó y escribió datos basura en otra tabla. El radio de daño no fue inmediato; fue corrupción lenta, el peor tipo. Cuando lo notaron, los datos malos ya se habían replicado y respaldado varias veces.
Hicieron una recuperación punto en el tiempo hasta un timestamp justo antes de la migración, verificaron con las mismas consultas de prueba y restauraron servicio con drama mínimo. El incidente aún ocurrió, pero no se convirtió en un evento de carrera.
Broma #2: Los planes de respaldo son como membresías de gimnasio—tener una no cambia nada hasta que realmente la usas.
Errores comunes: síntoma → causa raíz → arreglo
1) Síntoma: “La CPU de la BD está baja, pero las peticiones se agotan”
Causa raíz: Bloqueos o saturación del pool de conexiones. La base de datos está mayormente esperando, no trabajando.
Arreglo: Identificar consultas/transacciones bloqueadoras; acortar scopes de transacción; añadir timeouts; limitar concurrencia; introducir un pooler (Postgres) o ajustar límites de threads/conexiones (MySQL).
2) Síntoma: “Una consulta es lenta solo en producción”
Causa raíz: Distribución de datos diferente, estadísticas faltantes o índices distintos. Producción tiene skew y hotspots; staging tiene pensamiento deseado.
Arreglo: Capturar EXPLAIN/ANALYZE en producción; actualizar stats; añadir índices dirigidos; considerar índices parciales (Postgres) o índices compuestos cubrientes (MySQL).
3) Síntoma: “La réplica de lectura muestra datos antiguos”
Causa raíz: Lag de replicación asíncrona y suposiciones de read-after-write en la app.
Arreglo: Leer desde el primario después de escrituras (stickiness), o implementar replicación síncrona para operaciones críticas, o diseñar la UI para tolerar consistencia eventual explícitamente.
4) Síntoma: “El uso de disco crece para siempre aunque el tráfico sea estable”
Causa raíz: Bloat de Postgres por churn de updates/deletes; crecimiento de tablas/índices en MySQL por fragmentación o retención sin límites.
Arreglo: Postgres: ajustar autovacuum, reducir churn de updates, particionar tablas con mucho churn, considerar REINDEX/VACUUM FULL planificado con downtime. MySQL: archivar/purgar datos antiguos, reconstruir tablas cuidadosamente, revisar índices sobredimensionados.
5) Síntoma: “Un deploy causó un outage total y luego se recuperó”
Causa raíz: DDL que bloquea o una migración que reescribió una tabla enorme, saturando I/O y bloqueando consultas.
Arreglo: Usar cambios de esquema online, dividir migraciones, añadir columnas sin defaults primero, backfill por lotes y luego aplicar constraints.
6) Síntoma: “Todo se pone lento cuando corre un reporte”
Causa raíz: Consultas de larga duración consumiendo I/O y buffers, o manteniendo locks, o provocando conflictos en replicación (hot standby de Postgres).
Arreglo: Enrutar reportes a una réplica, añadir timeouts de sentencia, precomputar agregados y crear índices para ese reporte o prohibirlo en horas pico.
7) Síntoma: “El rendimiento empeora con los días, luego mejora temporalmente tras mantenimiento”
Causa raíz: Deuda de vacuum y bloat en Postgres, o churn del buffer pool en MySQL debido a working set cambiante y crecimiento de índices.
Arreglo: Postgres: ajustar autovacuum y analyze; monitorizar tuplas muertas y edad de freeze. MySQL: revisar dimensionamiento del buffer pool, índices faltantes y eliminar índices que amplifiquen escrituras.
8) Síntoma: “Picos de latencia altos durante ráfagas de tráfico”
Causa raíz: Tormentas de conexiones, encolamiento en la base de datos o saturación de I/O del disco.
Arreglo: Poner límites duros a la concurrencia, garantizar pooling, usar retro-presión y medir la profundidad de la cola/await en almacenamiento.
Listas de verificación / plan paso a paso
Checklist de decisión: elegir MySQL o PostgreSQL para un sitio web
- Si tu equipo tiene madurez operacional profunda en MySQL (replicación, upgrades, tuning de índices, restores de backup) y tu carga es OLTP sencilla: elige MySQL y sigue adelante.
- Si esperas consultas complejas, constraints más ricas y quieres semántica SQL sensata con un ecosistema de extensiones: elige Postgres.
- Si no puedes comprometerte con disciplina de pooling de conexiones: MySQL normalmente te perdonará más tiempo, pero sigues acumulando deuda. Arregla el pooling de cualquier modo.
- Si haces muchas actualizaciones/borrados en tablas grandes: Postgres necesita amor de vacuum; MySQL necesita disciplina en índices y purge. Elige según el patrón de mantenimiento que puedas ejecutar con fiabilidad.
- Si dependes mucho de réplicas de lectura: diseña consistencia eventual desde el día uno, independientemente de la base de datos.
Primera semana de configuración: higiene de producción (ambas bases)
- Habilitar visibilidad de consultas: slow query logs (MySQL) o pg_stat_statements (Postgres). Si no puedes ver consultas, depuras a ciegas.
- Establecer timeouts: timeouts de sentencia, lock timeouts y timeouts de transacción apropiados para peticiones web.
- Establecer backup + pruebas de restore: una ruta de restauración scriptada que alguien pueda ejecutar bajo estrés.
- Decidir semántica de replicación: qué endpoints pueden leer de réplicas; hacerla cumplir en código.
- Definir política de migraciones: no reescribir tablas en pico, dividir backfills, updates por lotes.
Flujo de trabajo de performance: cuando una página va lenta
- Encuentra el endpoint y correlaciona con las consultas principales por tiempo total.
- Captura EXPLAIN/ANALYZE (o MySQL EXPLAIN + digest stats).
- Revisa si la consulta está sin índice o usando el índice equivocado.
- Valida recuentos de filas y selectividad (aparece skew).
- Arregla la ruta de acceso (índice o reescritura de consulta) antes de tocar perillas del servidor.
- Solo entonces considera cambios de configuración y hardware.
Plan de cambio de esquema: migraciones seguras sin drama
- Primero aditivo: añadir nuevas columnas nullable, sin defaults, sin constraints inicialmente.
- Backfill por lotes: transacciones pequeñas, sleep entre lotes, medir lag de replicación.
- Escritura dual si es necesario: escribir tanto en el campo viejo como en el nuevo durante el rollout.
- Añadir constraints al final: validar cuidadosamente, preferiblemente online/bajo impacto si se soporta.
- Eliminar campos antiguos solo tras verificación: y solo después de conocer las rutas de rollback.
Preguntas frecuentes
1) ¿Cuál es más rápido para un sitio web típico: MySQL o PostgreSQL?
Ninguno, por defecto. La base de datos más rápida es la que tiene los índices correctos, consultas sensatas y no problemas autoinfligidos de replicación/locks. Para OLTP simple, ambos son rápidos. Para consultas complejas, Postgres suele ganar—hasta que descuidas stats y vacuum.
2) ¿Cuál es “más fiable”?
La fiabilidad es una propiedad operacional: backups, restores probados, monitoring y migraciones seguras. Ambos pueden operar con fiabilidad. Postgres tiende a ser más estricto y consistente en comportamiento; MySQL suele perdonar usos descuidados hasta que deja de hacerlo.
3) ¿Necesito un pooler de conexiones con PostgreSQL?
Para apps web con tráfico en ráfagas: sí, prácticamente siempre. Sin pooler, eventualmente tendrás tormentas de conexiones, sobrecarga de memoria y mala latencia en la cola. Con un pooler, Postgres se comporta mucho más calmado bajo carga.
4) ¿Es el vacuum de Postgres un motivo para descartarlo?
No, pero es una responsabilidad. Si tus datos son mayormente inserts con pocas actualizaciones/borrados, el vacuum es sencillo. Si haces updates de alto churn en tablas grandes, debes monitorizar y ajustar autovacuum o particionar. Si tu equipo no hará eso, estás apostando contra la física.
5) ¿Son seguras las réplicas de lectura para “lecturas normales”?
Seguras para lecturas no críticas, sí—si aceptas consistencia eventual. Inseguras para workflows read-after-write a menos que implementes stickiness o replicación síncrona en esos caminos de código.
6) ¿Cuál maneja JSON mejor para un sitio web?
JSONB de Postgres es típicamente la mejor herramienta cuando necesitas indexado y consultas sobre campos JSON. JSON de MySQL funciona, pero los equipos suelen derivar hacia almacenar demasiados datos semiestructurados sin guardarraíles. Si necesitas JSON como estructura consultable de primera clase, Postgres es la apuesta más segura.
7) ¿Cuál es la causa número uno de incidentes “la base de datos está lenta”?
Caminos de acceso malos: índices faltantes o equivocadas y consultas que accidentalmente escanean. El segundo lugar lo ocupa la contención por locks de transacciones largas. El hardware rara vez es la primera causa, aunque a menudo se le culpa primero.
8) Si ya ejecutamos una base de datos, ¿deberíamos cambiar?
Usualmente no. Cambiar de base de datos es caro y arriesgado. En su lugar, arregla el cuello de botella real: indexado, patrones de consulta, diseño de replicación, backups y mantenimiento. Cambia solo si una capacidad específica o un modo de fallo operativo te está matando repetidamente.
9) ¿Cómo elegir para un sitio de e-commerce específicamente?
Elige la que tu equipo pueda operar sin heroísmos. E-commerce necesita corrección (inventario, pagos) y latencia predecible. Postgres es un buen por defecto si puedes manejar pooling y vacuum. MySQL es excelente si ya tienes ops maduros en MySQL y mantienes transacciones e índices disciplinados.
10) ¿Qué debería monitorizar desde el primer día?
Distribución de latencia de consultas (p95/p99), consultas principales por tiempo total, esperas de lock, lag de replicación, conteos de conexiones, crecimiento de espacio en disco y latencia de almacenamiento (await). Estos hallan cuellos de botella antes que los clientes lo hagan.
Próximos pasos que puedes hacer esta semana
- Elige la base de datos según los cuellos de botella que puedas manejar, no por ideología. Si dudas, por defecto usa Postgres para nuevos desarrollos.
- Instrumenta visibilidad de consultas (digests, slow logs, pg_stat_statements). Sin visibilidad, no hay verdad.
- Implementa disciplina de conexiones: poolers para Postgres, tamaños de pool sensatos para ambos.
- Documenta semántica de replicación: qué endpoints pueden tolerar lecturas desfasadas; hazla cumplir en el código.
- Programa un simulacro de restauración y ejecútalo. Si esto te incomoda, ahí está tu riesgo real.
- Construye una rutina de “consultas principales”: revisión semanal de los mayores consumidores de tiempo y de los principales contribuyentes a esperas por locks.
Si haces eso, dejarás de tratar la base de datos como una caja misteriosa y empezarás a tratarla como lo que es: una máquina predecible que castiga el pensamiento descuidado y recompensa la competencia aburrida.