Esta publicación cubre una guía práctica de las funciones JSON de MySQL 8.0. El soporte para JSON ha estado en MySQL desde 5.7, pero 8.0 añadió un conjunto significativo de mejoras —mejores estrategias de indexación, nuevas funciones y índices multi-valor— que hacen que trabajar con datos JSON sea considerablemente más práctico. Lo siguiente documenta varios de los patrones más comúnmente necesarios, incluyendo la salida de EXPLAIN y observaciones de rendimiento que vale la pena conocer.
Esta no es una publicación de debate "JSON vs. relacional". Si estás almacenando JSON en MySQL, probablemente ya tienes tus razones. El objetivo aquí es asegurarte de que estás usando las herramientas disponibles de manera efectiva.
Entorno
mysql> SELECT @@version, @@version_comment\G
*************************** 1. row ***************************
@@version: 8.0.36
@@version_comment: MySQL Community Server - GPL
Las pruebas se realizaron en una VM con 8GB de RAM y innodb_buffer_pool_size configurado en 4G. Una nota de mantenimiento que vale la pena mencionar: query_cache_type es irrelevante en 8.0 ya que la caché de consultas fue eliminada por completo. Si migraste una instancia de 5.7 y aún tienes esa variable en tu my.cnf, elimínala — MySQL 8.0 lanzará un error de inicio.
Configuración de una Tabla de Prueba
La tabla de prueba simula un patrón bastante común — una aplicación que almacena datos de perfil de usuario y metadatos de eventos como bloques JSON:
CREATE TABLE user_events (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id INT UNSIGNED NOT NULL,
event_data JSON NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_user (user_id)
) ENGINE=InnoDB;
INSERT INTO user_events (user_id, event_data) VALUES
(1, '{"action":"login","ip":"192.168.1.10","tags":["mobile","vpn"],"score":88}'),
(1, '{"action":"purchase","ip":"192.168.1.10","tags":["desktop"],"score":72,"amount":49.99}'),
(2, '{"action":"login","ip":"10.0.0.5","tags":["mobile"],"score":91}'),
(3, '{"action":"logout","ip":"10.0.0.9","tags":["desktop","vpn"],"score":65}'),
(2, '{"action":"purchase","ip":"10.0.0.5","tags":["mobile"],"score":84,"amount":129.00}');
Extracción Básica: JSON_VALUE vs. JSON_EXTRACT
JSON_VALUE() fue introducida en MySQL 8.0.21 y es la forma más limpia de extraer valores escalares con conversión de tipo incorporada. Antes de eso, usabas JSON_EXTRACT() (o la abreviatura ->) y convertías manualmente, lo que funciona pero añade ruido a tus consultas.
-- Pre-8.0.21 approach
SELECT user_id,
JSON_EXTRACT(event_data, '$.action') AS action,
CAST(JSON_EXTRACT(event_data, '$.score') AS UNSIGNED) AS score
FROM user_events;
-- Cleaner 8.0.21+ approach
SELECT user_id,
JSON_VALUE(event_data, '$.action') AS action,
JSON_VALUE(event_data, '$.score' RETURNING UNSIGNED) AS score
FROM user_events;
Salida de la segunda consulta:
+---------+----------+-------+
| user_id | action | score |
+---------+----------+-------+
| 1 | login | 88 |
| 1 | purchase | 72 |
| 2 | login | 91 |
| 3 | logout | 65 |
| 2 | purchase | 84 |
+---------+----------+-------+
5 rows in set (0.00 sec)
La cláusula RETURNING es genuinamente útil. Elimina el patrón incómodo de doble conversión y hace que la intención sea más clara al leer el código de la consulta más tarde.
Índices Multi-Valor: El Verdadero Cambiador de Juego
Aquí es donde 8.0 realmente movió la aguja para las cargas de trabajo JSON. Los índices multi-valor, disponibles desde MySQL 8.0.17, te permiten indexar elementos de array dentro de una columna JSON directamente. Así es como se ve en la práctica:
ALTER TABLE user_events
ADD INDEX idx_tags ((CAST(event_data->'$.tags' AS CHAR(64) ARRAY)));
Aquí es lo que muestra EXPLAIN antes y después en una consulta que filtra por valor de etiqueta:
-- Without the multi-valued index:
EXPLAIN SELECT * FROM user_events
WHERE JSON_CONTAINS(event_data->'$.tags', '"vpn"')\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: user_events
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 5
filtered: 100.00
Extra: Using where
-- After adding the multi-valued index:
EXPLAIN SELECT * FROM user_events
WHERE JSON_CONTAINS(event_data->'$.tags', '"vpn"')\G
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: user_events
partitions: NULL
type: range
possible_keys: idx_tags
key: idx_tags
key_len: 67
ref: NULL
rows: 2
filtered: 100.00
Extra: Using where
Barrido completo de tabla reducido a un barrido de rango. En 5 filas esto es trivial, pero en una tabla con millones de filas y filtrado frecuente basado en etiquetas, esa diferencia es significativa. La mejora escala directamente con el tamaño de la tabla y la frecuencia de las consultas.
Un importante inconveniente: MEMBER OF() y JSON_OVERLAPS() también se benefician de los índices multi-valor, pero JSON_SEARCH() no. Esto importa al elegir tu patrón de consulta en tiempo de diseño:
-- This WILL use the multi-valued index:
SELECT * FROM user_events
WHERE 'vpn' MEMBER OF (event_data->'$.tags');
-- This will NOT use it:
SELECT * FROM user_events
WHERE JSON_SEARCH(event_data->'$.tags', 'one', 'vpn') IS NOT NULL;
Aggregating and Transforming JSON
``````html-- Build a JSON array of actions per user
SELECT user_id,
JSON_ARRAYAGG(JSON_VALUE(event_data, '$.action')) AS actions
FROM user_events
GROUP BY user_id;
+---------+----------------------+
| user_id | actions |
+---------+----------------------+
| 1 | ["login","purchase"] |
| 2 | ["login","purchase"] |
| 3 | ["logout"] |
+---------+----------------------+
3 rows in set (0.01 sec)
-- Summarize into a JSON object keyed by action
SELECT user_id,
JSON_OBJECTAGG(
JSON_VALUE(event_data, '$.action'),
JSON_VALUE(event_data, '$.score' RETURNING UNSIGNED)
) AS score_by_action
FROM user_events
GROUP BY user_id;
+---------+--------------------------------+
| user_id | score_by_action |
+---------+--------------------------------+
| 1 | {"login": 88, "purchase": 72} |
| 2 | {"login": 91, "purchase": 84} |
| 3 | {"logout": 65} |
+---------+--------------------------------+
3 rows in set (0.00 sec)
JSON_OBJECTAGG() lanzará un error si hay claves duplicadas dentro de un grupo. Esto es importante saberlo antes de encontrártelo en un pipeline ETL de producción. En ese caso, necesitarás desduplicar aguas arriba o manejarlo en la lógica de la aplicación antes de que los datos lleguen a este paso de agregación.
Comprobando SHOW STATUS después de consultas intensivas en JSON
Al evaluar patrones de consultas, comprobar las métricas de handler es un hábito útil:
FLUSH STATUS;
SELECT * FROM user_events
WHERE JSON_VALUE(event_data, '$.score' RETURNING UNSIGNED) > 80;
SHOW STATUS LIKE 'Handler_read%';
+----------------------------+-------+
| Variable_name | Value |
+----------------------------+-------+
| Handler_read_first | 1 |
| Handler_read_key | 0 |
| Handler_read_last | 0 |
| Handler_read_next | 4 |
| Handler_read_prev | 0 |
| Handler_read_rnd | 0 |
| Handler_read_rnd_next | 6 |
+----------------------------+-------+
7 rows in set (0.00 sec)
El valor de Handler_read_rnd_next confirma un escaneo completo — no es una sorpresa ya que no hay un índice funcional en el valor de score. Para filtrado basado en score a escala, una columna generada con un índice es la solución correcta:
ALTER TABLE user_events
ADD COLUMN score_val TINYINT UNSIGNED
GENERATED ALWAYS AS (JSON_VALUE(event_data, '$.score' RETURNING UNSIGNED)) VIRTUAL,
ADD INDEX idx_score (score_val);
Después de agregarlo, la misma consulta cae a un escaneo de rango de índice adecuado. Las columnas generadas en campos JSON están disponibles tanto en MySQL 8.0 como en Percona Server 8.0, y siguen siendo el camino más confiable para filtrado de campos JSON escalares a cualquier escala significativa.
Si estás ejecutando Percona Server, pt-query-digest del Percona Toolkit sigue siendo la forma más práctica de identificar qué consultas intensivas en JSON están realmente causando problemas en producción antes de que empieces a agregar índices especulativamente.
Observaciones prácticas
- Los índices multi-valor (8.0.17+) son una mejora muy esperada y funcionan bien cuando tus patrones de consulta se alinean con JSON_CONTAINS() o MEMBER OF()
- JSON_VALUE() con RETURNING (8.0.21+) es más limpio que el viejo patrón de casteo después de extraer y vale la pena adoptarlo consistentemente
- Las columnas generadas más índices siguen siendo el camino más confiable para filtrado de campos JSON escalares a escala
- Vigila los errores de claves duplicadas en JSON_OBJECTAGG() en datos agrupados — aparece como un error duro en pipelines ETL y puede ser fácil de pasar por alto en pruebas si tus datos de muestra casualmente están limpios
- Verifica siempre el uso de índices con EXPLAIN — el optimizador no siempre detecta índices multi-valor en cláusulas WHERE complejas, y vale la pena confirmarlo en lugar de asumirlo
Resumen
Las mejoras de JSON en MySQL 8.0 son genuinamente útiles, particularmente los índices multi-valor y JSON_VALUE() con casteo de tipo. No reemplazan un buen diseño de esquema, pero para casos donde el almacenamiento JSON es apropiado o heredado, ahora tienes herramientas reales con las que trabajar en lugar de solo esperar que el optimizador lo resuelva. El patrón de columna generada en particular vale la pena evaluarlo temprano si sabes que ciertos campos JSON se usarán regularmente en cláusulas WHERE.
Referencias útiles: