Añades una columna JSON porque “solo necesitas flexibilidad”. Luego tus paneles se vuelven lentos, tus réplicas se retrasan,
y alguien pide “una consulta ad-hoc rápida” que se convierte en un escaneo de tabla a través de millones de filas. JSON es la
cinta adhesiva del modelado de datos: a veces salva el día, otras veces es la razón por la que hubo que salvarlo.
MySQL y PostgreSQL soportan JSON, pero fomentan hábitos muy diferentes. Uno te dejará lanzar rápido y acumular deuda en silencio.
El otro te permite construir índices y restricciones potentes—pero también te da suficiente cuerda para tejer un suéter de bloat y contención de locks si no tienes cuidado.
La decisión en una página: qué elegir y cuándo
Usa PostgreSQL cuando…
- Necesitas consultas ricas (contención, existencia, filtros anidados) y quieres que el optimizador tenga opciones. JSONB + GIN de PostgreSQL es la caja de herramientas madura.
- Quieres restricciones sobre datos semiestructurados: CHECK constraints, índices de expresión, columnas generadas e índices funcionales son ciudadanos de primera clase.
- Esperas que JSON permanezca más de un trimestre. PostgreSQL suele envejecer mejor cuando JSON se convierte en “esquema central”.
- Puedes operar vacuum con competencia. PostgreSQL te lo recompensará, pero solo si respetas el housekeeping de MVCC.
Usa MySQL cuando…
- Tu uso de JSON es principalmente almacenamiento de documentos + recuperación, no filtrado analítico intenso. Si las consultas son “buscar por id y devolver blob”, MySQL puede ir muy bien.
- Dependes de columnas generadas para proyectar rutas JSON calientes en escalares indexables. Este es el camino práctico de MySQL hacia la predictibilidad.
- Ya estás estandarizado operativamente en MySQL y JSON es una pequeña parte de la carga. Operaciones consistentes superan a la elegancia teórica.
Qué le diría a un equipo en producción
Si tus columnas JSON son un hack transitorio (ingesta rápida, normalizar después), elige la base de datos que tu equipo ya
opere bien. Pero si JSON es el contrato de interfaz (eventos, configuraciones, feature flags, atributos de usuario) y esperas consultar
dentro de él a escala, PostgreSQL suele ser la apuesta a largo plazo más segura.
MySQL puede rendir bien con JSON, pero a menudo exige que “declares las partes importantes” mediante columnas generadas e índices dirigidos.
Si no lo haces, acabarás explicándole a la dirección por qué tu esquema flexible se convirtió en latencia inflexible.
Una cita que debería estar en cada runbook de on-call: “La esperanza no es una estrategia.”
— un máximo de operaciones ampliamente repetido (idea parafraseada).
Con JSON, esperar que la base de datos lo resuelva es la forma de comprarte un incidente de fin de semana.
Hechos e historia: cómo llegamos aquí
JSON en la base de datos parece moderno, pero la industria lleva décadas rondando esta idea: “almacena datos flexibles junto a los estructurados,
y consúltalos sin renunciar a la seguridad transaccional.” Los detalles difieren, y esos detalles son por qué estás leyendo esto en lugar de dormir.
8 hechos que vale la pena tener en la cabeza
- PostgreSQL añadió JSON en 9.2 (2012), luego introdujo JSONB en 9.4 (2014) para almacenamiento binario y mejor indexación.
- MySQL introdujo un tipo JSON nativo en 5.7 (2015); antes era TEXT con una oración y una regex.
- JSONB normaliza el orden de claves y elimina claves duplicadas (la última clave gana). Eso es genial para indexación, sorprendente para “almacenar exactamente lo que envié”.
- MySQL también almacena JSON en formato binario, y valida JSON al insertar, evitando algunos horrores de “blob inválido”.
- Los índices GIN de PostgreSQL se construyeron originalmente para búsqueda de texto completo, luego se convirtieron en la herramienta para contención de JSONB.
- Las columnas generadas de MySQL existen desde 5.7, y son la razón por la que muchos despliegues JSON en MySQL no colapsan.
- MVCC en PostgreSQL significa que las actualizaciones crean nuevas versiones de fila; las actualizaciones grandes de JSON pueden amplificar el bloat a menos que vacuum esté al día.
- Los formatos de replicación importan: binlog por fila de MySQL y logical decoding de PostgreSQL se comportan distinto bajo actualizaciones frecuentes de JSON y filas calientes.
Semántica JSON: qué almacenan realmente los motores
MySQL: JSON es un tipo, pero trátalo como documento a menos que proyectes campos
El tipo JSON de MySQL no es “TEXT con etiqueta.” Se valida, se almacena en una representación binaria y se manipula con funciones JSON.
Esa es la buena noticia. La noticia operacional es que rara vez obtendrás rendimiento sostenido a menos que hagas una de dos cosas:
(1) mantengas JSON mayormente de escritura-única/lectura-por-clave-primaria, o (2) extraigas las rutas consultadas con frecuencia en columnas generadas y las indexees.
MySQL te dejará escribir una consulta que parece selectiva pero no es indexable. El optimizador hará lo que pueda, luego escaneará.
A veces puedes rescatarla con índices funcionales (dependiendo de la versión) o columnas generadas, pero debes ser intencional.
PostgreSQL: JSONB es para consultar; JSON (texto) es para preservar la entrada exacta
PostgreSQL te ofrece dos filosofías diferentes:
json almacena el texto original (incluyendo espacios y orden), y
jsonb almacena un formato binario descompuesto optimizado para operadores e indexación.
Si quieres rendimiento, casi siempre querrás JSONB.
Los operadores de PostgreSQL son expresivos: contención (@>), existencia (?, ?|, ?&),
extracción por ruta (->, ->>, #>, #>>), y consultas JSON path.
Esa expresividad puede ser una trampa: la gente escribe filtros ingeniosos que parecen baratos y terminan con la CPU ahogada en descompresión o atascados en un índice que no coincide con el predicado.
Broma 1/2: JSON es como un cajón de sastre—todo cabe hasta que realmente necesitas encontrar las tijeras.
Indexación JSON: donde se gana o se pierde rendimiento
Indexación en MySQL: las columnas generadas son el movimiento adulto
En MySQL, indexar expresiones JSON arbitrarias ha mejorado con el tiempo, pero el patrón operacional más confiable sigue siendo:
define columnas generadas para las pocas rutas JSON que consultas todo el tiempo, cástralas a tipos escalares estables e índexalas.
Esto hace tres cosas:
- Le da al optimizador un índice B-tree normal que entiende.
- Evita extracciones JSON repetidas en tiempo de ejecución.
- Te obliga a admitir qué campos son realmente parte del “esquema real”.
La contrapartida: los cambios de esquema se vuelven más lentos y políticos, porque ahora el blob JSON tiene tentáculos en DDL y migraciones.
Eso no es un bug. Es el precio de fingir que los datos semiestructurados no tienen estructura (porque la tienen, una vez que dependes de ellos).
Cuando la indexación JSON de MySQL falla en la práctica
- Predicados excesivamente dinámicos (diferentes rutas JSON según la entrada del usuario) te empujan hacia escaneos.
- Comparar cadenas JSON con números provoca casts implícitos y rompe el uso de índices.
- Usar funciones en WHERE sin una expresión indexable hace que el optimizador se encoge de hombros y haga el trabajo de la forma lenta.
Indexación en PostgreSQL: GIN es potente, pero debes elegir la clase de operador
La historia de indexación JSONB de PostgreSQL es más fuerte, pero no es magia. Los índices GIN pueden acelerar contención y consultas de existencia,
pero tienen diferentes clases de operador:
- jsonb_ops: indexa más tipos de operaciones pero puede ser más grande.
- jsonb_path_ops: más compacto y rápido para contención, pero soporta menos operadores.
Si tu carga es “encuentra filas donde JSON contiene estos pares”, jsonb_path_ops suele ser la elección correcta.
Si necesitas existencia flexible y más soporte de operadores, jsonb_ops.
Elige mal y tendrás un índice que existe solo para hacer sufrir a VACUUM.
Índices de expresión: el puente práctico entre JSON y relacional
Si filtras frecuentemente por un campo extraído (por ejemplo, payload->>'customer_id'), un índice de expresión puede superar a un GIN amplio
en tamaño y predictibilidad. También es más fácil razonar sobre la selectividad.
Broma 2/2: Un índice GIN es como la cafeína—increíble cuando está dirigido, remordimiento cuando te excedes.
Patrones de consulta que separan “bien” de “arde todo”
Patrón 1: “Recuperar por id y devolver JSON” (poco riesgoso)
Tanto MySQL como PostgreSQL manejan esto bien. El coste dominante es I/O y tamaño de fila, no las funciones JSON.
Donde los equipos se lesionan es la lenta erosión: JSON crece, el tamaño de fila crece, la eficiencia de caché cae, y de repente “lecturas simples” se vuelven lecturas desde disco.
Patrón 2: “Filtrar por claves JSON con alta cardinalidad” (indexar o morir)
Si filtras por user_id, tenant_id, order_id dentro de JSON, en efecto estás filtrando por una clave relacional.
No finjas que es flexible. Promuévela: columna generada + índice en MySQL, índice de expresión en Postgres, o simplemente hazla una columna real.
Esto no es ideología. Es para evitar escaneos completos y planes de consulta inestables.
Patrón 3: “Analítica ad-hoc sobre JSON” (cuidado con la erosión lenta)
JSON atrae para analítica porque se autodescribe. En bases OLTP de producción, eso es una trampa.
La analítica ad-hoc tiende a:
- Usar funciones sobre muchas filas, causando consumo de CPU.
- Forzar escaneos secuenciales porque los predicados no coinciden con índices.
- Serializar tu carga en una tabla grande y un subsistema de disco caliente.
Si el negocio quiere analítica, o separa una réplica de reporting con guardrails estrictos, o transmite eventos a otro sitio.
“Solo ejecútalo en prod” es una decisión de presupuesto disfrazada de decisión de ingeniería.
Patrón 4: actualizaciones parciales a JSON (filas calientes, logs pesados)
Ambas bases pueden actualizar rutas dentro de JSON, pero las características de rendimiento difieren y el impacto operacional es parecido:
actualizaciones frecuentes a documentos JSON grandes significan más bytes escritos, más churn de índices, más trabajo de replicación y más invalidación de caché.
La regla práctica: si un campo JSON se actualiza con frecuencia y se lee con frecuencia, merece una columna real o una tabla separada.
JSON no es un pase libre sobre la normalización; es una factura diferida.
Actualizaciones, WAL/binlog y retraso de replicación
MySQL: volumen de binlog y realidades de replicación por fila
En MySQL, las actualizaciones grandes de JSON pueden producir eventos de binlog grandes—especialmente con replicación por fila. Si actualizas muchas filas o documentos grandes,
tus réplicas pagan el precio. El retraso de replicación rara vez es “un problema de la réplica”. Es un problema de amplificación de escritura de la aplicación.
También vigila el tamaño de las transacciones y la frecuencia de commits. Una carga que actualiza JSON en ráfagas puede crear picos desagradables: presión de fsync,
stalls en el flush de binlog y backlog del hilo SQL en la réplica.
PostgreSQL: presión de WAL + churn de MVCC
PostgreSQL escribe WAL por cambios, y MVCC significa que las actualizaciones crean nuevas versiones de fila. Actualiza frecuentemente un campo JSONB grande y obtendrás:
más WAL, más tuples muertas, más trabajo de vacuum y potencialmente más bloat de índices.
El retraso de replicación se manifiesta como backlog del WAL sender o demora de replay. La clave es distinguir:
la réplica no puede aplicar lo bastante rápido (limitada por CPU/I/O aplicando cambios) vs
el primario produce demasiado WAL (amplificación de escritura).
Guía operacional
- Mide bytes de WAL/binlog por segundo durante el pico. Es lo más cercano a la “verdad” sobre amplificación de escritura.
- Particiona o separa campos JSON calientes si las tasas de actualización son altas.
- En PostgreSQL, ajusta autovacuum para tablas con muchas actualizaciones de JSON, o la deuda de vacuum aparecerá como deuda de latencia.
Realidad de almacenamiento e I/O: bloat, churn de páginas y comportamiento de caché
Tamaño de fila y caché: tu impuesto invisible
Las columnas JSON agrandan las filas. Filas más grandes significan menos filas por página. Menos filas por página significa más lecturas de página para el mismo número de filas lógicas.
Esto se manifiesta como:
- Mayor churn en el buffer pool de MySQL (InnoDB).
- Más churn en shared_buffers de PostgreSQL.
- Más presión sobre la caché de páginas del SO.
La mayoría de las “regresiones de rendimiento misteriosas” tras añadir JSON son en realidad “duplicamos el tamaño de fila y nadie ajustó la memoria o patrones de acceso.”
Bloat en PostgreSQL: MVCC significa que le debes el collector vacuum
PostgreSQL no actualiza en sitio; crea nuevas versiones de fila. Si JSONB es grande y se actualiza frecuentemente, se acumulan tuples muertas y los índices churnean.
Autovacuum puede manejar mucho, pero necesita los umbrales correctos. Los ajustes por defecto están diseñados para ser seguros para principiantes, no óptimos para tu desastre.
MySQL: índices secundarios y presión de undo/redo
InnoDB de MySQL tiene su propia amplificación de escritura: redo logs, undo logs, doublewrite buffer, mantenimiento de índices secundarios.
Las actualizaciones grandes de JSON aumentan los bytes tocados y pueden empujarte a stalls de flush de logs. Lo verás como picos intermitentes de latencia,
“commits que de repente son lentos” y réplicas quedándose atrás.
Tareas prácticas: 14 comandos que puedes ejecutar hoy
Estos son los tipos de comandos que echo a correr durante un incidente o una revisión de rendimiento. Cada tarea incluye:
el comando, qué significa la salida y qué decisión tomar a continuación.
Los hostnames y rutas son deliberadamente aburridos; lo aburrido se repite.
Tarea 1 (MySQL): confirmar uso de JSON y presión de tamaño
cr0x@server:~$ mysql -e "SELECT table_schema, table_name, column_name, data_type FROM information_schema.columns WHERE data_type='json' ORDER BY table_schema, table_name;"
+--------------+------------+-------------+-----------+
| table_schema | table_name | column_name | data_type |
+--------------+------------+-------------+-----------+
| app | events | payload | json |
| app | users | attrs | json |
+--------------+------------+-------------+-----------+
Significado: ahora sabes qué tablas son candidatas a dolores relacionados con JSON.
Decisión: preselecciona las 1–3 tablas principales por recuento de filas y tasa de actualización. Ahí es donde importan la indexación y las decisiones de esquema.
Tarea 2 (MySQL): comprobar tamaños de tablas y huella de índices
cr0x@server:~$ mysql -e "SELECT table_name, table_rows, ROUND(data_length/1024/1024,1) AS data_mb, ROUND(index_length/1024/1024,1) AS index_mb FROM information_schema.tables WHERE table_schema='app' ORDER BY data_length DESC LIMIT 10;"
+------------+------------+---------+----------+
| table_name | table_rows | data_mb | index_mb |
+------------+------------+---------+----------+
| events | 4821031 | 8120.4 | 2104.7 |
| users | 820114 | 1190.8 | 412.2 |
+------------+------------+---------+----------+
Significado: las tablas con mucho JSON tienden a inflar data_mb.
Decisión: si data_mb crece más rápido que el crecimiento del negocio, necesitas limitar el tamaño del payload, comprimir aguas arriba o normalizar campos calientes.
Tarea 3 (MySQL): identificar predicados JSON lentos en el slow log
cr0x@server:~$ sudo pt-query-digest /var/log/mysql/mysql-slow.log --limit 5
# 1.2s user time, 40ms system time, 27.31M rss, 190.55M vsz
# Query 1: 0.68 QPS, 0.31x concurrency, ID 0xA1B2C3D4 at byte 91234
# Time range: 2025-12-28T00:00:00 to 2025-12-28T01:00:00
# Attribute pct total min max avg 95% stddev median
# ============ === ======= ======= ======= ======= ======= ======= =======
# Exec time 62 180s 120ms 12s 540ms 3s 900ms 300ms
# Rows examine 90 1200M 10 2.5M 360k 1.1M 500k 200k
# Query: SELECT ... WHERE JSON_EXTRACT(payload,'$.customer.id') = ?
Significado: rows examined es tu “impuesto por escaneo.” JSON_EXTRACT en WHERE sin índice es un sospechoso habitual.
Decisión: crea una columna generada para esa ruta (o un índice funcional si es apropiado) y reescribe la consulta para usarla.
Tarea 4 (MySQL): verificar si una consulta usa un índice
cr0x@server:~$ mysql -e "EXPLAIN SELECT id FROM app.events WHERE JSON_UNQUOTE(JSON_EXTRACT(payload,'$.customer.id'))='12345' LIMIT 10\G"
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 4821031
filtered: 10.00
Extra: Using where
Significado: type: ALL y sin key significa escaneo completo de tabla.
Decisión: no optimices buffers primero. Arregla el esquema/consulta: columna generada + índice, o rediseña.
Tarea 5 (MySQL): añadir una columna generada para una ruta JSON caliente
cr0x@server:~$ mysql -e "ALTER TABLE app.events ADD COLUMN customer_id VARCHAR(64) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(payload,'$.customer.id'))) STORED, ADD INDEX idx_events_customer_id (customer_id);"
Query OK, 0 rows affected (2 min 41 sec)
Records: 0 Duplicates: 0 Warnings: 0
Significado: la columna generada STORED materializa el valor, el índice pasa a ser utilizable.
Decisión: reescribe las consultas de la aplicación para filtrar por customer_id en lugar de JSON_EXTRACT en WHERE. Luego vuelve a comprobar EXPLAIN.
Tarea 6 (MySQL): validar que el optimizador ahora usa el nuevo índice
cr0x@server:~$ mysql -e "EXPLAIN SELECT id FROM app.events WHERE customer_id='12345' LIMIT 10\G"
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events
type: ref
possible_keys: idx_events_customer_id
key: idx_events_customer_id
key_len: 258
ref: const
rows: 120
Extra: Using index
Significado: pasaste de escanear millones a tocar ~120 filas.
Decisión: despliega el cambio y observa la latencia de escritura: mantener el nuevo índice aumenta el coste de escritura.
Tarea 7 (MySQL): comprobar retraso de replicación y presión de aplicación
cr0x@server:~$ mysql -e "SHOW REPLICA STATUS\G" | egrep "Seconds_Behind_Source|Replica_SQL_Running|Replica_IO_Running|Last_SQL_Error"
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 87
Last_SQL_Error:
Significado: hay lag aunque los threads estén activos. Usualmente la aplicación no puede aplicar tan rápido como escribe.
Decisión: mide la tasa de binlog y el tamaño de las transacciones; reduce el volumen de actualizaciones JSON o cambia el batching antes de culpar a la réplica.
Tarea 8 (PostgreSQL): listar columnas JSON/JSONB y sus tablas
cr0x@server:~$ psql -d appdb -c "SELECT table_schema, table_name, column_name, data_type FROM information_schema.columns WHERE data_type IN ('json','jsonb') ORDER BY 1,2,3;"
table_schema | table_name | column_name | data_type
--------------+------------+-------------+-----------
public | events | payload | jsonb
public | users | attrs | jsonb
(2 rows)
Significado: alcance. Igual que MySQL: identifica las pocas tablas que más importan.
Decisión: céntrate primero en tablas con altas tasas de actualización y consultas cara al cliente.
Tarea 9 (PostgreSQL): encontrar las peores consultas JSON por tiempo total
cr0x@server:~$ psql -d appdb -c "SELECT calls, total_exec_time::bigint AS total_ms, mean_exec_time::numeric(10,2) AS mean_ms, rows, query FROM pg_stat_statements WHERE query ILIKE '%jsonb%' OR query ILIKE '%->%' OR query ILIKE '%@>%' ORDER BY total_exec_time DESC LIMIT 5;"
calls | total_ms | mean_ms | rows | query
-------+----------+---------+------+-------------------------------------------
18211 | 932144 | 51.20 | 0 | SELECT ... WHERE payload @> $1
4102 | 512030 | 124.82 | 0 | SELECT ... WHERE (payload->>'customer')= $1
(2 rows)
Significado: tienes consultas calientes, no teorías.
Decisión: ejecuta EXPLAIN (ANALYZE, BUFFERS) en los principales culpables y construye el índice correcto para la forma del predicado.
Tarea 10 (PostgreSQL): inspeccionar un plan de consulta JSONB con buffers
cr0x@server:~$ psql -d appdb -c "EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM events WHERE payload @> '{\"customer\":{\"id\":\"12345\"}}'::jsonb LIMIT 10;"
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------
Limit (cost=0.42..8.44 rows=10 width=8) (actual time=0.088..0.146 rows=10 loops=1)
Buffers: shared hit=42
-> Index Scan using idx_events_payload_gin on events (cost=0.42..22134.77 rows=26235 width=8) (actual time=0.086..0.141 rows=10 loops=1)
Index Cond: (payload @> '{"customer": {"id": "12345"}}'::jsonb)
Buffers: shared hit=42
Planning Time: 0.412 ms
Execution Time: 0.182 ms
(7 rows)
Significado: index scan + mayormente buffer hits = saludable.
Decisión: conserva este índice si soporta rutas de producto centrales. Si es solo para consultas ad-hoc, no pagues el impuesto de escritura.
Tarea 11 (PostgreSQL): crear un GIN dirigido (elige la clase de operador)
cr0x@server:~$ psql -d appdb -c "CREATE INDEX CONCURRENTLY idx_events_payload_pathops ON events USING gin (payload jsonb_path_ops);"
CREATE INDEX
Significado: la construcción concurrente evita bloquear escrituras (a costa de tiempo y trabajo extra).
Decisión: usa jsonb_path_ops cuando la contención domine; de lo contrario considera jsonb_ops o índices de expresión.
Tarea 12 (PostgreSQL): construir un índice de expresión para una ruta caliente
cr0x@server:~$ psql -d appdb -c "CREATE INDEX CONCURRENTLY idx_events_customer_id_expr ON events ((payload->'customer'->>'id'));"
CREATE INDEX
Significado: esto hace que los predicados de igualdad sobre ese valor extraído sean previsibles y baratos.
Decisión: si esta ruta es estable y muy usada, considera promoverla a una columna real para reducir el procesamiento JSON por completo.
Tarea 13 (PostgreSQL): verificar señales de bloat y efectividad de autovacuum
cr0x@server:~$ psql -d appdb -c "SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, last_vacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 5;"
relname | n_live_tup | n_dead_tup | last_autovacuum | last_vacuum
---------+------------+------------+------------------------+------------------------
events | 4809123 | 912044 | 2025-12-28 00:41:12+00 | 2025-12-22 03:11:02+00
users | 820104 | 12033 | 2025-12-28 00:38:01+00 | 2025-12-23 02:08:40+00
(2 rows)
Significado: las tuples muertas en events son altas; autovacuum está corriendo, pero puede estar subdimensionado para el churn de actualizaciones.
Decisión: ajusta umbrales de autovacuum por tabla, reduce la frecuencia de actualizaciones a JSONB grandes, o separa campos mutables.
Tarea 14 (Nivel sistema): identificar si estás limitado por I/O o CPU
cr0x@server:~$ iostat -x 1 5
Linux 6.1.0 (db01) 12/29/2025 _x86_64_ (16 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
22.11 0.00 6.34 18.90 0.00 52.65
Device r/s rkB/s rrqm/s %rrqm r_await rareq-sz w/s wkB/s w_await wareq-sz aqu-sz %util
nvme0n1 320.0 18240.0 0.0 0.00 4.20 57.00 410.0 24576.0 9.80 59.95 6.10 92.0
Significado: %util alto y iowait significativo apuntan a saturación de almacenamiento. Las cargas JSON a menudo inflan I/O debido a filas más grandes y churn de índices.
Decisión: arregla patrones de consulta/índice primero; si sigue saturado, escala IOPS (mejores discos) o reduce la amplificación de escritura (cambios de esquema/diseño).
Guion de diagnóstico rápido
Cuando las consultas JSON se vuelven lentas, la gente pierde horas discutiendo “elección de base de datos” en lugar de encontrar el verdadero cuello de botella.
Este guion es el orden en que lo ejecutaría en un incidente—porque converge rápido.
Primero: demuestra si es un escaneo, una falta de índice o I/O bruto
- MySQL: ejecuta
EXPLAINen la consulta lenta. Sitype: ALL, para y arregla el predicado/índice. - PostgreSQL: ejecuta
EXPLAIN (ANALYZE, BUFFERS). Si ves sequential scans en tablas grandes, necesitas un índice coincidente o reescribir la consulta. - Sistema: comprueba
iostat -x. Si el almacenamiento está al 100%, los escaneos y el bloat serán tus sospechosos principales.
Segundo: cuantifica la amplificación de escritura y presión de replicación
- MySQL: inspecciona el retraso de replicación y patrones de crecimiento del binlog; las actualizaciones grandes de JSON suelen correlacionarse con picos de lag.
- PostgreSQL: comprueba la generación de WAL y las tuples muertas; las actualizaciones intensas de JSON pueden convertir el vacuum en una crisis de fondo permanente.
Tercero: revisa la efectividad de caché y la erosión del tamaño de fila
- ¿Tu working set caliente sigue en memoria, o el crecimiento de JSON lo expulsó?
- ¿Añadiste un GIN amplio que duplicó el coste de escritura?
- ¿Alguien empezó a hacer filtros ad-hoc sobre claves JSON sin indexar?
Cuarto: arregla lo más pequeño que cambie la curva
- Promueve claves calientes a columnas reales (mejor) o a columnas generadas/índices de expresión (siguiente mejor).
- Añade el índice más adecuado para la forma del predicado, y valida con EXPLAIN.
- Si las actualizaciones son el problema, separa campos mutables del blob JSON.
Errores comunes: síntomas → causa raíz → solución
Error 1: “La consulta parece selectiva pero es lenta”
Síntomas: la latencia crece con el tamaño de la tabla; EXPLAIN muestra escaneo completo; CPU se dispara en pico.
Causa raíz: extracción JSON en WHERE sin una expresión indexable (MySQL), o desacople entre operador e índice (PostgreSQL).
Solución: MySQL: columna generada STORED + índice B-tree; PostgreSQL: índice de expresión o clase de operador GIN correcta; reescribe el predicado para que coincida con el índice.
Error 2: “Añadimos un índice GIN y las escrituras se volvieron más lentas”
Síntomas: la latencia de insert/update aumenta; la tasa de WAL/binlog sube; el retraso de replicación empeora tras crear el índice.
Causa raíz: GIN amplio sobre JSONB grande con actualizaciones frecuentes; alto coste de mantenimiento de índice.
Solución: reemplaza por índices de expresión más estrechos; usa jsonb_path_ops si es solo contención; separa campos mutables; reconsidera si necesitas esa consulta en OLTP.
Error 3: “Postgres está lento con el tiempo; vacuum no da abasto”
Síntomas: tamaños de tabla e índice crecen; consultas lentas; autovacuum corre constantemente; tuples muertas altas.
Causa raíz: actualizaciones frecuentes a JSONB grandes crean muchas tuples muertas; umbrales de autovacuum no sintonizados para el churn de la tabla.
Solución: ajusta autovacuum por tabla; reduce frecuencia/tamaño de actualizaciones; mueve datos mutables a tabla separada; considera particionado para tablas tipo evento.
Error 4: “Lag de replicación en MySQL tras añadir características JSON”
Síntomas: Seconds_Behind_Source sube durante ráfagas; réplicas se recuperan lentamente; commits son espaciados.
Causa raíz: grandes eventos de binlog por fila derivados de actualizaciones JSON; transacciones sobredimensionadas; demasiados índices secundarios en proyecciones JSON.
Solución: reduce volumen de actualizaciones JSON; agrupa las operaciones de otra manera; limita las proyecciones indexadas a rutas realmente calientes; verifica ajustes de binlog/redo log y patrones de commit.
Error 5: “Almacenamos todo en JSON y ahora necesitamos restricciones”
Síntomas: valores inconsistentes en JSON; validaciones en la aplicación que se desbordan; consultas deben manejar claves faltantes y tipos erróneos.
Causa raíz: esquema externalizado al código de la aplicación; sin restricciones aplicadas; migraciones evitadas hasta demasiado tarde.
Solución: promueve campos clave a columnas; añade CHECK constraints (Postgres) o aplica columnas generadas + NOT NULL/tipos (MySQL); introduce payloads versionados.
Tres micro-historias corporativas desde las trincheras JSON
1) Incidente causado por una suposición errónea: “JSON es básicamente gratis de consultar”
Una compañía SaaS mediana lanzó un “feed de actividad” respaldado por una tabla de eventos. Cada evento tenía un payload JSON.
El equipo de producto quería filtrado: “mostrar solo eventos donde payload.actor.role = ‘admin’.” Fácil, pensaron.
El backend usaba MySQL, y la primera implementación usó JSON_EXTRACT en la cláusula WHERE.
En staging iba bien. En producción fue un desastre en cámara lenta: la tabla events era grande, y el filtro era popular.
La consulta parecía selectiva, pero hacía un escaneo completo, tocando millones de filas por petición en pico.
CPU al máximo, I/O saturado y todo el clúster desarrolló el síntoma de “todo es lento” que hace que los ejecutivos entren al canal de incidentes.
La suposición equivocada no fue “MySQL no puede hacer JSON.” Fue: “si el predicado es estrecho, la base de datos lo optimizará.”
Las bases optimizan lo que indexas, no lo que esperas. La extracción JSON sin expresión indexable no es estrecha; es matemática cara repetida en muchas filas.
La solución fue dolorosamente directa: añadir una columna generada STORED para actor_role, indexarla y cambiar la consulta.
El postmortem añadió una regla: cualquier clave JSON usada en un WHERE crítico debe proyectarse e indexarse, o moverse a una columna real.
El esquema flexible siguió existiendo, pero solo donde no estaba en la ruta crítica.
2) Optimización que salió mal: “Solo añade un GIN grande”
Otra empresa usaba PostgreSQL y tenía una única tabla masiva events con payloads JSONB.
Querían búsquedas ad-hoc más rápidas para soporte al cliente, así que alguien añadió un GIN amplio sobre todo el payload usando la clase de operador por defecto.
La velocidad de consulta mejoró al instante. Todos celebraron y siguieron adelante.
Dos semanas después, la latencia de escritura empezó a crecer. La actividad de autovacuum se volvió constante. Apareció retraso de replicación en picos.
El índice GIN era caro de mantener porque los payloads eran grandes y se actualizaban con campos de enriquecimiento.
El índice también creció rápido, aumentando la presión de checkpoints e I/O. La victoria de “búsqueda de soporte” se convirtió en “cada endpoint API es más lento”.
El problema no fue que GIN sea malo. Fue que indexaron todo, para una carga de consultas que en realidad no era central.
El índice convirtió la base de datos en un motor de búsqueda. PostgreSQL puede hacer eso, pero se paga en amplificación de escritura y bloat.
La solución eventual: eliminar el índice amplio, añadir dos índices de expresión para un puñado de claves usadas en filtros de soporte,
y sacar la búsqueda tipo texto completo fuera del camino OLTP. Soporte recuperó su flujo, pero producción dejó de pagar el impuesto en cada escritura.
3) Práctica aburrida pero correcta que salvó el día: “Haz del JSON un contrato, versiona y prueba”
Un equipo fintech almacenó metadata de verificación de clientes en JSONB en PostgreSQL. Incluía campos anidados, claves opcionales y bloques específicos por proveedor.
Sabían que estos datos evolucionarían, y también sabían que tendrían que consultar algunos campos de forma fiable para informes de cumplimiento.
Así que hicieron algo que parece poco sexy: añadieron una columna schema_version entero y escribieron migraciones explícitas para cambios en la forma del payload.
También promovieron unos campos críticos a columnas reales: customer_id, verification_status y vendor_name.
Todo lo demás vivía en JSONB. Encima de eso, tuvieron CHECK constraints que aseguraban que la columna status coincidiera con un conjunto conocido,
y tests de aplicación que validaban compatibilidad de esquema por versión.
Meses después, un proveedor cambió su formato de payload de forma sutil (un campo se movió más profundo).
Los equipos que almacenan JSON crudo sin contrato suelen descubrirlo cuando los informes fallan a las 2 a.m.
Este equipo lo descubrió en CI, porque un test de validación de esquema falló y la herramienta de migración obligó a una transformación explícita.
La práctica “aburrida” no fue un índice sofisticado. Fue tratar JSON como un contrato versionado, no como un cajón de sastre sin límites.
Producción se benefició: el rendimiento de consultas se mantuvo estable y la frecuencia de incidentes bajó—ese tipo de victoria que nunca recibe un correo de celebración.
Listas de verificación / plan paso a paso
Si estás iniciando una nueva feature con mucho JSON
- Escribe los 5 principales patrones de consulta que esperas en los próximos seis meses (no solo la semana de lanzamiento).
- Clasifica campos: inmutables vs mutables; frecuentemente filtrados vs raramente filtrados; alta cardinalidad vs baja cardinalidad.
- Promueve los campos “frecuentemente filtrados y alta cardinalidad” a columnas reales (preferido) o a columnas generadas/índices de expresión.
- Elige la estrategia de indexación específica por base de datos:
- MySQL: columnas generadas STORED + índices B-tree; evita JSON_EXTRACT en cláusulas WHERE calientes.
- PostgreSQL: índices de expresión para rutas calientes; GIN para contención/existencia; selecciona la clase de operador intencionalmente.
- Fija presupuestos de tamaño de payload (límites suaves y duros). El crecimiento de JSON es silencioso hasta que no lo es.
- Planifica la evolución: añade
schema_version, documenta transformaciones y haz las migraciones rutinarias.
Si ya lo lanzaste y está lento
- Encuentra las 3 consultas principales por tiempo total (slow log / pg_stat_statements).
- Ejecuta EXPLAIN con la realidad (MySQL EXPLAIN, Postgres EXPLAIN ANALYZE BUFFERS). No adivines.
- Añade el índice más pequeño que coincida con el predicado (índice de columna generada o índice de expresión) y verifica cambios de plan.
- Mide el coste del lado escribiente tras indexar (latencia de commit, tasa WAL/binlog, retraso de replicación).
- Si las actualizaciones son intensas, separa campos mutables del JSON y ponlos en otra tabla con clave correcta.
- Pon guardrails a las consultas ad-hoc (timeouts, réplicas de lectura o una ruta de reporting dedicada).
Si estás decidiendo entre MySQL y PostgreSQL para JSON hoy
- Elige PostgreSQL si la consulta JSON es una característica de producto, no un detalle de implementación.
- Elige MySQL si JSON es mayormente almacenamiento y estás dispuesto a proyectar las claves calientes en columnas generadas indexadas.
- Elige la base de datos que tu equipo pueda operar en condiciones de incidente. Una característica teóricamente superior no despertará a tu on-call a las 3 a.m.
Preguntas frecuentes
1) ¿PostgreSQL siempre es mejor para JSON que MySQL?
No. PostgreSQL suele ser mejor para consultas complejas y indexación flexible. MySQL puede ser excelente cuando mantienes el uso de JSON simple
o proyectas rutas calientes en columnas generadas indexadas. “Siempre” es como empiezan los outages.
2) ¿Debería almacenar JSON como TEXT/VARCHAR en su lugar?
Usualmente no. Pierdes validación y muchos operadores JSON. Si realmente nunca consultas dentro del JSON y solo lo almacenas y recuperas,
TEXT puede servir—pero te cargas el riesgo de higiene de datos. Los tipos JSON nativos son más seguros para la corrección.
3) ¿Cuándo debe una clave JSON convertirse en una columna real?
Si se usa en joins, en WHERE calientes, para ordenación o se necesita para restricciones, debe ser columna. Si se actualiza frecuentemente,
probablemente sea columna o tabla separada. JSON es para variabilidad, no para identidad central.
4) ¿Los índices GIN resuelven el rendimiento de JSONB en PostgreSQL?
Resuelven algunos problemas. También pueden crear otros (coste de escritura, bloat, mantenimiento).
Usa GIN cuando tus predicados se alineen con contención/existencia y los datos indexados sean lo bastante estables para justificar el impuesto de escritura.
5) ¿Cuál es el equivalente en MySQL de un índice GIN en JSONB de Postgres?
No hay un equivalente directo. En MySQL típicamente creas columnas generadas que extraen valores escalares e indizas esos valores.
Es una filosofía diferente: decides qué importa desde el principio.
6) ¿Cómo evito “claves aleatorias por todas partes” en JSON?
Trata JSON como un contrato: versiona, valida y documenta las formas permitidas.
Aplica invariantes críticas con restricciones de base de datos (Postgres) o columnas generadas + NOT NULL/casts de tipo (MySQL).
7) ¿Por qué las actualizaciones parciales de JSON siguen sintiéndose caras?
Porque la “actualización parcial” a nivel SQL aún puede significar reescritura sustancial y churn de índices a nivel de almacenamiento,
además de volumen de WAL/binlog. Documentos grandes actualizados frecuentemente son caros, independientemente de lo bonito que se vea el SQL.
8) ¿Puedo usar JSON para datos multi-tenant y simplemente filtrar por tenant_id dentro del JSON?
Puedes, pero no deberías. El aislamiento por tenant pertenece a una columna real con índice.
Ponerlo en JSON facilita escanear accidentalmente entre tenants y complica hacer cumplir límites de rendimiento y restricciones.
9) ¿Cuál es el patrón híbrido más seguro?
Almacena campos centrales como columnas (ids, status, timestamps, claves foráneas), guarda campos opcionales/específicos de proveedor en JSON/JSONB,
e indexa solo el subconjunto pequeño de rutas JSON que realmente consultas. Todo lo demás permanece flexible sin impulsar el coste de las consultas centrales.
Conclusión: próximos pasos que no te avergüencen después
JSON en MySQL y PostgreSQL ya no es una novedad. Es una herramienta de producción—y como todas las herramientas de producción, recompensa la disciplina.
MySQL tiende a querer que proyectes estructura fuera del JSON e indexes explícitamente. PostgreSQL te da consultas e indexación más expresivas,
pero te cobrará en WAL, bloat y mantenimiento si indexas demasiado o actualizas campos JSONB grandes con demasiada frecuencia.
Pasos prácticos siguientes:
- Identifica las 3 consultas JSON principales por tiempo total y ejecuta EXPLAIN con estadísticas reales de ejecución.
- Promueve las 3 claves JSON principales usadas para filtrado/joining a columnas o columnas generadas/índices de expresión y indexalas.
- Mide la amplificación de escritura (tasa WAL/binlog) antes y después de indexar; vigila el retraso de replicación.
- Establece un presupuesto de tamaño de payload e implémentalo en la ingestión.
- Versiona tus payloads JSON. El tú del futuro, de lo contrario, pasará un fin de semana descifrando “por qué a veces existe esta clave”.
Elige la base de datos que coincida con las fortalezas operativas de tu equipo, y luego diseña el uso de JSON como si esperases que se vuelva permanente—porque normalmente lo hace.