JSONB indexing strategies: GIN vs GIST e performance

1. Introdução ao JSONB e a Necessidade de Indexação

1.1. O que é JSONB e diferenças fundamentais para JSON

JSONB é o formato binário de JSON no PostgreSQL, introduzido na versão 9.4. Diferentemente do tipo json, que armazena o texto exato como foi inserido (preservando espaços, ordem de chaves e duplicatas), o jsonb armazena os dados em um formato binário decomposto. Isso significa que:

  • Chaves duplicadas são removidas (apenas a última prevalece)
  • A ordem das chaves não é preservada
  • O armazenamento é mais eficiente para consultas, mas ligeiramente maior em disco
  • Índices podem ser criados diretamente sobre o conteúdo interno
-- Diferença prática entre JSON e JSONB
CREATE TABLE exemplo_json (dados json);
CREATE TABLE exemplo_jsonb (dados jsonb);

INSERT INTO exemplo_json VALUES ('{"nome": "João", "nome": "Maria"}');
INSERT INTO exemplo_jsonb VALUES ('{"nome": "João", "nome": "Maria"}');

SELECT * FROM exemplo_json;  -- Retorna {"nome": "João", "nome": "Maria"}
SELECT * FROM exemplo_jsonb; -- Retorna {"nome": "Maria"} (duplicata removida)

1.2. Por que consultas em documentos JSONB sem índices são lentas

Sem índices, o PostgreSQL precisa realizar um full sequential scan em toda a tabela, aplicando parsing e extração de caminhos em cada documento. Para tabelas com milhões de linhas, isso é catastrófico para performance:

-- Consulta lenta sem índice (sequential scan)
EXPLAIN ANALYZE
SELECT * FROM produtos 
WHERE dados @> '{"categoria": "eletrônicos"}';
-- Saída: Seq Scan on produtos (cost=0.00..45000.00 rows=5000 width=200)

1.3. Operadores e caminhos de acesso comuns

Os operadores mais utilizados em consultas JSONB são:

->  : Acessa campo como JSON (retorna objeto/array)
->> : Acessa campo como texto
@>  : Contém (verifica se documento contém subdocumento)
?   : Existe chave no nível raiz
?|  : Alguma das chaves existe
?&  : Todas as chaves existem
#>> : Caminho como texto
@@  : Correspondência com jsonb_path_ops (apenas GIN)

2. GIN (Generalized Inverted Index): Estrutura e Mecanismo

2.1. Como o GIN indexa internamente cada chave/valor

O índice GIN cria uma estrutura de lista invertida onde cada chave, valor e caminho do documento JSONB é tratado como um termo de busca. Internamente, ele decompõe o documento em uma série de pares (caminho, valor) e os armazena em uma árvore B-tree de termos, com ponteiros para as linhas correspondentes.

-- Criação de índice GIN padrão
CREATE INDEX idx_gin_produtos ON produtos USING GIN (dados);

-- Índice GIN com jsonb_path_ops (mais compacto)
CREATE INDEX idx_gin_path_ops ON produtos USING GIN (dados jsonb_path_ops);

2.2. Operadores suportados nativamente

O GIN suporta todos os operadores JSONB principais:

-- Operadores suportados pelo GIN
EXPLAIN ANALYZE
SELECT * FROM produtos 
WHERE dados @> '{"fabricante": {"nome": "Samsung"}}';  -- Usa índice GIN

EXPLAIN ANALYZE
SELECT * FROM produtos 
WHERE dados ? 'garantia';  -- Usa índice GIN

EXPLAIN ANALYZE
SELECT * FROM produtos 
WHERE dados ?| ARRAY['cor', 'tamanho'];  -- Usa índice GIN

2.3. Variação jsonb_path_ops: redução de tamanho e ganho em buscas de aninhamento profundo

A opção jsonb_path_ops modifica o comportamento do GIN para armazenar apenas os caminhos completos (path + valor) como termos, em vez de decompor cada chave individualmente. Isso reduz significativamente o tamanho do índice (30-50% menor) e acelera consultas com @> em documentos profundamente aninhados.

