sqlpostgresqljsonbgin

Indexing JSONB with GIN: jsonb_ops vs jsonb_path_ops

How to speed up JSONB filters in PostgreSQL with GIN indexes, and when to pick jsonb_path_ops over the default operator class.

12 perc olvasásReferencesql · postgresql · jsonb · gin · index · json
Ez a cikk jelenleg oroszul van — az angol fordítás folyamatban van.

jsonb в PostgreSQL удобен, когда нужно хранить гибкие структурированные данные.

Например, событие может иметь разную полезную нагрузку:

{
  "status": "paid",
  "channel": "web",
  "plan": "pro",
  "tags": ["beta", "eu"],
  "device": {
    "os": "ios",
    "app_version": "1.4.2"
  }
}

На маленькой таблице можно спокойно писать фильтры по JSONB:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';

Но на большой таблице без индекса такой запрос может превратиться в полное сканирование.

PostgreSQL будет читать строки одну за другой, доставать JSONB, проверять условие и отбрасывать неподходящие документы.

На миллионах строк это быстро становится больно.

Для таких задач в PostgreSQL есть GIN-индексы.

Но здесь важно не просто написать:

CREATE INDEX ...

и надеяться на магию.

У JSONB есть разные способы индексации:

  • GIN по всему документу;
  • GIN с операторным классом jsonb_ops;
  • GIN с операторным классом jsonb_path_ops;
  • B-tree индекс по выражению вроде (payload ->> 'status');
  • частичные индексы под конкретные условия;
  • иногда отдельная обычная колонка вместо JSONB.

Выбор зависит от того, какие запросы вы реально выполняете.

Пример таблицы

Допустим, есть таблица событий:

CREATE TABLE events (
  id         bigint PRIMARY KEY,
  user_id    bigint NOT NULL,
  payload    jsonb  NOT NULL,
  created_at timestamptz DEFAULT now()
);

В payload лежит JSONB с параметрами события:

{
  "status": "paid",
  "channel": "web",
  "amount": 1500,
  "tags": ["promo", "eu"],
  "device": {
    "os": "ios"
  }
}

Типовые запросы могут быть такими:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';
SELECT id
FROM events
WHERE payload @> '{"device":{"os":"ios"}}';
SELECT id
FROM events
WHERE payload ? 'coupon';
SELECT id
FROM events
WHERE payload ->> 'status' = 'paid';

Выглядят похоже, но для индексов это разные случаи.

Зачем нужен GIN

Обычный B-tree индекс хорошо работает, когда мы сравниваем одно значение:

WHERE status = 'paid'

или:

WHERE created_at >= '2026-01-01'

Но JSONB-документ — это не одно простое значение. Внутри него могут быть:

  • ключи;
  • строки;
  • числа;
  • boolean;
  • вложенные объекты;
  • массивы;
  • объекты внутри массивов.

Когда мы пишем:

payload @> '{"status":"paid"}'

мы спрашиваем:

содержит ли JSONB-документ такой фрагмент?

GIN-индекс устроен как инвертированный индекс. Он помогает быстро находить строки, где внутри JSONB есть нужные ключи, значения или фрагменты.

Пример создания GIN-индекса:

CREATE INDEX events_payload_gin_idx
ON events
USING gin (payload);

После этого запрос вида:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';

может использовать индекс.

GIN — это не бесплатное ускорение

GIN-индекс ускоряет чтение, но за это приходится платить.

Минусы GIN-индексов:

  • они могут занимать много места;
  • вставки становятся дороже;
  • обновления JSONB-колонки становятся дороже;
  • индексу нужно обслуживание;
  • при низкой селективности планировщик всё равно может выбрать последовательное сканирование.

Например, если условие:

payload @> '{"status":"paid"}'

подходит 90% таблицы, индекс может быть бесполезен.

Базе дешевле прочитать таблицу последовательно, чем идти в индекс, найти почти все строки, а потом всё равно читать большую часть таблицы.

Поэтому главное правило:

Индекс нужен не потому, что колонка JSONB, а потому что есть частый и достаточно селективный запрос.

Оператор @> и GIN

Самый важный оператор для GIN-индексов по JSONB — это:

@>

Он проверяет containment, то есть содержание JSONB-фрагмента.

Пример:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';

Это означает:

payload содержит фрагмент {"status":"paid"}

Если в документе есть дополнительные поля, это нормально.

Документ:

{
  "status": "paid",
  "channel": "web",
  "amount": 1500
}

содержит фрагмент:

{"status": "paid"}

Поэтому условие истинно.

GIN-индекс по JSONB как раз хорошо подходит для таких проверок.

jsonb_ops: операторный класс по умолчанию

Если написать GIN-индекс так:

CREATE INDEX events_payload_gin_idx
ON events
USING gin (payload);

PostgreSQL создаст индекс с операторным классом по умолчанию — jsonb_ops.

То есть это примерно то же самое, что:

CREATE INDEX events_payload_gin_idx
ON events
USING gin (payload jsonb_ops);

jsonb_ops — самый универсальный вариант.

Он поддерживает больше операторов, в том числе:

@>

проверка содержания JSONB-фрагмента.

?

проверка наличия ключа или строкового элемента.

?|

проверка наличия хотя бы одного ключа из списка.

?&

проверка наличия всех ключей из списка.

Также современные версии PostgreSQL поддерживают JSONPath-операторы @? и @@.

Но для большинства практических задач новичку сначала важно запомнить @> и ?.

Пример jsonb_ops

Создадим обычный GIN-индекс:

CREATE INDEX events_payload_ops_idx
ON events
USING gin (payload);

Теперь запрос с @> может использовать индекс:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';

Запрос с проверкой ключа тоже может использовать этот индекс:

SELECT id
FROM events
WHERE payload ? 'coupon';

То есть jsonb_ops удобен, когда вы используете разные JSONB-операторы.

Например:

payload @> '{"status":"paid"}'
payload ? 'coupon'
payload ?| array['coupon', 'discount']
payload ?& array['status', 'channel']

Цена универсальности — размер индекса.

jsonb_ops обычно больше, потому что индексирует больше информации.

jsonb_path_ops: компактнее для @>

Есть второй операторный класс:

jsonb_path_ops

Индекс создаётся так:

CREATE INDEX events_payload_path_idx
ON events
USING gin (payload jsonb_path_ops);

jsonb_path_ops обычно меньше по размеру и часто быстрее для containment-запросов через @>.

Например:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';

или:

SELECT id
FROM events
WHERE payload @> '{"device":{"os":"ios"}}';

Но у него меньше возможностей.

Главное ограничение:

jsonb_path_ops не поддерживает операторы существования ключей ?, ?|, ?&.

То есть такой запрос:

SELECT id
FROM events
WHERE payload ? 'coupon';

не сможет использовать jsonb_path_ops так же, как обычный jsonb_ops.

jsonb_ops против jsonb_path_ops

Короткое сравнение:

jsonb_ops
- операторный класс по умолчанию;
- поддерживает больше операторов;
- подходит для @>, ?, ?|, ?&;
- индексирует ключи и значения шире;
- индекс обычно больше.

jsonb_path_ops
- оптимизирован под containment-запросы;
- хорошо подходит для @>;
- обычно компактнее;
- часто быстрее для @>;
- не поддерживает ?, ?|, ?&.

Оба класса могут быть полезны.

Нельзя сказать, что один всегда лучше другого.

Выбор зависит от ваших запросов.

Когда выбирать jsonb_ops

Выбирайте обычный GIN-индекс jsonb_ops, если у вас разные типы запросов к JSONB.

Например:

payload @> '{"status":"paid"}'
payload ? 'coupon'
payload ?| array['coupon', 'promo_code']
payload ?& array['status', 'channel']

В таком случае универсальность важнее компактности.

Создание индекса:

CREATE INDEX events_payload_ops_idx
ON events
USING gin (payload);

Это хороший стартовый вариант, если вы ещё не уверены, какие JSONB-операторы будут основными.

Когда выбирать jsonb_path_ops

Выбирайте jsonb_path_ops, если основные запросы — это containment через @>.

Например:

payload @> '{"status":"paid"}'
payload @> '{"status":"paid","channel":"web"}'
payload @> '{"device":{"os":"ios"}}'
payload @> '{"tags":["promo"]}'

Индекс:

CREATE INDEX events_payload_path_idx
ON events
USING gin (payload jsonb_path_ops);

Такой индекс часто будет меньше и быстрее именно для @>.

Но если завтра вы начнёте активно писать:

payload ? 'coupon'

то jsonb_path_ops под этот оператор не поможет.

Важная ошибка: ->> не использует GIN по payload

Очень частая ситуация.

Разработчик создаёт GIN-индекс:

CREATE INDEX events_payload_gin_idx
ON events
USING gin (payload);

А потом пишет запрос:

SELECT id
FROM events
WHERE payload ->> 'status' = 'paid';

И удивляется, почему GIN не используется.

Причина простая: этот запрос не проверяет JSONB containment.

Он достаёт текстовое значение:

payload ->> 'status'

и сравнивает его со строкой:

= 'paid'

GIN-индекс по всей JSONB-колонке обычно нужен для операторов вроде:

payload @> '{"status":"paid"}'

Если вы хотите использовать GIN, перепишите запрос через @>:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';

Если же вам нравится запрос через ->>, создавайте индекс по выражению.

Индекс по выражению для одного поля

Если вы часто фильтруете по одному конкретному полю, GIN по всему документу может быть избыточным.

Например, частый запрос:

SELECT id
FROM events
WHERE payload ->> 'status' = 'paid';

Для него можно создать B-tree индекс по выражению:

CREATE INDEX events_payload_status_idx
ON events ((payload ->> 'status'));

Теперь PostgreSQL может использовать этот индекс для фильтра:

WHERE payload ->> 'status' = 'paid'

Такой индекс обычно:

  • меньше GIN;
  • проще;
  • дешевле в обслуживании;
  • хорошо работает для конкретного поля;
  • поддерживает обычные сравнения по тексту.

Для одного стабильного ключа это часто лучше, чем GIN по всему документу.

Индекс по числовому выражению

Если поле внутри JSONB числовое, можно создать индекс с приведением типа.

Например, в payload есть:

{
  "amount": 1500
}

Запрос:

SELECT id
FROM events
WHERE (payload ->> 'amount')::numeric > 1000;

Индекс:

CREATE INDEX events_payload_amount_idx
ON events (((payload ->> 'amount')::numeric));

Теперь такой запрос потенциально может использовать B-tree индекс:

WHERE (payload ->> 'amount')::numeric > 1000

Это важное отличие от GIN.

@> хорошо проверяет равенство фрагменту:

payload @> '{"amount":1500}'

Но для диапазона:

amount > 1000

нужен B-tree по выражению или обычная колонка.

Грязные данные и индекс по cast

С индексами по приведению типа нужно быть аккуратным.

Если в JSONB где-то лежит не число:

{"amount": "unknown"}

то выражение:

(payload ->> 'amount')::numeric

упадёт с ошибкой.

Это может сломать не только запрос, но и создание индекса:

CREATE INDEX events_payload_amount_idx
ON events (((payload ->> 'amount')::numeric));

Поэтому для важных числовых полей лучше:

  • валидировать данные при записи;
  • хранить число в обычной колонке;
  • использовать generated column;
  • создавать частичный индекс только на валидные значения;
  • не считать финансовую аналитику напрямую по грязному JSONB.

Например, можно сделать частичный индекс:

CREATE INDEX events_payload_amount_valid_idx
ON events (((payload ->> 'amount')::numeric))
WHERE payload ->> 'amount' ~ '^-?[0-9]+(\.[0-9]+)?$';

И запрос должен повторять условие валидности:

SELECT id
FROM events
WHERE payload ->> 'amount' ~ '^-?[0-9]+(\.[0-9]+)?$'
  AND (payload ->> 'amount')::numeric > 1000;

Так индекс и запрос говорят об одном и том же наборе строк.

Частичный индекс

Частичный индекс полезен, если вас интересует только часть JSONB-документов.

Например, вы часто ищете только paid-события:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';

Можно создать частичный индекс:

CREATE INDEX events_paid_idx
ON events (created_at)
WHERE payload @> '{"status":"paid"}';

Такой индекс будет содержать только строки, где статус paid.

Это может быть намного меньше, чем индекс по всей таблице.

Другой пример: индекс по каналу только для paid-событий:

CREATE INDEX events_paid_channel_idx
ON events ((payload ->> 'channel'))
WHERE payload @> '{"status":"paid"}';

Запрос:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}'
  AND payload ->> 'channel' = 'web';

Частичные индексы очень полезны, когда у вас есть частый фильтр, который выбирает небольшую и важную часть таблицы.

Generated column как альтернатива

Если поле внутри JSONB стало важным для бизнес-логики, можно вынести его в generated column.

Например:

ALTER TABLE events
ADD COLUMN status text
GENERATED ALWAYS AS (payload ->> 'status') STORED;

Теперь можно создать обычный индекс:

CREATE INDEX events_status_idx
ON events (status);

И писать простой запрос:

SELECT id
FROM events
WHERE status = 'paid';

Это часто лучше, чем постоянно доставать поле из JSONB.

Generated column полезна, когда:

  • поле часто используется в фильтрах;
  • поле нужно сортировать;
  • поле участвует в JOIN;
  • поле нужно типизировать;
  • вы хотите сделать план проще и понятнее.

Но если схема очень гибкая и ключей много, GIN по JSONB всё ещё может быть удобнее.

Когда лучше отдельная колонка

Иногда правильный ответ — не индекс, а изменение модели данных.

Если поле:

  • всегда есть;
  • важно для бизнеса;
  • часто фильтруется;
  • участвует в отчётах;
  • используется в JOIN;
  • имеет строгий тип;
  • нужно для ограничений и проверок,

его лучше вынести в обычную колонку.

Например, вместо того чтобы хранить сумму только так:

{"amount": 1500}

лучше иметь:

amount numeric

А payload оставить для дополнительных метаданных.

Хорошее правило:

Ядро модели — обычные колонки.
Гибкие дополнительные данные — JSONB.

GIN-индекс не должен становиться способом спрятать плохую модель данных.

Как проверить, используется ли индекс

После создания индекса не нужно верить на слово, что запрос стал быстрее.

Проверяйте план:

EXPLAIN ANALYZE
SELECT id
FROM events
WHERE payload @> '{"status":"paid","channel":"web"}';

В хорошем случае можно увидеть что-то вроде:

Bitmap Heap Scan on events
  Recheck Cond: (payload @> '{"status": "paid", "channel": "web"}'::jsonb)
  -> Bitmap Index Scan on events_payload_path_idx
       Index Cond: (payload @> '{"status": "paid", "channel": "web"}'::jsonb)

Ключевые слова:

Bitmap Index Scan

и имя вашего индекса.

Это значит, что PostgreSQL использует индекс.

Если видите:

Seq Scan on events

значит база читает таблицу последовательно.

Но это не всегда ошибка. Возможно, условие слишком широкое и подходит большой части таблицы.

Почему планировщик может выбрать Seq Scan

Даже если индекс есть, PostgreSQL не обязан его использовать.

Например:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';

Если почти все события имеют статус paid, индекс не поможет.

Базе может быть дешевле прочитать всю таблицу, чем:

  1. сходить в индекс;
  2. получить огромное количество ссылок на строки;
  3. потом всё равно прочитать почти всю таблицу.

Индекс полезен, когда условие достаточно селективное.

То есть отбрасывает большую часть строк.

Пример хорошей селективности:

payload @> '{"status":"refunded","channel":"partner_x"}'

Пример плохой селективности:

payload @> '{"active":true}'

если почти все документы активные.

Важность ANALYZE

Планировщик выбирает план на основе статистики.

Если таблица сильно изменилась, статистика может устареть.

После больших загрузок данных полезно выполнить:

ANALYZE events;

или:

VACUUM ANALYZE events;

Это помогает PostgreSQL лучше оценивать количество подходящих строк.

Если статистика плохая, база может выбрать неудачный план: например, не использовать индекс там, где он был бы полезен, или наоборот.

GIN и запись данных

GIN-индексы могут замедлять вставки и обновления.

Почему?

Потому что для одного JSONB-документа нужно записать в индекс много внутренних элементов.

Например, такой JSON:

