Um índice GIN real, e uma query que nunca o tocou

Prévia grátis 3 min de leitura

O Catalog::SearchQuery é a classe por trás da busca do catálogo desta aplicação. Até 2 de setembro de 2026, ela filtrava cursos e aulas assim:

# app/queries/catalog/search_query.rb, antes de 2 de setembro de 2026
pattern = "%#{ActiveRecord::Base.sanitize_sql_like(term)}%"
title_sql = ->(table) { Arel.sql("COALESCE(#{table}.title->>#{ActiveRecord::Base.connection.quote(locale.to_s)}, #{table}.title->>'en')") }

courses = ::Catalog::Course.active
  .where("#{title_sql.call('courses')} ILIKE ?", pattern)

courses.title é jsonb. O título de cada curso é um pequeno documento, {"en" => "...", "pt-BR" => "..."}, e não uma coluna de texto simples. A migration que acrescentou índices de desempenho às tabelas principais, 20260301202548_add_performance_indexes_to_core_tables.rb, colocou um índice GIN direto nessa coluna, sob um comentário que diz para que serve:

# ── JSONB GIN Indexes (Search & Filtering) ──────────────────

# Using gin index for PostgreSQL jsonb translatable fields
add_index :courses, :title, using: :gin
add_index :courses, :description, using: :gin
add_index :categories, :name, using: :gin
add_index :lessons, :title, using: :gin

São quatro índices GIN nas quatro colunas jsonb de onde a aplicação mostra texto, rotulados para busca e filtragem. O SearchQuery buscava em courses.title e lessons.title, então, no papel, o índice e a query batiam.

A query fez uma leitura sequencial mesmo assim

A correção removeu esse índice, então o experimento abaixo roda em courses_old: uma cópia temporária da tabela courses de desenvolvimento (361 linhas) com o índice GIN original recriado em title. A busca, como rodava em inglês:

EXPLAIN SELECT * FROM courses_old
  WHERE COALESCE(title->>'en', title->>'en') ILIKE '%rails%';
Seq Scan on courses_old  (cost=0.00..22.32 rows=1 width=271)
  Filter: (COALESCE((title ->> 'en'::text), (title ->> 'en'::text)) ~~* '%rails%'::text)

Um Seq Scan lê e confere cada linha, e o índice GIN em title não aparece no plano. Com algumas centenas de linhas isso não é lento, mas "não é lento" é outra afirmação, diferente de "o índice ajudou". Este plano não o usou.

O mesmo índice, uma query que o usa

Esta é a mesma coluna e o mesmo índice, consultados com o operador de contenção do Postgres em vez de uma busca por padrão:

EXPLAIN SELECT * FROM courses_old WHERE title @> '{"en": "Ruby basics"}';
Bitmap Heap Scan on courses_old  (cost=12.98..16.99 rows=1 width=271)
  Recheck Cond: (title @> '{"en": "Ruby basics"}'::jsonb)
  ->  Bitmap Index Scan on courses_old_title  (cost=0.00..12.98 rows=1 width=0)
        Index Cond: (title @> '{"en": "Ruby basics"}'::jsonb)

É um Bitmap Index Scan no mesmo índice, na mesma coluna. O índice nunca esteve quebrado. Ele foi feito para uma pergunta que a busca da aplicação nunca fazia.

Do que trata este curso

Um índice GIN não é uma coisa só. É um mecanismo de armazenamento, e a classe de operadores dele decide que perguntas ele sabe responder sobre um valor. "Este documento contém esse par" é uma pergunta. "Este texto bate com este padrão" é outra, e acrescentar using: :gin a uma migration não escolhe entre elas. O resto deste curso mostra por que um índice GIN atendeu a uma query e ignorou outra na mesma coluna, e como foi a correção da aplicação.

Pontos-chave

  • O Catalog::SearchQuery filtra courses.title e lessons.title com ILIKE sobre o texto extraído por ->>: uma busca por padrão, e não uma operação de jsonb.
  • A migration original criou índices GIN em courses.title, courses.description, categories.name e lessons.title, rotulados para busca e filtragem.
  • Com o índice original recriado numa cópia de courses, a query de busca fez um Seq Scan, e o índice GIN não apareceu no plano.
  • O mesmo índice, consultado com @> em vez de ILIKE, produziu um Bitmap Index Scan, então o problema nunca foi o índice.
Aula 1 de 7 · grátis

Continue com a aula 2

jsonb_ops: o que a opclass padrão indexa

A classe padrão jsonb_ops registra seis operadores, como @> e existência de chave, e nenhum deles é LIKE ou ILIKE.

Com uma conta grátis você também tem os quizzes, seu progresso e suas anotações, e a discussão.

Digite para buscar...