-- Comparação de tamanho entre GIN padrão e jsonb_path_ops
SELECT 
    pg_size_pretty(pg_indexes_size('produtos')) as tamanho_total,
    'GIN padrão: ' || pg_size_pretty(
        (SELECT SUM(pg_relation_size(indexrelid)) 
         FROM pg_index 
         WHERE indrelid = 'produtos'::regclass AND indexrelid = 'idx_gin_produtos'::regclass)
    ) as gin_normal,
    'GIN path_ops: ' || pg_size_pretty(
        (SELECT SUM(pg_relation_size(indexrelid)) 
         FROM pg_index 
         WHERE indrelid = 'produtos'::regclass AND indexrelid = 'idx_gin_path_ops'::regclass)
    ) as gin_path_ops;

3. GIST (Generalized Search Tree): Estrutura e Mecanismo

3.1. Como o GIST organiza documentos JSONB em uma árvore balanceada

O GIST utiliza uma estrutura de árvore balanceada onde cada nó representa uma "bounding box" que agrupa documentos JSONB similares. Diferente do GIN, que decompõe o documento em termos, o GIST mantém o documento como uma unidade e usa funções de distância/similaridade para organizar os nós.

-- Criação de índice GIST
CREATE INDEX idx_gist_produtos ON produtos USING GIST (dados);

3.2. Operadores suportados e limitações

O GIST suporta um subconjunto menor de operadores comparado ao GIN:

-- Operadores suportados pelo GIST
EXPLAIN ANALYZE
SELECT * FROM produtos 
WHERE dados @> '{"preco": 1500}';  -- Suportado

EXPLAIN ANALYZE
SELECT * FROM produtos 
WHERE dados ? 'modelo';  -- Suportado

-- Operador NÃO suportado pelo GIST
EXPLAIN ANALYZE
SELECT * FROM produtos 
WHERE dados @@ '$.preco > 1000';  -- Erro: operador não suportado pelo GIST

3.3. Diferenças de estratégia: GIST como índice de similaridade

Enquanto o GIN é um índice de pertinência (o documento contém o termo?), o GIST funciona como um índice de similaridade (o documento está próximo ao termo de busca?). Isso faz com que o GIST seja mais adequado para:

  • Consultas de range e proximidade
  • Documentos com estrutura semelhante
  • Cargas com muitas operações de escrita

4. Comparação de Performance: GIN vs GIST em Cenários Típicos

4.1. Tempo de criação e tamanho do índice

-- Medição de tempo de criação e tamanho
\timing on

CREATE INDEX idx_gin ON produtos USING GIN (dados);
-- Tempo: 45.2 segundos, Tamanho: 850 MB

CREATE INDEX idx_gist ON produtos USING GIST (dados);
-- Tempo: 28.1 segundos, Tamanho: 520 MB

4.2. Velocidade de consulta exata (@>)

-- Consulta exata com @> - GIN é significativamente mais rápido
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM produtos 
WHERE dados @> '{"especificacoes": {"processador": "Intel i7", "ram": "16GB"}}';

-- Resultados típicos:
-- GIN: 0.8 ms (Bitmap Index Scan)
-- GIST: 3.2 ms (Index Scan)
-- Sem índice: 4500 ms (Seq Scan)

4.3. Velocidade de consulta por existência de chave (?)

-- Consulta por existência de chave
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM produtos 
WHERE dados ? 'desconto';

-- Resultados típicos:
-- GIN: 1.5 ms
-- GIST: 0.9 ms (ligeira vantagem em cargas com muitas chaves esparsas)

5. Fatores que Influenciam a Escolha

5.1. Cardinalidade dos dados

Documentos com muitas chaves únicas (alta cardinalidade) favorecem o GIN, pois a estrutura invertida lida melhor com termos variados. Documentos com estrutura repetitiva (baixa cardinalidade) podem se beneficiar do GIST.

5.2. Frequência de operações DML