{
  "status": "paid",
  "channel": "web",
  "tags": ["promo", "eu"],
  "device": {
    "os": "ios",
    "version": "17"
  }
}

даёт индексу не одну запись, а много записей, связанных с ключами, значениями и структурой.

Если у вас таблица с большим потоком вставок, GIN может стать заметной нагрузкой.

Практически важно:

  • не создавать GIN «на всякий случай»;
  • не держать несколько похожих GIN-индексов без причины;
  • следить за размером индексов;
  • проверять реальные запросы;
  • регулярно обслуживать таблицы через autovacuum/VACUUM;
  • понимать, что ускорение чтения оплачивается замедлением записи.

fastupdate и pending list

У GIN-индексов есть механизм fastupdate.

Когда он включён, PostgreSQL может сначала складывать изменения в специальный pending list, а потом переносить их в основную структуру индекса.

Обычно fastupdate включён по умолчанию.

Это помогает вставкам, но pending list тоже нужно периодически очищать. Этим занимается autovacuum или ручной VACUUM.

Если таблица активно пишется и запросы внезапно становятся нестабильными по времени, стоит проверить обслуживание GIN-индексов и autovacuum.

Для большинства разработчиков базовый совет такой:

Не трогайте fastupdate, пока не понимаете конкретную проблему. Но помните, что GIN требует нормального VACUUM-обслуживания.

Типичные стратегии индексации JSONB

Стратегия 1: много разных containment-фильтров

Если запросы разные и часто используют @>:

payload @> '{"status":"paid"}'
payload @> '{"device":{"os":"ios"}}'
payload @> '{"tags":["promo"]}'
payload @> '{"plan":"pro","active":true}'

подойдёт GIN:

CREATE INDEX events_payload_path_idx
ON events
USING gin (payload jsonb_path_ops);

Если ещё нужны ?, ?|, ?&, берите обычный jsonb_ops:

CREATE INDEX events_payload_ops_idx
ON events
USING gin (payload);

Стратегия 2: один частый текстовый ключ

Если почти все запросы такие:

WHERE payload ->> 'status' = 'paid'

лучше B-tree по выражению:

CREATE INDEX events_status_expr_idx
ON events ((payload ->> 'status'));

Стратегия 3: один частый числовой ключ

Если часто фильтруете по числу:

WHERE (payload ->> 'amount')::numeric > 1000

можно сделать индекс:

CREATE INDEX events_amount_expr_idx
ON events (((payload ->> 'amount')::numeric));

Но только если данные чистые и приводятся к числу.

Стратегия 4: поле стало частью бизнес-модели

Если поле status используется везде, лучше вынести его:

ALTER TABLE events
ADD COLUMN status text
GENERATED ALWAYS AS (payload ->> 'status') STORED;

CREATE INDEX events_status_idx ON events (status);

Или сразу хранить его как обычную колонку при записи.

@> или ->>: что выбрать для запроса

Оба запроса могут искать статус paid:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';

и:

SELECT id
FROM events
WHERE payload ->> 'status' = 'paid';

Но индексная стратегия разная.

Для первого запроса:

payload @> '{"status":"paid"}'

подходит GIN по JSONB.

Для второго:

payload ->> 'status' = 'paid'

подходит B-tree по выражению:

CREATE INDEX events_status_expr_idx
ON events ((payload ->> 'status'));

Выбор зависит от задачи.

Если вы проверяете JSONB-фрагменты разной формы — удобнее @> и GIN.

Если вы постоянно фильтруете одно поле — удобнее ->> и индекс по выражению.

Индекс должен совпадать с выражением в запросе

PostgreSQL использует индекс по выражению, когда выражение в запросе совпадает с выражением в индексе.

Если индекс такой:

CREATE INDEX events_status_expr_idx
ON events ((payload ->> 'status'));

то запрос должен быть таким:

SELECT id
FROM events
WHERE payload ->> 'status' = 'paid';

Если вы напишете иначе:

WHERE lower(payload ->> 'status') = 'paid'

этот индекс уже не тот.

Для такого запроса нужен другой индекс:

CREATE INDEX events_status_lower_idx
ON events (lower(payload ->> 'status'));

И запрос:

