Acest articol este momentan în limba rusă — traducerea în engleză este în curs.
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, индекс не поможет.
Базе может быть дешевле прочитать всю таблицу, чем:
- сходить в индекс;
- получить огромное количество ссылок на строки;
- потом всё равно прочитать почти всю таблицу.
Индекс полезен, когда условие достаточно селективное.
То есть отбрасывает большую часть строк.
Пример хорошей селективности:
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, а уже потом выбирайте индекс.
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 есть разные способы индексации:
jsonb_ops;jsonb_path_ops;(payload ->> 'status');Выбор зависит от того, какие запросы вы реально выполняете.
Пример таблицы
Допустим, есть таблица событий:
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-документ — это не одно простое значение. Внутри него могут быть:
Когда мы пишем:
payload @> '{"status":"paid"}'мы спрашиваем:
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-индексов:
Например, если условие:
payload @> '{"status":"paid"}'подходит 90% таблицы, индекс может быть бесполезен.
Базе дешевле прочитать таблицу последовательно, чем идти в индекс, найти почти все строки, а потом всё равно читать большую часть таблицы.
Поэтому главное правило:
Оператор @> и GIN
Самый важный оператор для GIN-индексов по JSONB — это:
@>Он проверяет containment, то есть содержание JSONB-фрагмента.
Пример:
SELECT id FROM events WHERE 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: компактнее для @>
Есть второй операторный класс:
Индекс создаётся так:
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"}}';Но у него меньше возможностей.
Главное ограничение:
То есть такой запрос:
SELECT id FROM events WHERE payload ? 'coupon';не сможет использовать
jsonb_path_opsтак же, как обычныйjsonb_ops.jsonb_ops против jsonb_path_ops
Короткое сравнение:
Оба класса могут быть полезны.
Нельзя сказать, что один всегда лучше другого.
Выбор зависит от ваших запросов.
Когда выбирать 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 по всему документу.
Индекс по числовому выражению
Если поле внутри 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));Поэтому для важных числовых полей лучше:
Например, можно сделать частичный индекс:
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 полезна, когда:
Но если схема очень гибкая и ключей много, GIN по JSONB всё ещё может быть удобнее.
Когда лучше отдельная колонка
Иногда правильный ответ — не индекс, а изменение модели данных.
Если поле:
его лучше вынести в обычную колонку.
Например, вместо того чтобы хранить сумму только так:
{"amount": 1500}лучше иметь:
А
payloadоставить для дополнительных метаданных.Хорошее правило:
GIN-индекс не должен становиться способом спрятать плохую модель данных.
Как проверить, используется ли индекс
После создания индекса не нужно верить на слово, что запрос стал быстрее.
Проверяйте план:
EXPLAIN ANALYZE SELECT id FROM events WHERE payload @> '{"status":"paid","channel":"web"}';В хорошем случае можно увидеть что-то вроде:
Ключевые слова:
и имя вашего индекса.
Это значит, что PostgreSQL использует индекс.
Если видите:
значит база читает таблицу последовательно.
Но это не всегда ошибка. Возможно, условие слишком широкое и подходит большой части таблицы.
Почему планировщик может выбрать Seq Scan
Даже если индекс есть, PostgreSQL не обязан его использовать.
Например:
SELECT id FROM events WHERE payload @> '{"status":"paid"}';Если почти все события имеют статус
paid, индекс не поможет.Базе может быть дешевле прочитать всю таблицу, чем:
Индекс полезен, когда условие достаточно селективное.
То есть отбрасывает большую часть строк.
Пример хорошей селективности:
payload @> '{"status":"refunded","channel":"partner_x"}'Пример плохой селективности:
payload @> '{"active":true}'если почти все документы активные.
Важность ANALYZE
Планировщик выбирает план на основе статистики.
Если таблица сильно изменилась, статистика может устареть.
После больших загрузок данных полезно выполнить:
или:
Это помогает PostgreSQL лучше оценивать количество подходящих строк.
Если статистика плохая, база может выбрать неудачный план: например, не использовать индекс там, где он был бы полезен, или наоборот.
GIN и запись данных
GIN-индексы могут замедлять вставки и обновления.
Почему?
Потому что для одного JSONB-документа нужно записать в индекс много внутренних элементов.
Например, такой JSON:
{ "status": "paid", "channel": "web", "tags": ["promo", "eu"], "device": { "os": "ios", "version": "17" } }даёт индексу не одну запись, а много записей, связанных с ключами, значениями и структурой.
Если у вас таблица с большим потоком вставок, GIN может стать заметной нагрузкой.
Практически важно:
fastupdate и pending list
У GIN-индексов есть механизм
fastupdate.Когда он включён, PostgreSQL может сначала складывать изменения в специальный pending list, а потом переносить их в основную структуру индекса.
Обычно
fastupdateвключён по умолчанию.Это помогает вставкам, но pending list тоже нужно периодически очищать. Этим занимается autovacuum или ручной
VACUUM.Если таблица активно пишется и запросы внезапно становятся нестабильными по времени, стоит проверить обслуживание GIN-индексов и autovacuum.
Для большинства разработчиков базовый совет такой:
Типичные стратегии индексации 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;payload;status;Например, если
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, но это отдельная механика и другой синтаксис.
По смыслу рецепт похож:
Но прямого аналога PostgreSQL GIN для JSONB там нет.
ClickHouse: другой подход
В ClickHouse подход к JSON другой.
Там чаще:
Например:
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"}';Обновить статистику
или:
Частые ошибки
Создали 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}'если бизнес-вопрос:
Для диапазона нужен 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"}';Главные правила:
jsonb_ops— операторный класс по умолчанию;jsonb_opsподдерживает@>,?,?|,?&;jsonb_path_opsкомпактнее и часто быстрее для@>;jsonb_path_opsне поддерживает?,?|,?&;payload ->> 'key' = 'value'не использует GIN по всемуpayload;->>нужен индекс по выражению;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'));Главная мысль:
Не лечите медленный JSONB-запрос универсальным индексом вслепую. Сначала посмотрите на реальные предикаты, селективность и
EXPLAIN ANALYZE, а уже потом выбирайте индекс.