Learning Platform
Урок 11.07 · 17 мин
Средний
GINGiSTexpression indexpartial indexindexing

Глубокий разговор про индексы будет в модуле 13 — там мы пройдём по B-tree, EXPLAIN, селективности и плану запроса. Этот урок — короткий, прагматичный: какой индекс ставить для типов из этого модуля, чтобы запросы из 30 секунд превращались в миллисекунды.

Почему B-tree не подходит для всего

Обычный B-tree индекс — это сортированное дерево, оптимизированное под операторы =, <, >, BETWEEN, LIKE 'prefix%'. Если запрос использует что-то другое (@>, &&, @@, <<), B-tree не помогает: оптимизатор делает sequential scan и проверяет каждую строку.

Для специальных операций существуют другие типы индексов:

  • GIN (Generalized Inverted Index) — обратный индекс. Хорош, когда внутри ячейки много мелких значений, по которым ищут.
  • GiST (Generalized Search Tree) — обобщённое поисковое дерево. Хорошо для диапазонов, геометрии, EXCLUDE constraint.
  • BRIN — block range index. Для очень больших таблиц с естественной локальностью (например, лог-таблица по дате).
  • SP-GiST и hash — реже используются.

Сейчас разберём первые два и две частые техники для B-tree.

GIN — для массивов, JSONB и FTS

GIN строит обратный индекс: для каждой «элементарной части» значения он хранит список строк, в которых эта часть встречается.

  • Для TEXT[] элементарная часть — это элемент массива.
  • Для JSONB — пары ключ-значение (зависит от operator class).
  • Для tsvector — каждая лексема.
Что хранит GIN — обратный индекс

Для каждой 'элементарной части' значения индекс знает, в каких строках она встречается. Поиск 'tag=vip' даёт сразу список row_id, без сканирования таблицы.

row 1tags = [vip, early_adopter]
row 2tags = [frequent]
row 3tags = [vip]
row 5tags = [frequent, vip]
GIN indexлексема → row ids
vip[1, 3, 5]
early_adopter[1]
frequent[2, 5]

GIN для массивов

CREATE INDEX customers_tags_idx ON customers USING GIN (tags);

После этого запросы вида WHERE tags @> ARRAY['vip'] или WHERE tags && ARRAY['vip','frequent'] идут через индекс, не через seq scan.

GIN для JSONB

CREATE INDEX products_attrs_idx ON products USING GIN (attrs);

Это индексирует все ключи и значения JSONB. Поддерживает @>, ?, ?|, ?&. Стоит много места на диске.

Если ты знаешь, что фильтруешь почти всегда через @>, можно сэкономить место через специальный operator class:

CREATE INDEX products_attrs_idx ON products USING GIN (attrs jsonb_path_ops);

jsonb_path_ops индексирует только хэш пути+значения, не ключи отдельно. В два-три раза компактнее, быстрее на @>, но не поддерживает ? и его друзей.

GIN для FTS

CREATE INDEX articles_search_idx ON articles USING GIN (search_vec);

Где search_vec — это tsvector, обычно хранимый в GENERATED ALWAYS AS (...) STORED колонке. Без этого индекса to_tsvector(...) @@ to_tsquery(...) идёт по всей таблице.

Цена GIN

  • Медленная вставка/обновление. GIN строит много элементов из одной строки — каждый INSERT/UPDATE дороже B-tree.
  • Больше места на диске.
  • fastupdate накапливает изменения в pending list; пока не VACUUM, поиск делает дополнительную работу.

Поэтому: GIN на read-heavy таблицы — отлично. На очень write-heavy — задумайся, нужен ли.

GiST — для диапазонов, геометрии и EXCLUDE

GiST мы уже встречали в EXCLUDE constraint. Это дерево, в котором каждый узел знает «диапазон/область, покрываемую его потомками». Поиск идёт сверху вниз: если запрос не пересекается с областью узла, целая ветка отсекается.

CREATE INDEX bookings_period_idx ON bookings USING GIST (period);

После этого WHERE period && '[2025-05-12 10:00, 2025-05-12 12:00)'::tstzrange идёт через индекс. Без него — seq scan.

GiST также используется для:

  • Геометрии (point, polygon, PostGIS).
  • Расстояний (<-> оператор, kNN-поиск).
  • tsvector — альтернатива GIN. GiST мельче на диске, но проигрывает на больших словарях.

Expression index — индекс по выражению

Бывает, что часто фильтруют по результату функции от колонки:

SELECT * FROM users WHERE lower(email) = '[email protected]';
SELECT * FROM products WHERE (attrs->>'color') = 'black';
SELECT * FROM customers WHERE date_trunc('month', signup_date) = '2025-01-01';

Обычный B-tree на email не поможет: индекс знает '[email protected]', а запрос ищет lower(...). Решение — индекс по выражению:

CREATE INDEX users_lower_email_idx ON users (lower(email));
CREATE INDEX products_color_idx    ON products ((attrs->>'color'));

Двойные скобки во втором примере — синтаксическая необходимость (Postgres требует выражение в скобках, кроме одного-имени-колонки).

Запросы должны использовать точно такое же выражение, как в индексе — иначе оптимизатор не поймёт, что индекс подходит.

Partial index — индекс на подмножестве строк

Если 99% строк имеют статус deleted и тебе их искать не нужно — нет смысла включать их в индекс. Partial index фильтрует, что в него попадает:

CREATE INDEX orders_active_idx
  ON orders (placed_at)
  WHERE status NOT IN ('cancelled', 'refunded');

Запрос WHERE placed_at > ... AND status NOT IN ('cancelled','refunded') будет использовать индекс, и сам индекс будет в несколько раз меньше полного.

Это особенно мощно с GIN или GiST на «горячем» подмножестве — например, бронирования будущих периодов.

Где это всё видно: цифры

Точных цифр без EXPLAIN ANALYZE не назвать, но порядок такой:

  • Seq scan на 10М строк JSONB с фильтром attrs @> '{...}' — десятки секунд.
  • С GIN-индексом — миллисекунды.

Разрыв растёт линейно с размером таблицы: на 100М строк seq scan становится минутами. Поэтому правило: если запрос на JSONB/массив/FTS вылетает в seq scan на таблице больше нескольких сотен тысяч строк, нужен индекс.

Какой индекс на какой тип

Шпаргалка: тип данных → подходящий индекс → какие операторы он ускоряет.

TEXT[]GIN
операторы@>, <@, &&
JSONBGIN (jsonb_path_ops)
операторы@>
tsvectorGIN
операторы@@
tstzrange, daterangeGiST
операторы&&, @>, <@

EXPLAIN — как увидеть, что индекс работает

В модуле 13 мы погрузимся в EXPLAIN глубоко, но даже сейчас полезно посмотреть, как выглядит «индекс работает» против «индекса нет».

Сравни: с индексом и без. В нашей маленькой таблице оптимизатор может выбрать seq scan даже при наличии индекса — но сам план изменится:

PostgreSQL

В выводе ищи строки Seq Scan on products vs Bitmap Index Scan on products_attrs_idx. На таблице из 20 строк seq scan выигрывает (потому что чтение страницы целиком быстрее, чем дополнительный hop через индекс). Но на 10M строк такой же запрос без индекса — секунды, с индексом — миллисекунды.

Множественные индексы и Bitmap AND

Postgres умеет объединять результаты нескольких индексов через bitmap operations. Если у тебя есть два индекса — на attrs и на category_id — и запрос фильтрует по обоим, оптимизатор может:

  1. Получить bitmap row-id из первого индекса (по attrs).
  2. Получить bitmap из второго (по category_id).
  3. Сделать AND между bitmaps.
  4. Прочитать только пересечение.

Это часто эффективнее, чем составной (multi-column) индекс, особенно когда фильтры приходят в разных комбинациях. На read-heavy таблицах с гибкими фильтрами правило большого пальца — много простых индексов, не один большой составной.

Когда индекс не нужен

Это контр-интуитивно, но иногда индекс вредит:

  • Маленькая таблица (тысячи строк). Seq scan всё равно быстрее — индекс просто занимает место и замедляет вставки.
  • Очень частые UPDATE колонки. Каждое UPDATE пересчитывает индекс — на горячих write-таблицах это видно.
  • Низкая селективность. Если запрос возвращает 60% таблицы, индекс не поможет — оптимизатор сам выберет seq scan.

Правило: индексы ставятся под конкретные запросы, не «на всякий случай». Запусти EXPLAIN ANALYZE, увидь bottleneck, поставь индекс — снова EXPLAIN. Это итеративный процесс, мы будем им заниматься плотнее в модуле 13.

Проверка знанийKnowledge check
В таблице events 50M строк с колонкой attrs JSONB. Часто бывает запрос WHERE attrs @> '{"source":"webhook"}' — он работает 25 секунд. На что поставить индекс и как именно?
ОтветAnswer
CREATE INDEX events_attrs_idx ON events USING GIN (attrs jsonb_path_ops). Outpost: (1) GIN — потому что @> оператор. (2) jsonb_path_ops — потому что фильтр всегда @>, не ?, не ?|. Это даст в 2-3 раза более компактный индекс и быстрее, чем дефолтный operator class. После создания запрос будет идти за миллисекунды. Дополнительно: если этот фильтр практически всегда комбинируется с временным диапазоном (WHERE created_at > ... AND attrs @>...), стоит проверить, что планировщик корректно использует индекс — иногда полезен составной BRIN на created_at плюс GIN на attrs. И — не забудь VACUUM/ANALYZE после миграции, иначе планировщик долго не будет знать о реальной селективности.
GIN: inverted index для arrays, JSONB и FTS — детальная механика GiST: фреймворк для range, geo и FTS индексов

Чек-лист

  • GIN для TEXT[], JSONB, tsvector. Идеален на read-heavy, дорог на write.
  • Для JSONB-фильтров через @>jsonb_path_ops компактнее обычного GIN.
  • GiST для range-типов и геометрии. Тот же индекс держит EXCLUDE constraint.
  • Expression index (CREATE INDEX ON t (lower(email))) — когда запросы используют функцию от колонки.
  • Partial index (... WHERE status='active') — индексировать только «горячее» подмножество.
  • На больших таблицах правильный спец-индекс — это разница «30 секунд vs миллисекунды».
  • Индексы ставятся под конкретные запросы, а не на всякий случай. Сначала — EXPLAIN, потом — индекс.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Какой индекс лучше поставить, чтобы ускорить запрос WHERE attrs @> '{"color":"black"}' на таблице с 10M строк?

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

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

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

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