SELECT id
FROM events
WHERE lower(payload ->> 'status') = 'paid';

Главная мысль:

Индекс по выражению ускоряет именно это выражение, а не все похожие варианты.

Комбинирование с обычными колонками

Часто JSONB-фильтр идёт вместе с обычными колонками.

Например:

SELECT id
FROM events
WHERE created_at >= now() - interval '7 days'
  AND payload @> '{"status":"paid"}';

Тут могут быть полезны разные стратегии:

  • индекс по created_at;
  • GIN по payload;
  • частичный индекс;
  • партиционирование по дате;
  • generated column status;
  • составной B-tree индекс по обычным колонкам и выражениям.

Например, если status вынесен в generated column:

CREATE INDEX events_status_created_idx
ON events (status, created_at);

Тогда запрос становится проще:

SELECT id
FROM events
WHERE status = 'paid'
  AND created_at >= now() - interval '7 days';

Это часто понятнее и быстрее, чем пытаться лечить всё одним GIN-индексом.

MySQL: без GIN

В MySQL нет PostgreSQL-GIN-индексов для JSONB.

Для JSON-полей там обычно используют другие подходы.

Например, generated column:

ALTER TABLE events
ADD COLUMN status VARCHAR(50)
GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(payload, '$.status'))) STORED;

CREATE INDEX events_status_idx ON events (status);

После этого можно писать:

SELECT id
FROM events
WHERE status = 'paid';

Для массивов в MySQL 8 есть multi-valued indexes, но это отдельная механика и другой синтаксис.

По смыслу рецепт похож:

часто используемое JSON-поле лучше вынести в индексируемое выражение или сгенерированную колонку.

Но прямого аналога PostgreSQL GIN для JSONB там нет.

ClickHouse: другой подход

В ClickHouse подход к JSON другой.

Там чаще:

  • извлекают нужные поля функциями;
  • используют материализованные колонки;
  • используют skip-индексы;
  • проектируют таблицу под аналитические запросы;
  • стараются хранить часто используемые поля в отдельных колонках.

Например:

SELECT id
FROM events
WHERE JSONExtractString(payload, 'status') = 'paid';

Для частого фильтра лучше материализовать поле в отдельную колонку, чем каждый раз разбирать JSON на лету.

ClickHouse — колоночная аналитическая СУБД, и там обычно выигрывает явная структура данных под запросы.

Практические шаблоны

Обычный GIN по JSONB

CREATE INDEX events_payload_ops_idx
ON events
USING gin (payload);

Подходит для:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';

и для:

SELECT id
FROM events
WHERE payload ? 'coupon';

GIN для containment через jsonb_path_ops

CREATE INDEX events_payload_path_idx
ON events
USING gin (payload jsonb_path_ops);

Подходит для частых запросов:

SELECT id
FROM events
WHERE payload @> '{"status":"paid","channel":"web"}';

B-tree индекс по текстовому полю JSONB

CREATE INDEX events_status_expr_idx
ON events ((payload ->> 'status'));

Запрос:

SELECT id
FROM events
WHERE payload ->> 'status' = 'paid';

B-tree индекс по числовому полю JSONB

CREATE INDEX events_amount_expr_idx
ON events (((payload ->> 'amount')::numeric));

Запрос:

SELECT id
FROM events
WHERE (payload ->> 'amount')::numeric > 1000;

Частичный индекс под paid-события

CREATE INDEX events_paid_created_idx
ON events (created_at)
WHERE payload @> '{"status":"paid"}';

Запрос:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}'
  AND created_at >= now() - interval '7 days';

Индекс по lower для case-insensitive поиска

CREATE INDEX events_status_lower_idx
ON events (lower(payload ->> 'status'));

Запрос:

SELECT id
FROM events
WHERE lower(payload ->> 'status') = 'paid';

Проверка плана

EXPLAIN ANALYZE
SELECT id
FROM events
WHERE payload @> '{"status":"paid","channel":"web"}';

Обновить статистику

ANALYZE events;

или:

VACUUM ANALYZE events;

Частые ошибки

Создали GIN, но запрос написали через ->>

Индекс:

CREATE INDEX events_payload_gin_idx
ON events
USING gin (payload);

Запрос:

