sábado, 21 de fevereiro de 2026

Funções JSON do MySQL 8.0: Exemplos Práticos e Indexação

This article was originally published in English at AnotherMySQLDBA.

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: