La mayoría de las migraciones de bases de datos no fallan porque los ingenieros no sepan copiar bytes. Fallan porque los sistemas de producción están llenos de suposiciones que se hacen pasar por hechos: peculiaridades de colación, conversiones implícitas, valores predeterminados “útiles” y consultas de la aplicación que solo funcionaban porque el motor antiguo las toleraba educadamente.
Si te estás moviendo de MariaDB a PostgreSQL y quieres cero tiempo de inactividad, te estás apuntando a un proyecto de sistemas distribuidos con sombrero de base de datos. Esta es la guía de campo: qué medir, qué cambiar, qué no tocar un viernes y cómo hacer el corte sin el clásico “¿por qué todo va lento ahora?”.
Elige la forma correcta de migración (y por qué “solo volcar/restaurar” es mentira)
Tienes tres maneras generales de moverte de MariaDB a PostgreSQL:
- Big-bang (volcar/restaurar): detén las escrituras, toma una instantánea, restaura en Postgres y cambia la app. Es limpio, fácil de razonar y requiere tiempo de inactividad. Incluso si lo llamas “modo de mantenimiento”, tus usuarios lo llamarán “roto”.
- Lectura en sombra: migra los datos y gradualmente envía tráfico de lectura a PostgreSQL mientras las escrituras siguen yendo a MariaDB. Útil para validar rendimiento. No es suficiente para el corte completo a menos que añadas una estrategia para escrituras.
- Sin tiempo de inactividad (backfill + CDC + corte controlado): mantén MariaDB como fuente de verdad mientras rellenas datos históricos en PostgreSQL y luego aplicas los cambios en curso con captura de datos de cambio (CDC) o escrituras dobles. Haz el corte de lecturas y escrituras de forma planificada y reversible.
Para “sin tiempo de inactividad y sin sorpresas” quieres la opción #3. Es más trabajo al principio, pero cambia el pánico por proceso. Obtendrás:
- Una canalización repetible que puedes ejecutar en staging y producción.
- Retraso cuantificado (segundos de retraso, no sensaciones).
- Un plan de reversión que no implique oraciones al azar.
Una verdad operativa: si no puedes medir la latencia de replicación y la divergencia de datos, no tienes una migración. Tienes una esperanza.
Hechos y contexto histórico que realmente importan
- MariaDB existe por desconfianza tras una adquisición. Fue creada por los desarrolladores originales de MySQL después de que Oracle adquiriera MySQL, con la promesa de permanecer abierta y orientada a la comunidad.
- La genealogía de PostgreSQL es académica en el mejor sentido. Evolucionó del proyecto POSTGRES en UC Berkeley, por eso es tan testarudo con respecto a la corrección.
- InnoDB fue el motor por defecto por una razón. El bloqueo a nivel de tabla y las limitaciones de recuperación de MyISAM enseñaron la lección de forma dura.
- MVCC de PostgreSQL está “siempre activado”. Moldea el vacuum, el bloat y el comportamiento de índices; no es un interruptor que puedas ignorar.
- La replicación de MariaDB creció históricamente alrededor de tradiciones de statement/binlog. Las herramientas CDC a menudo dependen de ese ecosistema; heredas sus casos límite (DDL, no determinismo, zonas horarias).
- PostgreSQL trata los identificadores de forma distinta. Los identificadores no citados se convierten a minúsculas, lo que rompe silenciosamente migraciones desde ecosistemas que dependen de comportamiento insensible a mayúsculas.
- JSONB de PostgreSQL no es solo “JSON, pero más rápido”. Almacena una representación parseada con diferente comportamiento de índices y sutiles semánticas de igualdad.
- UTF-8 en todas partes no siempre fue la norma. Las colaciones y juegos de caracteres siguen siendo un campo minado en migraciones porque “la misma cadena” puede significar “orden distinto”.
- Las fechas “cero” de MariaDB fueron una muleta de compatibilidad. PostgreSQL las rechaza. Tu código de aplicación tendrá que madurar.
MariaDB vs PostgreSQL: diferencias que golpean en producción
Dialectos SQL y sistema de tipos: la rigidez no es un error
MariaDB (como MySQL) históricamente es permisivo: conversiones implícitas, truncamiento silencioso según el modo SQL, conversiones “útiles”. PostgreSQL es más estricto y explícito. Eso es bueno para la corrección, pero expone las partes de tu aplicación que se aprovechaban del comportamiento indefinido.
Ejemplos que comúnmente rompen:
- Conversiones implícitas de cadena a entero que antes funcionaban y ahora lanzan errores.
- Comportamiento de GROUP BY diferente al seleccionar columnas no agregadas.
- Valores por defecto de timestamp y manejo de zona horaria.
- Ordenación de NULL y ordenación por colación que afectan la paginación.
Transacciones y bloqueo: mismas palabras, consecuencias distintas
Ambos tienen transacciones; ambos tienen niveles de aislamiento; ambos pueden producir deadlocks. Pero la forma difiere:
- PostgreSQL usa MVCC más bloqueos a nivel de fila; lectores no bloquean escritores y viceversa, pero las transacciones largas impiden la limpieza y generan bloat.
- MariaDB/InnoDB también usa MVCC, pero los patrones de bloqueo alrededor de gap locks, next-key locks y comprobaciones de unicidad aparecen distinto, especialmente bajo
REPEATABLE READ. - El bloqueo en DDL difiere. PostgreSQL puede realizar muchas operaciones concurrentemente, pero no todas; MariaDB tiene sus propias reglas de “DDL en línea” que dependen de la versión y el motor.
Auto-increment vs sequences: no puedes ignorar el mapeo
AUTO_INCREMENT de MariaDB está ligado a metadatos de la tabla. PostgreSQL típicamente usa objetos SEQUENCE, a menudo vía GENERATED ... AS IDENTITY. Durante la migración, debes ajustar los valores de las secuencias correctamente o tendrás colisiones de claves primarias tras el corte.
Juego de caracteres y colación: el asesino silencioso de la corrección
Incluso si estás “todo en UTF-8”, las colaciones difieren. El orden y el plegado de mayúsculas/minúsculas difieren. Eso rompe:
- Restricciones únicas (dos cadenas consideradas iguales en un sistema pero distintas en otro, o viceversa).
- Uso de índices (dependiente de colación).
- Paginación al ordenar por columnas de texto (los usuarios ven duplicados o filas faltantes entre páginas).
Modelo operativo: vacuum es tu nueva reunión recurrente
PostgreSQL necesita vacuum para recuperar espacio y mantener saludables los mapas de visibilidad. Autovacuum suele ser suficiente—hasta que no lo es. Si tu carga tiene muchas actualizaciones y ejecutas transacciones largas, aprenderás qué significa “bloat” de una forma personal.
Una cita para pegar en un post-it:
“La esperanza no es una estrategia.” — General Gordon R. Sullivan
Broma #1: Si tu plan de migración es “lo monitorizamos”, no tienes un plan—tienes un pasatiempo.
Arquitectura sin tiempo de inactividad: backfill + CDC + corte controlado
El patrón básico
Un plan sensato sin tiempo de inactividad suele verse así:
- Preparar Postgres: esquema, roles, extensiones, ajuste de parámetros, observabilidad.
- Backfill inicial: copiar tablas existentes de MariaDB a Postgres mientras la app sigue en línea y escribe en MariaDB.
- CDC / replicación hacia Postgres: transmitir cambios en curso (inserciones/actualizaciones/bajas) desde el binlog de MariaDB a Postgres para mantenerlo casi en tiempo real.
- Validación de consistencia: checksums, conteos, comparaciones de filas muestreadas y invariantes a nivel de aplicación.
- Corte: cambiar lecturas y luego escrituras (o hacer ambos con una breve pausa de escritura medida en segundos, no minutos).
- Endurecimiento post-corte: ajuste de índices, tuneo de vacuum, corrección de consultas y una ventana de reversión donde MariaDB permanece disponible.
CDC vs escrituras dobles: elige tu veneno con cuidado
CDC (recomendado) significa que la aplicación sigue escribiendo solo en MariaDB hasta el corte. Replica los cambios a Postgres fuera de banda. Esto reduce la complejidad de la aplicación y evita errores de consistencia de escrituras dobles. El coste es que necesitas una canalización CDC robusta y un plan para cambios de DDL durante la ventana de sincronización.
Escrituras dobles significa que tu app escribe en ambas bases de datos durante un periodo. Puede funcionar, pero ahora te haces cargo del problema de transacciones distribuidas. Si haces esto, debes diseñar para fallos parciales: una escritura tiene éxito y la otra falla, y tu sistema debe reconciliar. La mayoría de los equipos sobrestiman su apetito por esto.
Consejo práctico: si puedes evitar escrituras dobles, evítalas. CDC más un corte cuidadosamente gestionado es menos heroico y más repetible.
Mecánica de corte que no arruina tu fin de semana
El corte más limpio es “lecturas primero, escrituras al final”:
- Mueve el tráfico de solo lectura (reportes, trabajos por lotes) a Postgres temprano para detectar diferencias de consulta y lagunas de índices.
- Mantén las escrituras OLTP en MariaDB mientras CDC mantiene Postgres actualizado.
- Programa una breve pausa de escritura (segundos hasta unos pocos minutos) para reducir el lag a cero, validar y luego cambiar las escrituras.
Si tu equipo de producto exige “literalmente cero pausa de escritura”, puedes hacerlo, pero pagarás en complejidad: escrituras idempotentes, resolución de conflictos y, a veces, un sistema temporal de reloj lógico. Para la mayoría de los negocios, una breve pausa de escritura es la honestidad más barata que comprarás.
DDL durante la migración: el enemigo de lo “simple”
Los cambios de esquema en medio de la migración son donde los cronogramas van a morir. Trátalo deliberadamente:
- Congela DDL durante la ventana final de sincronización. Hazlo política, no sugerencia.
- Si debes aplicar DDL, aplícalo en ambos sistemas y valida que tu herramienta CDC lo maneje correctamente (muchas no lo hacen o solo manejan un subconjunto).
- Prefiere evolución de esquema compatible hacia atrás (añadir columnas nullable, rellenar datos y luego aplicar restricciones).
Tareas prácticas con comandos: qué ejecutas, qué significa, qué decides
Estas son las tareas que convierten “proyecto de migración” en “migración con recibos”. Los comandos son representativos y ejecutables; adapta credenciales y hosts.
Tarea 1: Verificar el binlog de MariaDB (prerrequisito CDC)
cr0x@server:~$ mysql -h mariadb01 -u admin -p -e "SHOW VARIABLES LIKE 'log_bin'; SHOW VARIABLES LIKE 'binlog_format'; SHOW VARIABLES LIKE 'server_id';"
Enter password:
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| log_bin | ON |
+---------------+-------+
+---------------+-----------+
| Variable_name | Value |
+---------------+-----------+
| binlog_format | ROW |
+---------------+-----------+
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| server_id | 101 |
+---------------+-------+
Qué significa: log_bin=ON y binlog_format=ROW son la línea base estándar para CDC fiable. server_id debe estar establecido y ser único.
Decisión: Si el binlog está apagado o no es ROW, arregla esto antes de cualquier otra cosa. La replicación basada en statements eventualmente generará momentos de “pero funcionó en staging”.
Tarea 2: Comprobar modo GTID en MariaDB (facilita reanudar CDC)
cr0x@server:~$ mysql -h mariadb01 -u admin -p -e "SHOW VARIABLES LIKE 'gtid_strict_mode'; SHOW VARIABLES LIKE 'gtid_binlog_pos';"
Enter password:
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| gtid_strict_mode | ON |
+------------------+-------+
+-----------------+------------------------------------------+
| Variable_name | Value |
+-----------------+------------------------------------------+
| gtid_binlog_pos | 0-101-9876543 |
+-----------------+------------------------------------------+
Qué significa: GTID facilita reiniciar una canalización CDC sin adivinar archivo/posición del binlog.
Decisión: Si GTID no está habilitado, aún puedes hacer CDC, pero tu carga operativa aumenta. Decide pronto si estandarizas en GTID.
Tarea 3: Inventariar características del esquema que no se traducen bien
cr0x@server:~$ mysql -h mariadb01 -u admin -p -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, ENGINE, TABLE_COLLATION FROM information_schema.TABLES WHERE TABLE_SCHEMA NOT IN ('mysql','information_schema','performance_schema','sys') ORDER BY TABLE_SCHEMA, TABLE_NAME LIMIT 10;"
Enter password:
appdb users InnoDB utf8mb4_unicode_ci
appdb orders InnoDB utf8mb4_unicode_ci
appdb order_items InnoDB utf8mb4_unicode_ci
appdb audit_log InnoDB utf8mb4_unicode_ci
appdb sessions InnoDB utf8mb4_unicode_ci
appdb settings InnoDB utf8mb4_unicode_ci
appdb products InnoDB utf8mb4_unicode_ci
appdb inventory InnoDB utf8mb4_unicode_ci
appdb payments InnoDB utf8mb4_unicode_ci
appdb refunds InnoDB utf8mb4_unicode_ci
Qué significa: Estás comprobando motores y colaciones. Las tablas no InnoDB, colaciones extrañas y codificaciones heredadas multiplican el riesgo de migración.
Decisión: Si encuentras MyISAM/Aria o colaciones mixtas, asigna tiempo para normalizar o mapear explícitamente el comportamiento en Postgres (incluyendo expectativas a nivel de app).
Tarea 4: Capturar el modo SQL de MariaDB (te dice cuánta mala data tienes)
cr0x@server:~$ mysql -h mariadb01 -u admin -p -e "SELECT @@sql_mode;"
Enter password:
+----------------------------------------------------------------------------------+
| @@sql_mode |
+----------------------------------------------------------------------------------+
| STRICT_TRANS_TABLES,ERROR_FOR_DIVISION_BY_ZERO,NO_AUTO_CREATE_USER,NO_ENGINE_SUBSTITUTION |
+----------------------------------------------------------------------------------+
Qué significa: Los modos menos estrictos a menudo ocultan truncamientos y fechas inválidas. El modo estricto reduce sorpresas, pero no elimina filas históricas malas.
Decisión: Si la estrictidad es baja, ejecuta comprobaciones de calidad de datos antes de migrar. Postgres rechazará algunos valores que MariaDB aceptó.
Tarea 5: Identificar fechas “cero” y timestamps inválidos
cr0x@server:~$ mysql -h mariadb01 -u admin -p -e "SELECT COUNT(*) AS zero_dates FROM appdb.orders WHERE created_at='0000-00-00 00:00:00';"
Enter password:
+-----------+
| zero_dates|
+-----------+
| 42 |
+-----------+
Qué significa: PostgreSQL no soporta timestamps con año 0000. Esas filas fallarán al cargarse a menos que se transformen.
Decisión: Elige una política de remediación: convertir a NULL, convertir a época (epoch) o mover el campo a texto temporalmente. Documenta la decisión; los auditores la preguntarán después.
Tarea 6: Levantar Postgres con ajustes base y verificar
cr0x@server:~$ psql -h pg01 -U postgres -c "SHOW server_version; SHOW wal_level; SHOW max_wal_senders;"
server_version
----------------
16.3
(1 row)
wal_level
-----------
replica
(1 row)
max_wal_senders
-----------------
10
(1 row)
Qué significa: Confirmas el motor destino y los ajustes de replicación (incluso si no usas replicación PG, el comportamiento WAL impacta operaciones).
Decisión: Si planeas replicación lógica o ingesta intensiva, configura parámetros WAL y checkpoint deliberadamente. Las configuraciones por defecto no son un plan de rendimiento.
Tarea 7: Crear roles y aplicar mínimo privilegio desde temprano
cr0x@server:~$ psql -h pg01 -U postgres -c "CREATE ROLE app_user LOGIN PASSWORD 'REDACTED'; GRANT CONNECT ON DATABASE appdb TO app_user;"
CREATE ROLE
GRANT
Qué significa: Evitas la clásica mentira de “arreglamos permisos después del corte”.
Decisión: Si tu app depende actualmente de privilegios amplios (p. ej., DDL), decide si conservar eso (no recomendado) o refactorizar ahora.
Tarea 8: Convertir esquema (ejemplo usando pgloader) e inspeccionar resultados
cr0x@server:~$ pgloader mysql://migrate:REDACTED@mariadb01/appdb postgresql://postgres@pg01/appdb
2025-12-30T10:01:12.000000Z LOG pgloader version "3.6.9"
2025-12-30T10:01:13.000000Z LOG Migrating from #<MYSQL-CONNECTION mysql://migrate@.../appdb>
2025-12-30T10:01:13.000000Z LOG Migrating into #<PGSQL-CONNECTION postgresql://postgres@pg01/appdb>
2025-12-30T10:05:44.000000Z LOG Data errors in table "orders": 0
2025-12-30T10:05:44.000000Z LOG Total import time: 00:04:31
Qué significa: La herramienta hizo una primera pasada de esquema + datos. La línea de log sobre errores de datos es tu primer detector de señales rojas.
Decisión: Trata esto como andamiaje, no como la respuesta final. Revisa tipos, restricciones, índices y secuencias; luego ejecuta CDC para cambios en curso.
Tarea 9: Confirmar que las secuencias estén ajustadas después del backfill
cr0x@server:~$ psql -h pg01 -U postgres -d appdb -c "SELECT pg_get_serial_sequence('orders','id') AS seq; SELECT MAX(id) FROM orders;"
seq
--------------------------
public.orders_id_seq
(1 row)
max
--------
984211
(1 row)
Qué significa: Encontraste el nombre de la secuencia y el id máximo actual en la tabla.
Decisión: Si la secuencia está por detrás, arréglala ahora, antes del corte.
cr0x@server:~$ psql -h pg01 -U postgres -d appdb -c "SELECT setval('public.orders_id_seq', (SELECT MAX(id) FROM orders));"
setval
--------
984211
(1 row)
Tarea 10: Medir lag de CDC (ejemplo usando offsets de estilo Debezium almacenados localmente)
cr0x@server:~$ jq -r '.source.ts_ms' /var/lib/cdc/offsets/appdb.json
1735559142000
Qué significa: Ese timestamp (ms desde epoch) es el tiempo del evento fuente del último cambio de binlog procesado.
Decisión: Compáralo con la hora actual; si el lag crece, no cortes. Arregla primero el throughput (red, velocidad de aplicación, índices, tamaño de lotes).
Tarea 11: Verificar conteos de filas entre sistemas (rápido, imperfecto, aún útil)
cr0x@server:~$ mysql -h mariadb01 -u admin -p -N -e "SELECT COUNT(*) FROM appdb.orders;"
Enter password:
984211
cr0x@server:~$ psql -h pg01 -U postgres -d appdb -t -c "SELECT COUNT(*) FROM orders;"
984211
Qué significa: Los conteos coinciden para esta tabla. Eso no prueba igualdad de datos, pero detecta trozos faltantes.
Decisión: Si los conteos difieren, para y averigua por qué: filtros en el loader, filas fallidas, CDC que no aplica deletes o colisiones de PK.
Tarea 12: Validar checksums en un segmento muestreado (mejor que conteos)
cr0x@server:~$ mysql -h mariadb01 -u admin -p -N -e "SELECT MD5(GROUP_CONCAT(CONCAT(id,':',status,':',total_cents) ORDER BY id SEPARATOR '|')) FROM appdb.orders WHERE id BETWEEN 900000 AND 900500;"
Enter password:
2f1c7c0a9b0e0a2f8dd7d8a11d5f4b3a
cr0x@server:~$ psql -h pg01 -U postgres -d appdb -t -c "SELECT md5(string_agg(id||':'||status||':'||total_cents, '|' ORDER BY id)) FROM orders WHERE id BETWEEN 900000 AND 900500;"
2f1c7c0a9b0e0a2f8dd7d8a11d5f4b3a
Qué significa: Para ese segmento, el contenido coincide. Este método es sensible al formateo/casting; eso es una ventaja si te preocupan las conversiones implícitas.
Decisión: Ejecuta esto para múltiples rangos y tablas de alta rotación. Si hay desajustes, investiga transformaciones, redondeos, conversiones de zona horaria y normalización de texto.
Tarea 13: Detectar consultas lentas en PostgreSQL inmediatamente después de la lectura en sombra
cr0x@server:~$ psql -h pg01 -U postgres -d appdb -c "SELECT calls, mean_exec_time, rows, query FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 5;"
calls | mean_exec_time | rows | query
-------+----------------+------+------------------------------------------------
1200 | 87.332 | 1 | SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2
310 | 54.910 | 25 | SELECT * FROM products WHERE name ILIKE $1
98 | 42.774 | 1 | UPDATE inventory SET qty = qty - $1 WHERE sku = $2
Qué significa: Estás observando patrones de consulta, no incidentes individuales. El tiempo medio de ejecución es tu indicador de “qué empeoró”.
Decisión: Añade índices, reescribe consultas o cambia la estrategia de paginación antes del corte. No “esperes y veas” con latencia visible para clientes.
Tarea 14: Explicar al peor culpable y decidir índice vs cambio de consulta
cr0x@server:~$ psql -h pg01 -U postgres -d appdb -c "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at DESC LIMIT 20;"
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.43..152.12 rows=20 width=256) (actual time=24.112..24.145 rows=20 loops=1)
Buffers: shared hit=12040 read=210
-> Index Scan Backward using orders_created_at_idx on orders (cost=0.43..81234.55 rows=10680 width=256) (actual time=24.110..24.140 rows=20 loops=1)
Filter: (user_id = 123)
Rows Removed by Filter: 420000
Buffers: shared hit=12040 read=210
Planning Time: 0.231 ms
Execution Time: 24.201 ms
(9 rows)
Qué significa: Está escaneando por created_at y filtrando por user_id. Eso es caro: muchas filas eliminadas por filtro.
Decisión: Crea un índice compuesto que coincida con el predicado + orden, p. ej. (user_id, created_at DESC), o reescribe la consulta para usar un método de búsqueda si es posible.
Tarea 15: Comprobar salud de vacuum y tuplas muertas (evita “era rápido ayer”)
cr0x@server:~$ psql -h pg01 -U postgres -d appdb -c "SELECT relname, n_dead_tup, last_autovacuum, last_autoanalyze FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;"
relname | n_dead_tup | last_autovacuum | last_autoanalyze
-------------+------------+-------------------------------+-------------------------------
audit_log | 1983321 | 2025-12-30 09:11:02+00 | 2025-12-30 09:12:40+00
sessions | 822110 | 2025-12-30 09:08:14+00 | 2025-12-30 09:10:05+00
orders | 110422 | 2025-12-30 09:00:01+00 | 2025-12-30 09:03:33+00
products | 9221 | 2025-12-30 08:55:40+00 | 2025-12-30 08:56:10+00
inventory | 1087 | 2025-12-30 08:54:11+00 | 2025-12-30 08:54:55+00
Qué significa: Muchas tuplas muertas indican churn; el vacuum se está ejecutando pero podría no ser suficiente, sobre todo si hay transacciones largas.
Decisión: Ajusta autovacuum por tabla para tablas con mucho churn, reduce transacciones largas y vigila el bloat antes de que se convierta en una avalancha de tickets.
Tarea 16: Confirmar conexiones y comportamiento del pooler antes del corte
cr0x@server:~$ psql -h pg01 -U postgres -c "SELECT state, count(*) FROM pg_stat_activity GROUP BY state ORDER BY count(*) DESC;"
state | count
---------+-------
idle | 180
active | 35
(2 rows)
Qué significa: El conteo de conexiones y estados te dice si necesitas pooling. PostgreSQL no agradece 2000 conexiones inactivas de la app consumiendo memoria.
Decisión: Si ves picos de conexiones o muchos idle, despliega un pooler (o arregla el pooling en la app) antes del día en que necesites calma.
Broma #2: Un corte de base de datos es como un salto en paracaídas—descubrir que lo empacaste mal es una experiencia memorable.
Tres mini-historias del mundo corporativo (dolor incluido)
Mini-historia #1: El incidente causado por una asunción errónea
La compañía: SaaS mediano, múltiples regiones, un primario MariaDB con réplicas. Querían PostgreSQL por mejores características de consulta y un ecosistema de extensiones más sano. El equipo de migración hizo las piezas grandes bien: backfill, CDC, lecturas en sombra. Todo parecía en verde. Ventana de corte reservada.
La suposición equivocada fue pequeña: “el orden de cadenas es básicamente igual”. Su app usaba paginación basada en cursores para listas de clientes, ordenando por una columna name y aplicando WHERE name > last_seen_name. En MariaDB con la colación elegida, las reglas de orden y comparación coincidían con lo que la interfaz esperaba. En PostgreSQL, la colación y las reglas específicas de locale produjeron un orden distinto para caracteres acentuados y algunos casos mixtos de mayúsculas/minúsculas.
Después del corte, el síntoma fue sutil: los clientes informaron “usuarios faltantes” en las listas. Los datos no estaban perdidos. La paginación omitía registros porque la lógica del cursor ya no se alineaba con el orden. Soporte escaló con capturas de pantalla; los ingenieros llegaron con incredulidad. Ese suele ser el orden de los hechos.
La solución no fue heroica. Hicieron el orden determinista añadiendo una clave de orden secundaria (id único estable), cambiaron el cursor para incluir ambos campos y alinearon las colaciones donde fue posible. También agregaron una prueba de regresión que genera nombres Unicode aleatorios y valida la paginación entre motores. Fue aburrido, y funcionó.
Mini-historia #2: La optimización que salió mal
La compañía: plataforma de comercio electrónico con tablas grandes y tráfico en ráfagas. Tenían una canalización de backfill que cargaba en PostgreSQL. Alguien notó que la ingesta era más lenta de lo esperado y decidió acelerarla deshabilitando índices y restricciones durante la carga, y luego recreándolo todo al final. Eso no es automáticamente malo.
El retroceso vino por dos frentes. Primero, su canalización CDC ya aplicaba cambios. Sin restricciones, duplicados y filas huérfanas se filtraron durante la fase de carga, porque la lógica de aplicación asumía que la base de datos impondría unicidad y claves foráneas. Segundo, después de recrear índices, las construcciones de índices saturaron I/O y CPU y dejaron sin recursos al aplicador CDC, lo que incrementó el lag. El lag incrementado aumentó el riesgo del corte. Nadie durmió bien.
Finalmente se estabilizaron cambiando la secuencia: cargar en trozos con las restricciones esenciales en su lugar (claves primarias, no todas las foreign keys), mantener predecible el rendimiento del apply CDC y programar las construcciones de índices usando CREATE INDEX CONCURRENTLY en lotes controlados. También implementaron manejo de dead-letter en la canalización CDC para filas que violaran restricciones, de modo que un evento malo no detuviera todo.
La moraleja: optimizar no es “hacer los números más grandes”. Es “hacer los modos de fallo más pequeños”.
Mini-historia #3: La práctica aburrida pero correcta que salvó el día
La compañía: B2B empresarial, muchas integraciones y obsesión por el rastro de auditoría. El equipo hizo algo poco sexy: ejecutaron toda la canalización de migración en un entorno de staging parecido a producción con volumen de datos y reproducción de tráfico reales. No una “prueba unitaria”. Un ensayo general.
Durante el ensayo encontraron que una tabla tenía una columna TEXT que almacenaba blobs JSON con codificación inconsistente. MariaDB lo aceptaba. Postgres rechazaba algunas filas al castearlas a jsonb. Como estaban en ensayo, tuvieron tiempo para implementar una transformación: almacenar el texto crudo en una columna json_raw, poblar una columna jsonb validada donde fue posible y añadir un job asíncrono para normalizar filas antiguas.
También practicaron la reversión. No solo “podemos revertir”, sino realmente cambiar de nuevo la app a MariaDB en staging con conexiones en caché, poolers y TTL de DNS. Midieron cuánto tiempo tardaba cada servicio en reconectarse. Ese número se convirtió en un requisito para el runbook de corte en producción.
En el día real de corte, un cambio de ACL de red ralentizó el tráfico a Postgres desde una subred de aplicaciones. Como habían ensayado, los primeros pasos del runbook lo detectaron rápido y la preparación para reversión mantuvo la calma. Arreglaron la ACL y siguieron adelante. Nadie escribió un postmortem titulado “Asumimos que la red estaba bien”. Lo aburrido salvó el día.
Guía rápida de diagnóstico
Esto es para el momento justo después de comenzar lecturas en sombra, aumentar tráfico o cortar—y la latencia sube. No discutas con los gráficos. Haz triage.
Primero: ¿es la base de datos, la red o la app?
- Comprueba la tasa de errores y los timeouts en la app: ¿estamos saturando los pools de conexiones o recibiendo errores nuevos por SQL más estricto?
- Comprueba RTT y pérdida de paquetes entre la app y Postgres (especialmente si Postgres está en otra subred/región).
- Comprueba estados de conexión en Postgres: active vs idle, waiting vs running.
Segundo: encuentra la espera principal, no la consulta principal
- Busca locks: ¿hay transacciones atascadas esperando locks por jobs largos o DDL?
- Busca presión de I/O: ¿las lecturas vienen del disco porque los índices no se usan o las caches no están calentadas?
- Busca presión de CPU: ¿un índice faltante convierte filtros simples en scans?
Tercero: confirma que Postgres no se está peleando consigo mismo
- Saturación de autovacuum: demasiadas tuplas muertas o vacuum bloqueado por transacciones largas.
- Tormentas de checkpoint: ajuste WAL/checkpoint causando picos periódicos de latencia.
- work_mem mal dimensionado: ordenaciones y hash que se salen a disco.
Comandos prácticos de “diagnóstico rápido”
cr0x@server:~$ psql -h pg01 -U postgres -d appdb -c "SELECT wait_event_type, wait_event, count(*) FROM pg_stat_activity WHERE wait_event IS NOT NULL GROUP BY 1,2 ORDER BY count(*) DESC;"
wait_event_type | wait_event | count
-----------------+--------------------+-------
Lock | transactionid | 12
IO | DataFileRead | 9
LWLock | WALWrite | 4
(3 rows)
Qué significa: Ves en qué esperan las sesiones. Locks y esperas de I/O son comunes durante el corte si los índices están mal o las transacciones son largas.
Decisión: Si las esperas por Lock dominan, identifica bloqueadores y arregla el comportamiento transaccional. Si dominan esperas de I/O, añade índices o ajusta consultas; no “tunées Postgres” antes de arreglar las rutas de acceso.
cr0x@server:~$ psql -h pg01 -U postgres -d appdb -c "SELECT pid, age(clock_timestamp(), xact_start) AS xact_age, state, wait_event, left(query,120) AS query FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_age DESC LIMIT 5;"
pid | xact_age | state | wait_event | query
------+----------+--------+------------+---------------------------------------------------------
4412 | 00:34:51 | active | | UPDATE inventory SET qty = qty - $1 WHERE sku = $2
3201 | 00:12:03 | idle in transaction | ClientRead | SELECT * FROM orders WHERE id = $1
(2 rows)
Qué significa: “idle in transaction” es el asesino silencioso del vacuum y amplificador de locks.
Decisión: Arregla el alcance de las transacciones en la app; mata sesiones patológicas si es necesario. Postgres no te salvará de transacciones pausadas.
Errores comunes: síntoma → causa raíz → solución
1) “Postgres es más lento” justo después del corte
Síntoma: La latencia sube, la CPU escala y ves scans secuenciales en rutas calientes.
Causa raíz: Índices compuestos faltantes y diferentes supuestos del optimizador. MariaDB a menudo capeó con distinto uso de índices, y tus consultas pueden depender de eso.
Solución: Usa pg_stat_statements + EXPLAIN (ANALYZE, BUFFERS). Añade índices que coincidan con filtros + orden. Valida con parámetros reales, no ejemplos simplificados.
2) Colisiones de clave primaria después de habilitar escrituras en Postgres
Síntoma: Inserciones fallan con duplicate key en tablas que usaban AUTO_INCREMENT.
Causa raíz: Secuencias no avanzadas al id máximo existente tras el backfill/CDC.
Solución: Ejecuta setval() para cada secuencia al menos hasta el id máximo actual, luego verifica. Automatiza esto en el runbook de corte.
3) CDC se atrasa para siempre y nunca alcanza
Síntoma: El lag crece durante pico de tráfico; el apply en Postgres no da abasto.
Causa raíz: El lado de aplicación realiza mantenimiento intensivo de índices durante el backfill, batching insuficiente o límites de red/disco.
Solución: Haz el backfill en trozos, crea solo índices esenciales temprano y añade índices secundarios después. Ajusta tamaños de lote de CDC. Asegura suficientes IOPS en Postgres y que ajustes WAL no estén limitando la ingesta.
4) “Funcionó en staging” pero en producción hay fallos de carga de datos
Síntoma: El loader rechaza filas, a menudo alrededor de fechas, precisión numérica o UTF-8 inválido.
Causa raíz: Datos de producción contienen basura histórica: fechas cero, enteros fuera de rango, bytes inválidos.
Solución: Ejecuta consultas de calidad de datos en MariaDB desde temprano. Decide reglas de transformación explícitas e implémentalas de forma consistente en backfill y CDC.
5) Duplicados/faltantes en paginación
Síntoma: Los usuarios ven el mismo elemento dos veces en páginas o elementos desaparecen.
Causa raíz: Orden no determinista, diferencias de colación o ordenar por columnas no únicas.
Solución: Añade un desempate estable (id único) al ORDER BY y tokens de cursor. Evita paginación por offset en listas de alto churn.
6) Aparecen deadlocks repentinamente en Postgres
Síntoma: Errores de deadlock en tablas con muchas actualizaciones; aumentan los reintentos.
Causa raíz: Orden distinto de adquisición de locks entre consultas y nuevos índices que cambian rutas de ejecución.
Solución: Estandariza el orden de locks en la lógica de la app. Mantén transacciones pequeñas. Añade reintentos con jitter. Analiza grafos de deadlock desde los logs del servidor.
7) El disco crece inesperadamente rápido en Postgres
Síntoma: El uso de almacenamiento crece diariamente; el rendimiento se degrada con el tiempo.
Causa raíz: Autovacuum no da abasto, transacciones largas impiden limpieza o umbrales de autovacuum demasiado bajos en tablas calientes.
Solución: Ajusta autovacuum por tabla; elimina idle-in-transaction; considera particionado para logs append-heavy; monitoriza bloat y lag de vacuum.
Listas de verificación / plan paso a paso
Fase 0: Decide los no negociables
- Define la pausa de escritura aceptable (si la hay) en segundos.
- Define la ventana de reversión (cuánto tiempo MariaDB permanece listo).
- Congela la política de DDL durante la sincronización final.
- Elige estrategia de migración: CDC-first (recomendado) o escrituras dobles (solo si debes).
Fase 1: Inventario preflight (donde nacen las sorpresas)
- Lista tablas, tamaños y tasa de churn; marca tablas calientes.
- Enumera tipos de datos que no mapean limpiamente: JSON-como-texto, fechas cero, enteros unsigned, enums, colaciones.
- Enumera consultas críticas y sus SLOs de latencia.
- Identifica jobs en background que ejecutan transacciones largas.
Fase 2: Construye PostgreSQL como si lo sintieras
- Elige versión de Postgres y úsala consistentemente en todos los entornos.
- Activa
pg_stat_statementsy establece observabilidad base. - Configura roles, canalización de migración y gestión de esquemas.
- Decide estrategia de particionado para tablas append-only grandes (audit logs, eventos).
Fase 3: Backfill seguro
- Backfill por trozos usando rangos de PK o ventanas temporales.
- Mantén claves primarias e índices esenciales disponibles para soportar el apply CDC.
- Rastrea filas rechazadas y arregla datos upstream o reglas de transformación.
- Registra checkpoints de backfill para que las re-ejecuciones sean deterministas.
Fase 4: Ventana de sincronización CDC
- Inicia CDC desde una posición/GTID conocido del binlog.
- Monitorea lag y errores de apply continuamente.
- Detén la deriva de esquemas: nada de DDL no coordinado.
- Ejecuta comprobaciones de consistencia nocturnas: conteos + checksums muestreados + invariantes de negocio.
Fase 5: Lecturas en sombra y ajuste de rendimiento
- Dirige un pequeño porcentaje de lecturas a Postgres y compara resultados.
- Arregla incompatibilidades de consultas y regresiones de rendimiento ahora, no durante el corte.
- Valida características sensibles a colación (búsquedas, ordenación, paginación).
- Calienta caches y valida dimensionamiento del pool de conexiones.
Fase 6: Runbook de corte (una secuencia que puedas ejecutar bajo estrés)
- Anuncia congelación de cambios y confirma cobertura on-call.
- Confirma que el lag de CDC es bajo y estable; identifica el lag actual en segundos.
- Activa una breve pausa de escritura (o límite estricto de tasa) en la capa de app.
- Espera a que CDC llegue a cero lag; confirma la última GTID/posición capturada.
- Ejecuta comprobaciones finales de consistencia en tablas críticas.
- Cambia las escrituras a Postgres (feature flag / config / descubrimiento de servicios).
- Cambia las lecturas a Postgres en todas partes.
- Monitorea errores, latencia p95, locks, replicación y almacenamiento.
- Mantén MariaDB en solo lectura y disponible para reversión durante la ventana acordada.
Fase 7: Endurecimiento post-corte
- Activa manejo de errores más estricto en la app para fallos de consulta; no enmascares excepciones.
- Revisa
pg_stat_statementssemanalmente durante el primer mes. - Afina autovacuum para tablas calientes.
- Retira la canalización CDC solo después de que la ventana de reversión cierre y los datos estén validados.
Preguntas frecuentes (FAQ)
1) ¿Puedo hacer MariaDB → PostgreSQL con literalmente cero tiempo de inactividad?
Sí, pero “cero tiempo de inactividad” normalmente oculta un problema de consistencia de escrituras. El enfoque pragmático es una breve pausa de escritura para drenar el lag de CDC a cero. Si debes evitarla, necesitarás escrituras dobles más reconciliación e idempotencia—espera complejidad y más casos límite.
2) ¿Debo usar CDC o escrituras dobles?
Prefiere CDC. Mantiene la aplicación más simple y localiza la complejidad en una canalización que puedes observar y reiniciar. Escrituras dobles es para casos donde ya tienes escrituras idempotentes, fuertes semánticas de reintento y disposición para construir herramientas de reconciliación.
3) ¿Qué se rompe con más frecuencia a nivel SQL?
Conversiones implícitas, semántica de GROUP BY, manejo de fecha/hora y sensibilidad de mayúsculas en identificadores. Que PostgreSQL sea estricto es una ventaja; saca a la luz bugs que ya tenías.
4) ¿Cómo manejo enteros UNSIGNED de MariaDB?
Decide por columna: mapea a un tipo signed más grande en Postgres (p. ej., unsigned int → bigint), o aplica restricciones y acepta rango reducido. No mapees a ciegas y esperes lo mejor—los desbordes de rango aparecen después como bugs “imposibles”.
5) ¿Y los tipos ENUM?
En Postgres puedes usar ENUM nativo o una tabla lookup con foreign key. Los enums nativos están bien si los valores son estables; las tablas lookup son mejores si los valores cambian con frecuencia y quieres auditabilidad.
6) ¿Cómo valido la corrección de datos sin comparar cada fila?
Combina técnicas: conteos de filas para cobertura, checksums muestreados para contenido e invariantes de negocio (p. ej., totales de pedido coinciden con suma de ítems). También valida “slices” borde: filas más nuevas, más antiguas y rangos de alta rotación.
7) ¿Por qué creció mi uso de disco en Postgres más rápido de lo esperado?
MVCC significa que las actualizaciones crean tuplas muertas hasta que vacuum las limpia. Si tienes transacciones largas o autovacuum no da abasto, el bloat crece. Arregla la longitud de transacciones y afina autovacuum; no pongas más discos como única solución.
8) ¿Necesito un pooler de conexiones para Postgres?
Muchas veces sí. Las conexiones de PostgreSQL son más pesadas de lo que muchos equipos esperan, y “una conexión por hilo” puede ser costosa. Si tienes muchas instancias de app, el pooling suele ser la opción calma.
9) ¿Cómo planifico la reversión?
Mantén MariaDB disponible y idealmente en solo lectura tras el corte. Para revertir escrituras limpiamente necesitas o (a) que no hayan ocurrido escrituras en Postgres (raro), o (b) un plan de sincronización inversa (difícil). En la práctica, la reversión significa cambiar rápido a MariaDB mientras investigas, luego recortar de nuevo cuando esté arreglado. Por eso importa minimizar el tiempo de divergencia post-corte.
10) ¿Es pgloader suficiente?
Es una herramienta potente para esquema inicial y backfill, no para una historia completa sin tiempo de inactividad por sí sola. Trátala como el paso de copia masiva, luego usa CDC y un corte disciplinado.
Conclusión: siguientes pasos que puedes ejecutar
Si quieres una migración sin tiempo de inactividad y sin sorpresas, haz la ingeniería aburrida: mide todo, restringe cambios y practica el corte. Los sistemas que sobreviven son aquellos donde puedes responder, en tiempo real, “¿qué tan atrasados estamos?” y “¿podemos revertir en cinco minutos?”
Siguientes pasos que puedes ejecutar esta semana:
- Inventario de MariaDB: formato de binlog, GTID, colaciones, modo SQL y datos sucios (fechas cero, UTF-8 inválido).
- Levantar Postgres con observabilidad:
pg_stat_statements, monitor de conexiones, visibilidad de vacuum. - Hacer una tabla de extremo a extremo: backfill, CDC, validación con checksums y rendimiento de consultas en lecturas en sombra.
- Escribir el runbook de corte y ensayarlo con reproducción de tráfico y un simulacro de reversión.
Ejecuta la migración como ejecutas producción: cambio controlado, evidencia firme y un plan que no dependa de optimismo para funcionar.