Learning Platform
Урок 04.01 · 22 мин
Продвинутый
GINInverted indexJSONBArraysFull-text search

В B-tree каждая строка вносит в индекс ровно одну запись: ключ-колонка → ctid. Это работает, пока в колонке одно скалярное значение. Но как только в колонке появляется composite value — массив, JSONB-документ, tsvector с сотней токенов — B-tree становится бесполезен: он не умеет искать «строки, где массив содержит X». Postgres решает это другим типом индекса —

GIN
.

Идея inverted index

Inverted index — это база любого поискового движка (Lucene, Elasticsearch, Sphinx). Идея проста: не индексируем «у документа D какие токены», а наоборот — «у токена T в каких документах он встречается».

Структура GIN

Каждый extracted ключ ведёт к posting list — отсортированному списку tid, где этот ключ присутствует. Один heap-tuple появляется в стольких posting list, сколько у него извлечённых ключей.

GIN entry tree (B-tree по ключам)ключи отсортированы; posting list сжатый
key: 'postgres'posting list
tid 42
tid 117
tid 991
key: 'index'posting list
tid 17
tid 42
tid 880
key: 'storage'posting list
tid 117
tid 880
Поиск 'postgres AND index'пересечение posting lists -> tid 42

Каждое значение в индексируемой колонке проходит через 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 — типичная схема каталога, где разные категории имеют разные атрибуты.

PostgreSQL

Теперь две тестовые операции — найти все товары категории laptop и все товары с тегом gaming. Без индекса.

Без индекса — Seq Scan на 20K строках. Это пока быстро, но почувствуй порядок: каждая строка проверяется.

PostgreSQL

Теперь сразу строим GIN-индекс и смотрим план снова.

GIN-индекс. Bitmap Index Scan по products_attrs_gin вместо Seq Scan. На 20K строк ускорение скромное, на миллионах — драматическое.

PostgreSQL

jsonb_ops vs jsonb_path_ops

У GIN для JSONB есть две opclass. По умолчанию используется jsonb_ops: она индексирует каждый ключ и каждое значение отдельно. Размер большой, зато умеет отвечать на ? (key exists), ?|, ?&, и @>.

Альтернатива — jsonb_path_ops. Она индексирует хэши путей (key → value целиком), не индексирует ключи отдельно. Размер меньше в 2-3 раза, поиск @> быстрее, но ? уже не работает.

Сравним размер двух индексов на одной таблице. Создаём оба, смотрим pg_relation_size.

PostgreSQL

Правило: если все запросы только @> (contains) — бери jsonb_path_ops. Если нужно ? (есть ли ключ) — jsonb_ops.

Когда GIN — оверкилл

GIN-индекс выглядит как магия, но цена есть.

  1. Insert медленный. Каждый INSERT пишет в каждый posting list, в котором появляется хотя бы один ключ этой строки. Для JSONB с 20 ключами — 20 записей в индекс. B-tree пишет одну. Если ты много вставляешь и редко читаешь — GIN бывает медленнее, чем Seq Scan.

  2. Update тяжёлый. Любое изменение JSONB пересоздаёт почти все entries. Если у тебя hot-updates на jsonb-колонку — GIN будет постоянно перестраиваться и расти.

  3. Pending list / fastupdate. Чтобы insert не зависал на каждом вставленном ключе, GIN накапливает изменения в pending list — отдельной линейной странице. Раз в gin_pending_list_limit MiB (по умолчанию 4 MB) либо при autovacuum список сливается в основной индекс. Это означает: после большой вставки SELECT может временно сканировать pending list линейно, и план будет неоптимальным до autovacuum. Включается параметром fastupdate opclass.

  4. На маленькой таблице — бесполезно. Если в таблице 100 строк, Seq Scan по @> отработает за миллисекунду, а GIN добавит 100-200 KiB к размеру таблицы. Имеет смысл начиная с десятков тысяч строк.

  5. Не поддерживает 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'). Это разные стратегии.

PostgreSQL

Видишь иерархию: B-tree expression — самый компактный, но узкоспециализированный (только category). jsonb_path_ops — золотая середина для @>-запросов. jsonb_ops — универсальный, но самый толстый.

Проверка знанийKnowledge check
Таблица events с колонкой metadata JSONB, 50M строк. 99% запросов — INSERT, 1% — поиск по @>. Стоит ли строить GIN-индекс?
ОтветAnswer
Скорее нет. GIN сделает каждый INSERT в десятки раз дороже (запись в десятки posting lists вместо одного места в heap), а ускорит только 1% запросов. На write-heavy таблице это плохой trade-off. Варианты: (1) держать GIN на партиции «горячих» данных, остальные сканировать; (2) включить fastupdate=on для амортизации записи; (3) если запросы на @> редкие — отказаться от GIN и жить с Seq Scan / parallel scan. Правило: GIN оправдан, когда read:write >= 10:1.

Мониторинг 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 быстрее.
JSONB: гибкая схема внутри строгой Индексирование специальных типов: GIN, GIST, expression-индексы Что такое хеш-функция

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что хранит GIN-индекс на каждом ключе?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5