SELECT id
FROM events
WHERE payload ->> 'status' = 'paid';

Такой запрос не использует GIN по payload как containment-индекс.

Используйте либо:

WHERE payload @> '{"status":"paid"}'

либо создайте индекс по выражению:

CREATE INDEX events_status_expr_idx
ON events ((payload ->> 'status'));

Выбрали jsonb_path_ops, а потом ждёте ускорения для ?

Индекс:

CREATE INDEX events_payload_path_idx
ON events
USING gin (payload jsonb_path_ops);

Запрос:

SELECT id
FROM events
WHERE payload ? 'coupon';

jsonb_path_ops не предназначен для ?.

Для таких операторов нужен обычный jsonb_ops:

CREATE INDEX events_payload_ops_idx
ON events
USING gin (payload);

Используют @> для диапазона

Плохо:

payload @> '{"amount":1000}'

если бизнес-вопрос:

amount > 1000

Для диапазона нужен cast и B-tree по выражению или отдельная колонка:

WHERE (payload ->> 'amount')::numeric > 1000

Создали слишком много индексов

Например:

CREATE INDEX events_payload_ops_idx ON events USING gin (payload);
CREATE INDEX events_payload_path_idx ON events USING gin (payload jsonb_path_ops);
CREATE INDEX events_status_expr_idx ON events ((payload ->> 'status'));
CREATE INDEX events_channel_expr_idx ON events ((payload ->> 'channel'));

Иногда это оправданно.

Но часто это просто лишняя нагрузка на запись и хранение.

Каждый индекс должен отвечать на конкретный частый запрос.

Не проверили EXPLAIN ANALYZE

Создание индекса ещё не означает, что планировщик его использует.

Всегда проверяйте:

EXPLAIN ANALYZE
SELECT ...

И смотрите не только на наличие индекса, но и на реальное время, количество строк, тип сканирования и селективность.

Что важно запомнить

GIN-индексы помогают ускорять поиск внутри jsonb, особенно containment-запросы через @>.

Пример:

CREATE INDEX events_payload_gin_idx
ON events
USING gin (payload);

Запрос:

SELECT id
FROM events
WHERE payload @> '{"status":"paid"}';

Главные правила:

  • GIN полезен для поиска по структуре JSONB;
  • jsonb_ops — операторный класс по умолчанию;
  • jsonb_ops поддерживает @>, ?, ?|, ?&;
  • jsonb_path_ops компактнее и часто быстрее для @>;
  • jsonb_path_ops не поддерживает ?, ?|, ?&;
  • payload ->> 'key' = 'value' не использует GIN по всему payload;
  • для ->> нужен индекс по выражению;
  • для диапазонов по числам лучше B-tree по выражению или отдельная колонка;
  • GIN-индексы занимают место и замедляют запись;
  • индекс должен соответствовать реальному запросу;
  • всегда проверяйте план через EXPLAIN ANALYZE.

Короткий вывод

Для JSONB в PostgreSQL нет одного идеального индекса на все случаи.

Если запросы такие:

payload @> '{"status":"paid"}'

и таких фильтров много по разным JSONB-фрагментам, смотрите в сторону GIN.

Если нужны разные операторы, включая проверку ключа:

payload ? 'coupon'

берите обычный jsonb_ops.

Если почти все запросы — containment через @>:

payload @> '{"device":{"os":"ios"}}'

можно рассмотреть jsonb_path_ops.

Если же вы постоянно фильтруете одно поле:

payload ->> 'status' = 'paid'

часто лучше создать B-tree индекс по выражению:

CREATE INDEX events_status_expr_idx
ON events ((payload ->> 'status'));

Главная мысль:

GIN нужен для поиска по JSONB-документу. Индекс по выражению нужен для конкретного JSONB-поля. Обычная колонка нужна для важного стабильного бизнес-поля.

Не лечите медленный JSONB-запрос универсальным индексом вслепую. Сначала посмотрите на реальные предикаты, селективность и EXPLAIN ANALYZE, а уже потом выбирайте индекс.

Gyakorolj valós feladatokon

Oldj meg feladatokat az SQL-trénerben azonnali értékeléssel és tippekkel.

Tréner megnyitása