Este post apresenta um guia prático sobre as funções JSON do MySQL 8.0. O suporte a JSON existe no MySQL desde a versão 5.7, mas a 8.0 trouxe um conjunto significativo de melhorias — estratégias de indexação melhores, novas funções e índices multi-valorizados — que tornam o trabalho com dados JSON consideravelmente mais prático. O conteúdo a seguir documenta vários padrões comumente necessários, incluindo saídas do EXPLAIN e observações de desempenho importantes.
Este não é um post de debate "JSON vs. relacional". Se você está armazenando JSON no MySQL, provavelmente já tem seus motivos. O objetivo aqui é garantir que você esteja usando as ferramentas disponíveis de forma eficaz.
Ambiente
mysql> SELECT @@version, @@version_comment\G
*************************** 1. row ***************************
@@version: 8.0.36
@@version_comment: MySQL Community Server - GPL
Os testes foram realizados em uma VM com 8GB de RAM e innodb_buffer_pool_size configurado para 4G. Uma nota importante de configuração: query_cache_type é irrelevante no 8.0, pois o cache de consultas foi removido completamente. Se você migrou uma instância 5.7 e ainda tem essa variável no seu my.cnf, remova-a — o MySQL 8.0 gerará um erro de inicialização.
Configurando uma Tabela de Teste
A tabela de teste simula um padrão bastante comum — uma aplicação armazenando dados de perfil de usuário e metadados de eventos como blobs 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}');
Extração Básica: JSON_VALUE vs. JSON_EXTRACT
JSON_VALUE() foi introduzida no MySQL 8.0.21 e é a forma mais limpa de extrair valores escalares com conversão de tipo integrada. Antes disso, você usava JSON_EXTRACT() (ou a abreviação ->) e fazia a conversão manualmente, o que funciona, mas adiciona ruído às suas 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;
Saída da 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)
A cláusula RETURNING é genuinamente útil. Ela elimina o padrão desajeitado de dupla conversão e torna a intenção mais clara ao ler o código da consulta posteriormente.
Índices Multi-Valorizados: A Verdadeira Revolução
É aqui que o 8.0 realmente fez a diferença para cargas de trabalho JSON. Os índices multi-valorizados, disponíveis desde o MySQL 8.0.17, permitem indexar elementos de array dentro de uma coluna JSON diretamente. Veja como isso funciona na prática:
ALTER TABLE user_events
ADD INDEX idx_tags ((CAST(event_data->'$.tags' AS CHAR(64) ARRAY)));
Aqui está o que o EXPLAIN mostra antes e depois em uma consulta filtrando por valor de tag:
-- 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
Varredura completa da tabela reduzida a uma varredura de intervalo. Em 5 linhas isso é trivial, mas em uma tabela com milhões de linhas e filtragem frequente por tags, essa diferença é significativa. A melhoria escala diretamente com o tamanho da tabela e a frequência das consultas.
Um ponto importante a observar: MEMBER OF() e JSON_OVERLAPS() também se beneficiam de índices multi-valorizados, mas JSON_SEARCH() não. Isso importa ao escolher o padrão da sua consulta no momento do design:
-- 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() lançará um erro se houver chaves duplicadas dentro de um grupo. Isso vale a pena saber antes de encontrá-lo em um pipeline ETL de produção. Nesse caso, você precisará desduplicar upstream ou lidar com isso na lógica da aplicação antes que os dados cheguem a esta etapa de agregação.
Verificando SHOW STATUS Após Consultas Pesadas em JSON
Ao avaliar padrões de consultas, verificar as métricas de handler é um 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)
O valor de Handler_read_rnd_next confirma uma varredura completa — sem surpresa, já que não há índice funcional no valor da pontuação. Para filtragem baseada em pontuação em escala, uma coluna gerada com índice é a solução correta:
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);
Após adicionar isso, a mesma consulta cai para uma varredura de intervalo de índice adequada. Colunas geradas em campos JSON estão disponíveis tanto no MySQL 8.0 quanto no Percona Server 8.0, e permanecem sendo o caminho mais confiável para filtragem de campos JSON escalares em qualquer escala significativa.
Se você estiver executando Percona Server, o pt-query-digest do Percona Toolkit ainda é a maneira mais prática de identificar quais consultas pesadas em JSON estão realmente causando problemas em produção antes de começar a adicionar índices de forma especulativa.
Observações Práticas
- Índices multi-valorados (8.0.17+) são uma melhoria há muito aguardada e funcionam bem quando seus padrões de consulta se alinham com JSON_CONTAINS() ou MEMBER OF()
- JSON_VALUE() com RETURNING (8.0.21+) é mais limpo que o antigo padrão de conversão após extração e vale a pena adotar consistentemente
- Colunas geradas mais índices permanecem sendo o caminho mais confiável para filtragem de campos JSON escalares em escala
- Fique atento a erros de chaves duplicadas em JSON_OBJECTAGG() em dados agrupados — isso surge como um erro grave em pipelines ETL e pode ser fácil de perder em testes se seus dados de amostra acontecerem de estar limpos
- Sempre verifique o uso de índices com EXPLAIN — o otimizador nem sempre detecta índices multi-valorados em cláusulas WHERE complexas, e vale a pena confirmar em vez de assumir
Resumo
As melhorias no JSON do MySQL 8.0 são genuinamente úteis, particularmente os índices multi-valorados e o JSON_VALUE() com conversão de tipo. Elas não substituem um bom design de esquema, mas para casos em que o armazenamento em JSON é apropriado ou herdado, agora você tem ferramentas reais para trabalhar em vez de apenas esperar que o otimizador descubra. O padrão de coluna gerada, em particular, vale a pena avaliar cedo se você souber que certos campos JSON serão usados regularmente em cláusulas WHERE.
Referências úteis: