Hay dos tipos de ingenieros: los que han tirado producción con un ALTER TABLE, y los que aún no… todavía. Los cambios de esquema parecen inocentes en un ticket. Luego suena el pager porque tu cambio “rápido” está esperando un bloqueo sostenido por una transacción de larga duración iniciada por una herramienta de BI que olvidó salir educadamente.
MySQL y PostgreSQL permiten mutar la realidad in situ. Simplemente te cobran en monedas distintas: MySQL adora los bloqueos de metadata y los casos límite del DDL en línea; PostgreSQL adora la pureza transaccional y la ocasional reescritura de tabla que convierte tu disco en un calefactor. Si operas cualquiera a escala, la pregunta no es “¿podemos ejecutar ALTER TABLE?” sino “¿qué exactamente bloqueará, reescribirá, replicará y nos sorprenderá a las 2 a.m.?”
Qué significa realmente “pesadilla de ALTER TABLE” en producción
Un cambio de esquema se convierte en pesadilla cuando viola una de tres verdades de producción:
- Los presupuestos de latencia son reales. Cualquier cosa que bloquee escrituras por segundos se traducirá en timeouts, reintentos, acumulación de colas y a veces fallos en cascada.
- La replicación es parte de tu camino de servicio. Incluso si “no lees desde réplicas”, el lag puede romper failovers, backups, analytics y cualquier mecanismo de seguridad que dependa de réplicas actualizadas.
- El almacenamiento no es infinito y el I/O no es gratis. Reescribir una tabla de 2 TB no solo es lento: puede llenar discos, agotar caches de búfer y desencadenar tormentas de autovacuum o purge.
En la práctica, el dolor de los cambios de esquema se concentra en:
- Bloqueos que bloquean el tráfico (bloqueos explícitos, implícitos y el tipo especialmente picante: los bloqueos de metadata en MySQL).
- Reescrituras de tablas (comunes en PostgreSQL; posibles en MySQL según el motor y el modo DDL).
- Comportamiento de replicación (implicaciones de statement-based vs row-based, serialización de DDL, retraso en aplicación en réplicas).
- Transacciones largas (ambas bases las detestan, solo que de forma distinta).
- Expectativas de las herramientas (alguien asume que “online” significa “sin impacto”, y la producción enseña humildad).
Una cita que vale la pena tener en una nota adhesiva:
“paraphrased idea” — Werner Vogels: construyes fiabilidad asumiendo que las cosas fallarán y diseñando para recuperarte rápido.
Los cambios de esquema son trabajo de fiabilidad. Trátalos como despliegues, no como editar una columna en una hoja de cálculo.
Algunos hechos históricos que explican el comportamiento actual
Esto no es trivia; es la razón por la que tu DDL se comporta así.
- PostgreSQL eligió DDL transaccional desde temprano: el DDL participa en transacciones, por lo que puede revertirse. Genial para la corrección, pero también significa que los bloqueos pueden sostenerse hasta el commit.
- El “DDL en línea” de MySQL es en su mayoría una historia de InnoDB: el comportamiento depende de las capacidades del motor de almacenamiento, y en la historia MyISAM bloqueaba felizmente todo.
- InnoDB introdujo “Fast Index Creation” (FIC) para reducir el costo de reconstrucción, pero la letra pequeña importa: algunos alters aún reconstruyen la tabla.
- PostgreSQL 9.2+ hizo práctico CREATE INDEX CONCURRENTLY como herramienta operativa, con tradeoffs: lleva más tiempo y puede fallar si no lo vigilas.
- PostgreSQL 11 mejoró “add column with default” evitando una reescritura completa en muchos casos para valores por defecto constantes — una de las mayores mejoras operativas.
- MySQL 5.6/5.7 ampliaron las opciones de DDL en línea con las cláusulas
ALGORITHMyLOCK, pero son más una negociación que una garantía. - MySQL 8.0 introdujo “instant ADD COLUMN” para algunos casos, pero en el momento en que tocas ciertos atributos o el orden de columnas, vuelves a operaciones más pesadas.
- MVCC de PostgreSQL significa que versiones antiguas de filas permanecen hasta que vacuum limpia; actualizaciones masivas durante cambios de esquema pueden crear una sacudida de vacuum/I/O.
- La replicación de MySQL históricamente aplicaba DDL en un solo hilo (y aun con replicación paralela, el DDL tiene restricciones de serialización), haciendo que “rápido en el primario” a veces sea “lento en todas partes”.
Broma #1: Llamar a un ALTER “online” porque no toma un bloqueo exclusivo es como llamar a un oso “amigable” porque aún no te ha mordido.
MySQL: DDL en línea, bloqueos de metadata y la ilusión de “instantáneo”
Problema central de MySQL: a los bloqueos de metadata no les importa tu intención
En MySQL, gran parte del dolor del ALTER no es el trabajo en sí: es MDL (metadata locks). Cualquier DDL necesita bloqueos de metadata, y esos bloqueos interactúan con consultas en curso de formas que sorprenden a los equipos que equiparan “DDL en línea” con “sin bloqueo”.
El modo de fallo clásico se ve así:
- Inicias
ALTER TABLE. - Espera en un MDL porque alguna sesión está leyendo la tabla.
- Nuevas consultas se acumulan detrás del ALTER en espera porque quieren bloqueos conflictivos.
- Tu app se derrite aunque “el ALTER no haya empezado”.
Cláusulas ALGORITHM y LOCK: útiles, pero no un campo de fuerza
MySQL te permite pedir un algoritmo DDL y comportamiento de bloqueo. Ejemplo:
ALGORITHM=INSTANTpara cambios sólo de metadata cuando están soportados.ALGORITHM=INPLACEpara evitar copia de tabla cuando es posible.LOCK=NONEoLOCK=SHAREDpara reducir el bloqueo.
El truco operativo: siempre especificarlos cuando tengas requisitos estrictos de disponibilidad, porque convierten sorpresas en errores inmediatos. Un error es un regalo. Te dice: “Hoy no, no así”.
Lo que MySQL hace bien
- Algunos cambios pueden ser verdaderamente instantáneos (p. ej., algunos ADD COLUMN en 8.0), lo cual es maravilloso cuando funciona.
- Ecosistema de herramientas para cambios de esquema en línea es maduro:
pt-online-schema-change,gh-osty variantes internas son comunes. - InnoDB puede hacer muchas operaciones de índice de forma “algo online”, con impacto manejable si entiendes las restricciones.
Dónde duele MySQL
- Las acumulaciones de MDL son catastróficas: parecen síntomas de “base de datos caída” de repente aunque el servidor esté sano.
- El lag de replicación puede convertirse en tu outage oculto: el DDL puede bloquear la aplicación o entrar en conflicto con el paralelismo.
- “Online” aún consume I/O y CPU: construir un índice secundario en una tabla grande competirá con la carga de producción. La ausencia de un gran bloqueo no implica ausencia de dolor.
PostgreSQL: DDL transaccional, reescrituras y consecuencias en forma de vacuum
Problema central de PostgreSQL: las reescrituras son honestas, brutales y a veces inevitables
PostgreSQL es consistente: si un cambio de esquema requiere tocar cada fila, hará exactamente eso. Y lo hará bajo semántica transaccional. Eso significa:
- Las reescrituras de tabla pueden ser enormes (tiempo + I/O + espacio adicional en disco).
- Los bloqueos pueden ser fuertes (algunas operaciones requieren
ACCESS EXCLUSIVE). - Las transacciones largas lo empeoran todo, porque impiden la limpieza de tuplas antiguas y pueden mantener bloqueos más tiempo del que piensas.
Súperpoder operativo de PostgreSQL: primitivas de herramienta predecibles
Postgres te da bloques de construcción que se comportan de manera predecible:
CREATE INDEX CONCURRENTLYevita bloqueos pesados (a costa de mayor tiempo de ejecución y más sensibilidad a fallos).ALTER TABLE ... ADD COLUMNsuele ser barato, y desde v11 añadir un default constante a menudo evita reescribir toda la tabla.- Introspección rica vía los catálogos del sistema y
pg_stat_activity, haciendo que “¿qué bloquea qué?” sea una pregunta respondible.
Dónde duele Postgres
- Algunos DDL aún requieren un ACCESS EXCLUSIVE, que bloquea lecturas y escrituras. La lista no es corta: cambios de tipo de columna, algunas validaciones de constraints, algunas reescrituras de tablas.
- Efectos secundarios de MVCC: cambios que tocan muchas filas pueden generar bloat; el vacuum luego compite por I/O. Tu migración puede “terminar” y el dolor comenzar después.
- Replicación en standbys físicos: alta escritura durante una reescritura significa volumen WAL, lo que implica lag en standbys y presión en almacenamiento.
Broma #2: PostgreSQL te dejará hacer casi cualquier cosa en una transacción; solo que ocasionalmente la transacción es “reconstruir el universo”.
¿Quién lo empeora? Veredicto práctico por tipo de cambio
1) Añadir una columna nullable
Por lo general: ambos están bien.
- MySQL 8.0: a menudo
ALGORITHM=INSTANTsi no fuerzas cambios en el orden de columnas y te mantienes dentro de patrones soportados. - PostgreSQL: cambio solo de metadata, rápido.
Riesgo de pesadilla: bajo, a menos que desencadenes acumulaciones de MDL (MySQL) o tomes bloqueos fuertes al agrupar varias operaciones (Postgres).
2) Añadir una columna con default
PostgreSQL mejoró dramáticamente en v11. Para defaults constantes, puede almacenar el default en metadata y aplicarlo en tiempo de lectura, evitando una reescritura completa en muchos casos.
MySQL: aún puede requerir reconstrucción de tabla dependiendo del cambio exacto y la versión/engine. “Default” rara vez es el único cambio; la gente introduce NOT NULL y reordenamientos y luego se pregunta por qué la tabla se copia.
Veredicto: Postgres moderno suele ganar para defaults constantes. MySQL puede ganar si es instantáneo; también puede perder espectacularmente si reconstruye.
3) Añadir un índice
Ninguno es gratis. Vas a leer mucha data y escribir mucha estructura.
- MySQL: la creación de índice puede ser “online” pero aún consume I/O/CPU; los bloqueos de metadata siguen importando, y la aplicación en réplicas puede retrasarse.
- PostgreSQL:
CREATE INDEX CONCURRENTLYes la opción para mantener uptime, con la advertencia operativa de que puede tardar más y no se puede ejecutar dentro de un bloque de transacción.
Veredicto: Postgres gana en evitación de bloqueos con CONCURRENTLY. MySQL gana cuando la carga es tolerante y puedes programar la operación—además tiene herramientas externas maduras para cambios en línea.
4) Cambiar el tipo de una columna
Aquí es donde te ganas el sueldo.
- PostgreSQL: puede reescribir la tabla y con frecuencia requiere
ACCESS EXCLUSIVE. Hay trucos (añadir nueva columna, backfill, swap) que evitan bloqueos exclusivos largos. - MySQL: a menudo reconstruye la tabla; puede ser bloqueado por MDL; el soporte “inplace” varía.
Veredicto: ambos pueden ser pesadillas. Postgres es más transparente sobre la reescritura; MySQL es más propenso a sorprenderte con MDL y síntomas de “¿por qué todo está atascado?”.
5) Añadir una clave foránea
Operativamente riesgoso en ambos. La base de datos debe validar datos existentes, lo que puede implicar escanear tablas y tomar bloqueos.
- PostgreSQL: puedes añadir la constraint
NOT VALIDy validar después para gestionar el tiempo de bloqueo. Eso es una característica real para producción, no un truco. - MySQL: la validación y el comportamiento de bloqueo dependen del motor/versión; podrías terminar con bloqueos largos o I/O intensivo.
Veredicto: Postgres generalmente te da un enfoque por etapas más seguro. MySQL puede ir bien pero tiene menos primitivas para “validación diferida”.
6) Eliminar una columna
Ambos son peligrosamente engañosos. No porque borrar sea difícil, sino porque las aplicaciones y los ORMs son excelentes en equivocarse de maneras nuevas.
Veredicto: la base de datos rara vez es la pesadilla; la coreografía del deploy lo es. Hazlo en fases.
Guía rápida de diagnóstico
Esta es la secuencia “iniciamos un ALTER y ahora todo está en llamas”. El objetivo es identificar si estás bloqueado por bloqueos, saturado de I/O/CPU o ahogado por lag de replicación—rápido.
Primero: ¿está bloqueado por locks o esperando metadata?
- MySQL: revisa processlist y esperas de metadata lock; busca sesiones en “Waiting for table metadata lock.”
- PostgreSQL: revisa
pg_stat_activityypg_lockspor cadenas de bloqueo y modos comoAccessExclusiveLock.
Si encuentras bloqueo: decide si matar al que bloquea, cancelar el DDL o esperar. Matar la sesión equivocada puede ser peor que la migración.
Segundo: ¿están saturados los recursos del servidor?
- CPU al máximo: construcción de índices, validación de constraints o triggers pesados.
- I/O al máximo: reescritura de tabla, creación de índices, vacuum, actividad de fondo de InnoDB.
- Presión de memoria: churn del buffer pool, ordenaciones que escriben en disco.
Si está saturado: limita, reprograma o muévete a herramientas en línea que copian en trozos más pequeños.
Tercero: ¿el lag de replicación se está convirtiendo en un segundo outage?
- MySQL: monitorea el delay de aplicación en réplicas y si el hilo SQL está bloqueado por DDL.
- PostgreSQL: monitorea generación de WAL y replay lag; el DDL que reescribe genera mucho WAL.
Si el lag es crítico: pausa el cambio, reduce la tasa de escrituras o acepta que no podrás hacer failover seguro hasta que el lag se recupere.
Tareas prácticas: comandos, salidas y decisiones (14)
Estos son sondeos prácticos que puedes ejecutar durante la planificación o respuesta a incidentes. Cada tarea incluye el comando, lo que significa la salida y la decisión que impulsa.
Task 1 (MySQL): ver quién está esperando locks de metadata
cr0x@server:~$ mysql -uroot -p -e "SHOW FULL PROCESSLIST\G"
*************************** 1. row ***************************
Id: 12345
User: app
Host: 10.0.2.15:53412
db: prod
Command: Query
Time: 120
State: Waiting for table metadata lock
Info: ALTER TABLE orders ADD COLUMN promo_code varchar(32)
*************************** 2. row ***************************
Id: 12201
User: bi
Host: 10.0.9.20:41233
db: prod
Command: Query
Time: 980
State: Sending data
Info: SELECT * FROM orders WHERE created_at > NOW() - INTERVAL 30 DAY
Significado: El ALTER está bloqueado por una consulta en curso que mantiene un lock de metadata conflictivo.
Decisión: Si la disponibilidad es más importante que esa consulta de BI, mata el que bloquea (o cancela el ALTER). De lo contrario, programa el DDL en una ventana o aplica timeouts a las consultas.
Task 2 (MySQL): identificar esperas de locks de InnoDB (locks de fila, no MDL)
cr0x@server:~$ mysql -uroot -p -e "SHOW ENGINE INNODB STATUS\G" | sed -n '1,160p'
=====================================
2025-12-29 10:18:07 0x7f9a2c1a0700 INNODB MONITOR OUTPUT
=====================================
------------
TRANSACTIONS
------------
Trx id counter 92839110
Purge done for trx's n:o < 92839000 undo n:o < 0 state: running
History list length 2341
...
LATEST DETECTED DEADLOCK
...
Significado: Muestra presión transaccional, history list length (purge lag), deadlocks y esperas de locks.
Decisión: Si la history list length crece rápido durante DDL + backfill, ralentiza el backfill y acorta las transacciones.
Task 3 (MySQL): comprobar si tu ALTER puede ser INSTANT/INPLACE
cr0x@server:~$ mysql -uroot -p -e "ALTER TABLE orders ADD COLUMN promo_code varchar(32), ALGORITHM=INSTANT, LOCK=NONE;"
ERROR 1845 (0A000) at line 1: ALGORITHM=INSTANT is not supported for this operation. Try ALGORITHM=INPLACE.
Significado: MySQL rechazó INSTANT; podría seguir siendo INPLACE, o podría necesitar COPY (reconstrucción de tabla).
Decisión: No dejes que “elija por sí mismo”. Reintenta con ALGORITHM=INPLACE, LOCK=NONE y acepta el fallo si no puede cumplir—luego pasa a herramientas de cambio en línea.
Task 4 (MySQL): verificar lag de réplicas y si el hilo SQL está atascado en DDL
cr0x@server:~$ mysql -uroot -p -e "SHOW SLAVE STATUS\G" | egrep "Seconds_Behind_Master|Slave_SQL_Running_State|Slave_IO_Running|Slave_SQL_Running"
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
Slave_SQL_Running_State: Waiting for dependent transaction to commit
Seconds_Behind_Master: 187
Significado: La réplica está con lag; el hilo SQL de aplicación espera, a menudo detrás de una transacción grande o serialización de DDL.
Decisión: Si el failover forma parte de tu plan de seguridad, detén la migración o limita al escritor hasta que el lag se recupere.
Task 5 (MySQL): comprobar el tamaño de tabla antes de un ALTER que pueda reconstruir
cr0x@server:~$ mysql -uroot -p -e "SELECT table_name, ROUND((data_length+index_length)/1024/1024/1024,2) AS size_gb FROM information_schema.tables WHERE table_schema='prod' AND table_name='orders';"
+------------+---------+
| table_name | size_gb |
+------------+---------+
| orders | 612.84 |
+------------+---------+
Significado: Estás a punto de tocar un objeto ~613 GB. Las reconstrucciones tomarán tiempo y espacio, y golpearán el I/O.
Decisión: Trátalo como un proyecto: considera gh-ost/pt-osc, asegúrate de espacio en disco y coordina un plan de throttling.
Task 6 (PostgreSQL): ver consultas activas y si tu DDL está esperando
cr0x@server:~$ psql -d prod -c "SELECT pid, state, wait_event_type, wait_event, now()-query_start AS age, left(query,80) AS query FROM pg_stat_activity WHERE datname='prod' ORDER BY age DESC LIMIT 10;"
pid | state | wait_event_type | wait_event | age | query
-------+--------+-----------------+--------------------+---------+--------------------------------------------------------------------------------
81231 | active | Lock | relation | 00:02:01| ALTER TABLE orders ALTER COLUMN total TYPE numeric(12,2)
80910 | active | | | 00:18:22| SELECT * FROM orders WHERE created_at > now() - interval '30 days'
Significado: El ALTER está esperando un bloqueo; otra consulta está activa lo suficiente como para resultar sospechosa.
Decisión: Encuentra los bloqueadores, luego cancela los bloqueadores o cancela la migración y reprograma con una estrategia más segura (dual-write, backfill, swap).
Task 7 (PostgreSQL): encontrar la cadena de bloqueo
cr0x@server:~$ psql -d prod -c "SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid, blocked.query AS blocked_query, blocking.query AS blocking_query FROM pg_locks blocked JOIN pg_stat_activity blocked_activity ON blocked.pid = blocked_activity.pid JOIN pg_locks blocking ON blocking.locktype = blocked.locktype AND blocking.database IS NOT DISTINCT FROM blocked.database AND blocking.relation IS NOT DISTINCT FROM blocked.relation AND blocking.page IS NOT DISTINCT FROM blocked.page AND blocking.tuple IS NOT DISTINCT FROM blocked.tuple AND blocking.virtualxid IS NOT DISTINCT FROM blocked.virtualxid AND blocking.transactionid IS NOT DISTINCT FROM blocked.transactionid AND blocking.classid IS NOT DISTINCT FROM blocked.classid AND blocking.objid IS NOT DISTINCT FROM blocked.objid AND blocking.objsubid IS NOT DISTINCT FROM blocked.objsubid AND blocking.pid != blocked.pid JOIN pg_stat_activity blocking_activity ON blocking.pid = blocking_activity.pid WHERE NOT blocked.granted AND blocking.granted LIMIT 5;"
blocked_pid | blocking_pid | blocked_query | blocking_query
------------+--------------+--------------------------------------------------+-----------------------------------------------
81231 | 80910 | ALTER TABLE orders ALTER COLUMN total TYPE ... | SELECT * FROM orders WHERE created_at > ...
Significado: Tienes un bloqueador directo. A menudo es una consulta de lectura que asumiste “no bloquea cambios de esquema”. Lo hace.
Decisión: Cancela la consulta bloqueadora si es seguro (pg_cancel_backend), o termínala si está colgada (pg_terminate_backend)—pero comprende el impacto en la aplicación.
Task 8 (PostgreSQL): estimar tamaño de tabla y si la reescritura da miedo
cr0x@server:~$ psql -d prod -c "SELECT pg_size_pretty(pg_total_relation_size('public.orders')) AS total_size, pg_size_pretty(pg_relation_size('public.orders')) AS heap_size;"
total_size | heap_size
------------+----------
835 GB | 612 GB
Significado: Reescribir el heap podría significar cientos de GB de nuevas escrituras, más WAL, más requisitos de espacio libre.
Decisión: No intentes una reescritura durante el pico. Usa un enfoque expandir/backfill/contract a menos que puedas tolerar bloqueos exclusivos largos y I/O intenso.
Task 9 (PostgreSQL): comprobar si añadir un default reescribirá en tu versión
cr0x@server:~$ psql -d prod -c "SHOW server_version;"
server_version
----------------
14.10
Significado: En v11+, existe la optimización de default constante; en versiones anteriores, ADD COLUMN DEFAULT podría reescribir la tabla.
Decisión: En Postgres antiguo, evita añadir un default directamente; añade la columna nullable, backfill en lotes, luego establece el default para filas futuras.
Task 10 (PostgreSQL): crear un índice sin bloquear escrituras
cr0x@server:~$ psql -d prod -c "CREATE INDEX CONCURRENTLY idx_orders_created_at ON public.orders(created_at);"
CREATE INDEX
Significado: El índice se construyó sin tomar el bloqueo más pesado. Probablemente tardó más que una creación normal.
Decisión: Prefiere esto en producción. Monitorea el progreso y prepárate para reintentar si falla por conflictos o cancelaciones.
Task 11 (PostgreSQL): vigilar el progreso de creación de índices
cr0x@server:~$ psql -d prod -c "SELECT pid, relid::regclass AS table_name, index_relid::regclass AS index_name, phase, blocks_done, blocks_total FROM pg_stat_progress_create_index;"
pid | table_name | index_name | phase | blocks_done | blocks_total
-------+------------+---------------------------+----------+-------------+-------------
82110 | orders | idx_orders_created_at | building | 120331 | 612840
Significado: La vista de progreso muestra dónde estás; blocks_done/blocks_total da una aproximación de finalización.
Decisión: Si es demasiado lento o está agotando el sistema, considera limitar a nivel de infraestructura (límites de I/O) o reprogramar.
Task 12 (PostgreSQL): medir lag de replay de replicación durante DDL/backfill intensivo
cr0x@server:~$ psql -d prod -c "SELECT application_name, state, pg_size_pretty(pg_wal_lsn_diff(sent_lsn, replay_lsn)) AS byte_lag FROM pg_stat_replication;"
application_name | state | byte_lag
------------------+-----------+----------
standby-a | streaming | 3.1 GB
Significado: Tu standby está varios GB detrás en términos de WAL; eso puede ser minutos o horas, dependiendo del throughput.
Decisión: Si necesitas un failover caliente, detén o ralentiza la operación hasta que el lag sea aceptable.
Task 13 (MySQL): usar pt-online-schema-change como alternativa más segura
cr0x@server:~$ pt-online-schema-change --alter "ADD COLUMN promo_code varchar(32)" D=prod,t=orders --execute
No slaves found. See --recursion-method if host has slaves.
Not checking slave lag because no slaves were found and --check-slave-lag was not specified.
Creating new table...
Created new table prod._orders_new OK.
Altering new table...
Altered `prod`.`_orders_new` OK.
Creating triggers...
Created triggers OK.
Copying rows...
Copying `prod`.`orders`: 12% 00:03 remain
Significado: La herramienta crea una tabla sombra, copia filas y usa triggers para mantenerla sincronizada. Esto evita bloqueos largos en la tabla original a cambio de carga y complejidad extra.
Decisión: Úsala cuando un ALTER directo arriesgue bloquear o reconstruir. Asegúrate de entender triggers, replicación y comportamiento de cutover.
Task 14 (MySQL): detectar contención de MDL desde performance_schema
cr0x@server:~$ mysql -uroot -p -e "SELECT OBJECT_SCHEMA, OBJECT_NAME, LOCK_TYPE, LOCK_STATUS, THREAD_ID FROM performance_schema.metadata_locks WHERE OBJECT_SCHEMA='prod' AND OBJECT_NAME='orders' LIMIT 5;"
+--------------+-------------+-----------+------------+-----------+
| OBJECT_SCHEMA| OBJECT_NAME | LOCK_TYPE | LOCK_STATUS| THREAD_ID |
+--------------+-------------+-----------+------------+-----------+
| prod | orders | SHARED_READ | GRANTED | 88121 |
| prod | orders | EXCLUSIVE | PENDING | 88210 |
+--------------+-------------+-----------+------------+-----------+
Significado: Hay un lock exclusivo pendiente (tu DDL) atascado detrás de locks compartidos concedidos (lecturas).
Decisión: Mata o drena lectores largos, aplica tiempo máximo de ejecución, o mueve cambios de esquema a una ventana de mantenimiento dedicada.
Tres mini-historias corporativas desde las trincheras
Mini-historia 1: el incidente causado por una suposición errónea
En una empresa de tamaño medio tipo marketplace, el equipo programó una creación de índice “online” en MySQL. El cambio fue aprobado por un desarrollador senior que recordaba que InnoDB puede construir índices sin bloquear escrituras. Todos se fueron a casa.
Diez minutos después, la tasa de errores de la API se disparó. La latencia se volvió vertical. La CPU de MySQL estaba bien. El disco ni siquiera estaba tan ocupado. Los gráficos parecían un incidente de red, lo que es indicador de que en realidad era un incidente de base de datos.
El culpable fue la contención de metadata lock. Una consulta de reporting de larga duración sostenía un lock de metadata compartido en la tabla. El ALTER TABLE ... ADD INDEX esperó por un lock exclusivo de metadata. Una vez que quedó en espera, las solicitudes entrantes empezaron a hacer cola detrás de él, porque sus peticiones de metadata lock no podían ser concedidas de manera que no se perjudicara al lock exclusivo pendiente. La tabla no solo se ralentizó; prácticamente dejó de aceptar trabajo nuevo.
La suposición errónea fue “las lecturas no bloquean cambios de esquema”. En MySQL, pueden hacerlo. La solución no fue heroica: mataron la sesión de reporting, el DDL se ejecutó y el tráfico se recuperó. La ganancia real vino después: timeouts para consultas de reporting, mover BI a réplicas con guardrails y un paso de preflight que comprueba consultas de larga duración en tablas objetivo.
Mini-historia 2: la optimización que salió mal
Un equipo fintech en PostgreSQL quiso reducir latencia cambiando una columna de dinero de numeric a bigint de céntimos. Es una optimización sensata: la aritmética entera es más barata, los índices son más pequeños y evitas casos raros con decimales.
Intentaron el camino directo: ALTER TABLE ... ALTER COLUMN ... TYPE bigint USING .... En staging terminó rápido. En producción tomó un ACCESS EXCLUSIVE y empezó a reescribir una tabla grande y caliente.
La app no cayó inmediatamente. Se quedó atascada. Las conexiones se acumularon. Porque el cambio estaba en una transacción (como Postgres fomenta), el bloqueo persistió hasta el commit. Mientras tanto, el volumen de WAL se disparó, las réplicas se retrasaron y el equipo perdió su red de seguridad: ya no podían hacer failover sin perder un trozo de escrituras reconocidas.
La “optimización” era correcta en principio y desastrosa en la ejecución. La solución final fue una migración escalonada: añadir una nueva columna, escribir en ambas a nivel de aplicación, backfill en pequeños lotes, validar, cambiar lecturas y luego eliminar la columna antigua. El beneficio de rendimiento llegó—solo que no como un único ALTER heroico.
Mini-historia 3: la práctica aburrida pero correcta que salvó el día
Un equipo SaaS empresarial tenía un runbook de migración estricto: cada cambio de esquema requería un preflight, un canario en réplica, ajustes explícitos de lock/algorithm (para MySQL) o constraints por etapas (para Postgres), y un plan de rollback que no implicara restaurar desde backup.
Necesitaban añadir una foreign key y poner una columna NOT NULL en PostgreSQL. Ese es exactamente el tipo de cambio que puede desencadenar una fiesta de bloqueos. En lugar de hacerlo directamente, añadieron la foreign key como NOT VALID, limpiaron filas huérfanas con un job controlado y luego validaron en un periodo de baja carga.
Para el cambio a NOT NULL, primero añadieron un CHECK (col IS NOT NULL) NOT VALID, rellenaron nulls en lotes, validaron el check y solo entonces hicieron SET NOT NULL en una operación rápida.
No pasó nada emocionante. Ningún incidente. Ningún aplauso. Pero las mejores migraciones son las de las que nadie se entera, incluyendo a ti.
Errores comunes: síntoma → causa raíz → solución
1) Síntoma: tráfico MySQL se detiene de repente; CPU baja; muchas conexiones “waiting”
Causa raíz: acumulación de MDL. Un ALTER está esperando un metadata lock; nuevas consultas hacen cola detrás de él.
Solución: identifica bloqueadores, mata/limita lectores de larga duración y ejecuta DDL con cláusulas ALGORITHM/LOCK. Usa herramientas de cambio en línea para tablas calientes.
2) Síntoma: ALTER TABLE en PostgreSQL aparece “atascado” y bloquea todo en la tabla
Causa raíz: ACCESS EXCLUSIVE esperando a una transacción o consulta de larga duración.
Solución: encuentra el bloqueador con pg_locks/pg_stat_activity, cancélalo o reprograma. Para cambios de tipo, evita el ALTER directo; usa expand/backfill/contract.
3) Síntoma: uso de disco se dispara durante migración en Postgres, luego réplicas se quedan atrás
Causa raíz: reescritura de tabla genera muchas escrituras en heap y WAL; la réplica no puede reproducirlo al mismo ritmo.
Solución: migración por etapas, throttling del backfill, monitorizar WAL lag, asegurar espacio en disco y considerar relajar temporalmente expectativas de failover.
4) Síntoma: lag de réplicas MySQL crece tras creación de índice “online”
Causa raíz: DDL y/o transacciones enormes serializan la aplicación de replicación; la construcción de índice compite con el hilo de apply.
Solución: monitorea Seconds_Behind_Master y estado SQL. Usa herramientas de cambio con throttling; reduce la tasa de escrituras; programa en baja carga.
5) Síntoma: creación CONCURRENTLY en Postgres falla a mitad
Causa raíz: CREATE INDEX CONCURRENTLY es más frágil: puede fallar por cancelaciones, tuplas muertas o conflictos; también deja índices inválidos.
Solución: busca índices inválidos y elimina/crea de nuevo. Ejecútalo con supervisión operativa y timeouts; evita hacerlo en periodos inestables.
6) Síntoma: “ALTER succeeded” pero la app sigue fallando
Causa raíz: descoordinación del deploy: la app espera una columna no presente / ya eliminada; ORMs cachean esquema; statements preparados.
Solución: despliegues en dos fases: primero expande el esquema, despliega código que lo use opcionalmente, luego contrae. Mantén compatibilidad entre versiones de la app.
7) Síntoma: la migración provoca fuerte ralentización semanas después
Causa raíz: bloat/vacuum debt en Postgres o change buffer/purge debt en InnoDB por backfills grandes.
Solución: backfills por lotes, ajustar vacuum, monitorizar bloat y history list length, y programar mantenimiento post-migración.
Listas de verificación / plan paso a paso
Plan A: expandir → backfill → switch → contraer (funciona en ambos)
- Expandir: añade nuevas columnas/tablas/índices de forma segura en línea (columnas nullable, índices concurrentes, herramientas de tabla sombra).
- Dual-write: si cambias semántica (cambio de tipo, desnormalización), que la app escriba ambos campos.
- Backfill: rellena filas históricas en lotes con transacciones pequeñas; duerme entre lotes; monitoriza lag y bloqueos.
- Validar: constraints en Postgres usando
NOT VALIDy luegoVALIDATE; en MySQL valida mediante consultas y/o chequeos en la app. - Cambiar lecturas: redirige lecturas a la nueva columna detrás de un feature flag; vigila tasas de error.
- Contraer: elimina columnas/índices antiguos tras un ciclo completo de deploy y confirmar que no hay lectores.
Lista específica para MySQL
- Siempre especifica
ALGORITHMyLOCK. Si MySQL no puede cumplir, que falle. - Antes de ejecutar DDL, comprueba consultas de larga duración en la tabla y mátalas o deságualas.
- Asegura espacio en disco para reconstrucciones incluso si “esperas inplace”. Espera equivocarte al menos una vez.
- Decide tu postura de replicación: ¿puedes tolerar lag? Si no, aplica throttling o usa herramientas con chequeos de lag en réplicas.
- Prefiere gh-ost/pt-osc para tablas grandes y calientes donde el riesgo de MDL es inaceptable.
Lista específica para PostgreSQL
- Asume que cambios de tipo y algunas operaciones de constraints requerirán bloqueos fuertes o reescrituras.
- Usa
CREATE INDEX CONCURRENTLYpara escrituras en producción; monitoriza progreso y planifica recuperación ante fallos. - Usa
NOT VALIDpara foreign keys/check constraints y valida más tarde. - Vigila volumen de WAL y replay lag en standbys; trátalo como parte de la disponibilidad.
- Mantén transacciones cortas durante backfills; las transacciones largas sabotean vacuum y gestión de locks.
¿“Lo ejecutamos ahora”?
- Verde: la operación es sólo de metadata o concurrente/instantánea, el tamaño de la tabla es manejable, no hay transacciones largas, y el lag de réplica es bajo.
- Amarillo: la operación es pesada pero se puede throttlear; tienes plan de reversión; puedes aceptar lag temporal.
- Rojo: requiere reescritura + bloqueos fuertes en pico; la replicación ya está con lag; el espacio en disco es justo; no tienes camino de rollback seguro.
Preguntas frecuentes
1) ¿Es PostgreSQL mejor que MySQL para cambios de esquema?
Postgres suele ser más predecible: normalmente puedes razonar a partir de los modos de bloqueo y si una operación reescribe. MySQL puede ser más suave para algunas operaciones “instantáneas”, pero la contención de MDL puede crear outages súbitos.
2) ¿Cuál es el comportamiento DDL de MySQL más peligroso?
Acumulaciones de metadata lock. Un DDL en espera puede provocar que nuevas consultas se pongan en cola detrás, convirtiendo un problema leve en un bloqueo total.
3) ¿Cuál es el comportamiento DDL de PostgreSQL más peligroso?
Disparar accidentalmente una reescritura de tabla bajo un ACCESS EXCLUSIVE en una tabla caliente. Bloquea y escribe mucho—lo peor de ambos mundos.
4) ¿Puedo añadir con seguridad una constraint NOT NULL en Postgres sin downtime?
Frecuentemente sí, con un enfoque por etapas: añade un CHECK (...) NOT VALID, backfill, valida y luego SET NOT NULL. El paso final puede ser rápido si los datos ya cumplen.
5) ¿Por qué mi migración en Postgres “terminó” pero el rendimiento empeoró después?
Los backfills generan tuplas muertas y bloat; vacuum entonces tiene más trabajo. Además, las caches se ven alteradas por escaneos y escrituras grandes. Planifica la reacción posterior.
6) ¿Las herramientas de cambio en línea en MySQL garantizan sin impacto?
No. Cambian tiempo de bloqueo por carga de escritura adicional (triggers), tiempos de ejecución más largos y más piezas móviles. Son más seguras para el uptime, no mágicamente gratuitas.
7) ¿Debo ejecutar DDL dentro de una transacción en Postgres?
Depende. El DDL transaccional es potente, pero también puede sostener bloqueos más tiempo del esperado. Operaciones como CREATE INDEX CONCURRENTLY no pueden ejecutarse dentro de un bloque de transacción. Usa transacciones cuando la seguridad de rollback importe y el tiempo de bloqueo esté controlado.
8) ¿Cuál es la forma más segura de cambiar el tipo de una columna en cualquiera de las dos bases?
Expandir/backfill/contract. Añade una nueva columna con el tipo deseado, dual-write, backfill por lotes, cambia lecturas y luego elimina la columna antigua.
9) ¿Cómo detengo un ALTER que está causando problemas?
MySQL: encuentra la sesión y KILLéala, pero ten en cuenta que el servidor puede seguir limpiando. Postgres: primero pg_cancel_backend; si hace falta, pg_terminate_backend. Confirma siempre qué estás matando.
10) ¿Qué base de datos es “menos pesadilla” en general?
Si valoras introspección y primitivas para validación escalonada, Postgres tiende a ser más calmado. Si dependes de cambios metadata “instantáneos” y herramientas externas, MySQL puede ser excelente—hasta que el MDL convierta tu día en una danza interpretativa.
Próximos pasos que puedes hacer esta semana
- Haz inventario de tus 10 tablas más grandes y etiqueta cuáles son “calientes” (alta QPS) vs “frías”. Esto por sí solo cambia cómo planificas DDL.
- Añade checks previos a las migraciones: bloquear si existen consultas de larga duración en la tabla objetivo (riesgo de MDL en MySQL) o si hay bloqueadores (cadena de locks en Postgres).
- Estandariza un patrón de migración: expandir/backfill/contract para cambios riesgosos, y requiere ajustes “online” explícitos (
ALGORITHM/LOCKoCONCURRENTLY). - Define SLOs de replicación durante mantenimiento: cuánto lag es aceptable y qué acción dispara una pausa.
- Practica la ruta de abortar: ensaya cómo cancelar/matar de forma segura, cómo detectar la limpieza y cómo restaurar servicio si tu cambio se atasca.
Cuando alguien pregunta “MySQL vs PostgreSQL: ¿quién convierte ALTER TABLE en una pesadilla?”, la respuesta honesta es: el que tratas con ligereza. La segunda respuesta más honesta es: MySQL te sorprenderá con bloqueos que no viste; Postgres te sorprenderá con trabajo que no presupuestaste. Elige tus sorpresas—luego elimínalas mediante diseño.