O GIST sofre menos com bloat (crescimento desnecessário do índice) em tabelas com muitas operações de INSERT/UPDATE/DELETE. O GIN tende a acumular entradas órfãs que exigem VACUUM frequente.

-- Monitoramento de bloat no índice GIN
SELECT 
    n.nspname as schema,
    c.relname as tabela,
    pg_size_pretty(pg_relation_size(c.oid)) as tamanho_tabela,
    pg_size_pretty(pg_total_relation_size(c.oid) - pg_relation_size(c.oid)) as tamanho_indices
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname = 'produtos';

5.3. Tipos de consulta predominantes

  • Buscas por caminho específico (@> com aninhamento profundo): GIN com jsonb_path_ops
  • Varreduras de existência (?, ?|, ?&): GIST pode ser competitivo
  • Consultas com operadores de texto (@@): Apenas GIN suporta

6. Técnicas Avançadas de Otimização com Índices JSONB

6.1. Índices parciais para filtrar subconjuntos

-- Índice parcial apenas para produtos ativos
CREATE INDEX idx_gin_produtos_ativos 
ON produtos USING GIN (dados)
WHERE (dados ->> 'status') = 'ativo';

6.2. Índices compostos para consultas híbridas

-- Índice composto: coluna relacional + JSONB
CREATE INDEX idx_composto 
ON produtos (categoria_id, dados) 
WHERE dados ? 'promocao';

6.3. Uso de pg_stat_user_indexes e EXPLAIN (ANALYZE, BUFFERS)

-- Verificar uso real dos índices
SELECT 
    schemaname,
    tablename,
    indexname,
    idx_scan as scans_realizados,
    idx_tup_read as tuplas_lidas,
    idx_tup_fetch as tuplas_buscadas
FROM pg_stat_user_indexes
WHERE tablename = 'produtos'
ORDER BY idx_scan DESC;

-- Análise detalhada de uma consulta
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM produtos 
WHERE dados @> '{"marca": "Apple"}';

7. Casos de Uso Práticos e Recomendações Finais

7.1. Catálogo de produtos com atributos dinâmicos

Para um catálogo de e-commerce com atributos variáveis (cores, tamanhos, especificações técnicas), o GIN com jsonb_path_ops é a melhor escolha:

-- Estrutura ideal para catálogo dinâmico
CREATE TABLE catalogo_produtos (
    id SERIAL PRIMARY KEY,
    sku VARCHAR(20) UNIQUE,
    atributos JSONB NOT NULL
);

CREATE INDEX idx_catalogo_gin 
ON catalogo_produtos USING GIN (atributos jsonb_path_ops);

-- Consulta rápida por atributos aninhados
SELECT * FROM catalogo_produtos 
WHERE atributos @> '{"dimensoes": {"altura": 30, "largura": 20}}';

7.2. Logs de eventos com metadados esparsos

Para sistemas de logging onde cada evento tem metadados diferentes e há muitas inserções, o GIST é mais adequado:

-- Estrutura para logs de eventos
CREATE TABLE logs_eventos (
    id BIGSERIAL,
    timestamp TIMESTAMPTZ DEFAULT NOW(),
    metadados JSONB
);

CREATE INDEX idx_logs_gist 
ON logs_eventos USING GIST (metadados);

-- Consulta por existência de campos esparsos
SELECT * FROM logs_eventos 
WHERE metadados ? 'user_agent';

7.3. Regra geral para escolha

Critério GIN GIST
Leitura intensiva ✅ Excelente ⚠️ Bom
Escrita intensiva ⚠️ Requer manutenção ✅ Melhor
Consultas @> profundas ✅ Superior ⚠️ Adequado
Consultas ? ✅ Bom ✅ Bom
Tamanho do índice ⚠️ Maior ✅ Menor
Suporte a @@ ✅ Sim ❌ Não

Recomendação final: Prefira GIN para a maioria dos casos de leitura intensiva com alta seletividade. Use GIST quando houver muitas operações de escrita e documentos com estrutura esparsa.

Referências