В B-tree каждая строка вносит в индекс ровно одну запись: ключ-колонка → ctid. Это работает, пока в колонке одно скалярное значение. Но как только в колонке появляется composite value — массив, JSONB-документ, tsvector с сотней токенов — B-tree становится бесполезен: он не умеет искать «строки, где массив содержит X». Postgres решает это другим типом индекса —
Идея inverted index
Inverted index — это база любого поискового движка (Lucene, Elasticsearch, Sphinx). Идея проста: не индексируем «у документа D какие токены», а наоборот — «у токена T в каких документах он встречается».
Каждый extracted ключ ведёт к posting list — отсортированному списку tid, где этот ключ присутствует. Один heap-tuple появляется в стольких posting list, сколько у него извлечённых ключей.
Каждое значение в индексируемой колонке проходит через extractValue — функцию-callback, специфичную для opclass. Для массива text[] она вернёт все элементы; для tsvector — все лексемы; для JSONB (jsonb_ops) — все ключи и значения как отдельные элементы. Для каждого извлечённого ключа GIN кладёт tid строки в posting list этого ключа.
При запросе вида arr @> ARRAY['x','y'] GIN извлекает из запроса ключи x, y, берёт их posting lists и пересекает (для AND) или объединяет (для OR). В результате — список tid, которые нужно прочитать из heap. То же самое для tsvector через @@ или для JSONB через ?/@>.
Создаём колонку JSONB и пробуем
В стандартном датасете customers нет JSONB-колонок, добавим вручную и наполним.
Создаём таблицу products с JSONB-колонкой attributes — типичная схема каталога, где разные категории имеют разные атрибуты.
Теперь две тестовые операции — найти все товары категории laptop и все товары с тегом gaming. Без индекса.
Без индекса — Seq Scan на 20K строках. Это пока быстро, но почувствуй порядок: каждая строка проверяется.
Теперь сразу строим GIN-индекс и смотрим план снова.
GIN-индекс. Bitmap Index Scan по products_attrs_gin вместо Seq Scan. На 20K строк ускорение скромное, на миллионах — драматическое.
jsonb_ops vs jsonb_path_ops
У GIN для JSONB есть две opclass. По умолчанию используется jsonb_ops: она индексирует каждый ключ и каждое значение отдельно. Размер большой, зато умеет отвечать на ? (key exists), ?|, ?&, и @>.
Альтернатива — jsonb_path_ops. Она индексирует хэши путей (key → value целиком), не индексирует ключи отдельно. Размер меньше в 2-3 раза, поиск @> быстрее, но ? уже не работает.
Сравним размер двух индексов на одной таблице. Создаём оба, смотрим pg_relation_size.
Правило: если все запросы только @> (contains) — бери jsonb_path_ops. Если нужно ? (есть ли ключ) — jsonb_ops.
Когда GIN — оверкилл
GIN-индекс выглядит как магия, но цена есть.
-
Insert медленный. Каждый INSERT пишет в каждый posting list, в котором появляется хотя бы один ключ этой строки. Для JSONB с 20 ключами — 20 записей в индекс. B-tree пишет одну. Если ты много вставляешь и редко читаешь — GIN бывает медленнее, чем Seq Scan.
-
Update тяжёлый. Любое изменение JSONB пересоздаёт почти все entries. Если у тебя hot-updates на jsonb-колонку — GIN будет постоянно перестраиваться и расти.
-
Pending list / fastupdate. Чтобы insert не зависал на каждом вставленном ключе, GIN накапливает изменения в pending list — отдельной линейной странице. Раз в
gin_pending_list_limitMiB (по умолчанию 4 MB) либо при autovacuum список сливается в основной индекс. Это означает: после большой вставки SELECT может временно сканировать pending list линейно, и план будет неоптимальным до autovacuum. Включается параметромfastupdateopclass. -
На маленькой таблице — бесполезно. Если в таблице 100 строк, Seq Scan по
@>отработает за миллисекунду, а GIN добавит 100-200 KiB к размеру таблицы. Имеет смысл начиная с десятков тысяч строк. -
Не поддерживает
ORDER BY. GIN возвращает неупорядоченный bitmap. Если нуженORDER BY price DESCс фильтром по тегу — после GIN придётся отдельно сортировать (а часто это убивает выигрыш).
Trigram-поиск через pg_trgm
Один из самых популярных не-документных use case для GIN — это trigram-индекс через расширение pg_trgm. Идея: разбить строку на тройки символов («postgres» → pos, ost, stg, tgr, gre, res), а потом GIN индексирует каждый триграмм как ключ. Это позволяет ускорять LIKE '%substring%' (внутренние подстроки!), что не умеет ни B-tree, ни SP-GiST.
CREATE EXTENSION pg_trgm;
CREATE INDEX products_name_trgm ON products USING GIN (name gin_trgm_ops);
-- Запрос с подстрокой в середине — теперь Index Scan
SELECT * FROM products WHERE name ILIKE '%book%';
Цена та же — большой индекс (на каждой строке генерируется столько триграммов, сколько в ней символов минус 2), медленный insert. Но это единственный реалистичный способ ускорить fuzzy-search в Postgres без выноса в Elasticsearch.
GIN vs B-tree на JSONB по размеру
В реальной системе нужно понимать, насколько большим становится индекс. Простое сравнение: B-tree на функциональном выражении даёт точечный быстрый lookup, но только по одному пути; GIN — универсален, но в разы тяжелее.
Сравним: GIN (jsonb_ops), GIN (jsonb_path_ops) и B-tree на одном выражении (attributes->>'category'). Это разные стратегии.
Видишь иерархию: B-tree expression — самый компактный, но узкоспециализированный (только category). jsonb_path_ops — золотая середина для @>-запросов. jsonb_ops — универсальный, но самый толстый.
Мониторинг pending list
Если у тебя fastupdate=on, размер pending list важен — слишком большой замедлит SELECT, потому что его придётся сканировать линейно. Смотри его так:
SELECT gin_metapage_info(get_raw_page('products_attrs_gin', 0));
-- покажет n_pending_pages и pending tuples
Если pending pages подбираются к gin_pending_list_limit (по умолчанию 4 MB) — пора запустить vacuum products или вызвать gin_clean_pending_list('products_attrs_gin') руками.
Чек-лист
- GIN — inverted index: ключ → posting list из tid. Каждая строка может вносить много entries (по числу извлечённых ключей).
- Подходит для arrays, JSONB, tsvector, trigram (pg_trgm).
- На JSONB используй
jsonb_ops(богаче запросы) илиjsonb_path_ops(меньше и быстрее, только@>). - INSERT/UPDATE дорогие — GIN не для write-heavy сценариев. Спасает
fastupdate=on(pending list). - ORDER BY не поддерживается напрямую — после bitmap придётся сортировать.
- На таблице ≤ 1000 строк GIN бесполезен: Seq Scan быстрее.