Привет, Хабр!
В PostgreSQL есть два оператор‑класса для GIN‑индексов на JSONB‑столбцах: дефолтный jsonb_ops и альтернативный jsonb_path_ops. Документация сухо объясняет разницу в паре абзацев, на оверфлоу видим какие‑то противоречивые советы, в итоге большинство просто пишут CREATE INDEX ... USING GIN (data),то есть используют jsonb_ops по умолчанию, не задумываясь.
Между тем, на таблице в 10 миллионов строк неправильный выбор оператор‑класса может означать индекс в 4 раза больше нужного и запросы в 2–3 раза медленнее. Разберём, чем эти два класса отличаются на уровне внутренней структуры, какие операторы поддерживает каждый, и по каким критериям выбирать.
GIN (Generalized Inverted Index) — это инвертированный индекс. Та же идея, что в полнотекстовом поиске: вместо «документ содержит слова [a, b, c]» хранится «слово a встречается в документах [1, 5, 17]». Для JSONB это работает аналогично, но индексируются не слова, а элементы JSON‑структуры.
Ключевой вопрос — что именно считать «элементом». И вот тут‑то два оператор‑класса расходятся принципиально.
jsonb_ops: индексируем ключи и значения отдельно
CREATE INDEX idx_data ON products USING GIN (data); -- Эквивалентно: CREATE INDEX idx_data ON products USING GIN (data jsonb_ops);
jsonb_ops разбирает JSON‑документ на отдельные элементы и индексирует каждый по отдельности. Для документа:
{"brand": "Apple", "specs": {"ram": 16, "storage": 512}}
В GIN‑индекс попадут отдельные записи: ключ brand, значение "Apple", ключ specs, ключ ram, значение 16, ключ storage, значение 512. Каждый ключ и каждое значение — отдельная запись в инвертированном индексе, без информации о пути.
Это даёт широкий набор поддерживаемых операторов:
-- Containment (@>) — основной SELECT * FROM products WHERE data @> '{"brand": "Apple"}'; -- Existence (?) — есть ли ключ SELECT * FROM products WHERE data ? 'brand'; -- Any existence (?|) — есть ли хотя бы один из ключей SELECT * FROM products WHERE data ?| array['brand', 'category']; -- All existence (?&) — есть ли все ключи SELECT * FROM products WHERE data ?& array['brand', 'specs'];
Все четыре оператора используют GIN‑индекс. Это главное преимущество jsonb_ops — универсальность. Если запросы разнообразные и заранее непредсказуемые (пользователь может фильтровать по любому ключу), jsonb_ops покроет все случаи.
Но за универсальность приходится платить. Поскольку каждый ключ и значение индексируются отдельно, индекс не знает о структуре документа. Когда вы ищете data @> '{"brand": "Apple"}', GIN находит все строки, где есть ключ brand И значение "Apple", но — внимание — не знает, что "Apple" должен быть именно значением ключа brand. Он находит пересечение двух множеств. Для большинства данных это пересечение совпадает с правильным ответом, но в индексе хранится больше информации, чем нужно, и размер его соответствующий.
jsonb_path_ops: индексируем пути целиком
CREATE INDEX idx_data_path ON products USING GIN (data jsonb_path_ops);
jsonb_path_ops работает уже иначе. Вместо разбиения на отдельные ключи и значения он строит хеш полного пути от корня до значения. Для того же документа:
{"brand": "Apple", "specs": {"ram": 16, "storage": 512}}
В индекс попадут хеши путей: hash(brand, "Apple"), hash(specs, ram, 16), hash(specs, storage, 512). Каждая запись — полный путь от корня, а не отдельный фрагмент.
Это даёт две фичи.
Первая — размер. Поскольку путь хешируется в одно значение (а не раскладывается на N отдельных записей для N уровней вложенности), индекс занимает значительно меньше места. На реальных данных с глубоко вложенными JSON‑документами разница может быть 2–4 раза.
Второе — точность поиска. Когда вы ищете data @> '{"brand": "Apple"}', индекс находит строки по хешу пути hash(brand, "Apple"). Он не может ошибочно найти строку, где "Apple" — значение другого ключа. Меньше перепроверок, следовательно быстрее запрос.
Но есть ограничение. jsonb_path_ops поддерживает только оператор @> (containment). Операторы ?, ?|, ?& не работают, индекс просто не может ответить на вопрос «есть ли в документе ключ X», потому что он хранит хеши путей, а не сами ключи.
-- Работает с jsonb_path_ops SELECT * FROM products WHERE data @> '{"brand": "Apple"}'; -- НЕ работает с jsonb_path_ops (будет seq scan) SELECT * FROM products WHERE data ? 'brand';
Бенчмарк на данных
Возьмём сценарий: таблица events с 10 миллионов строк, JSONB‑столбец payload содержит событие аналитики с 15–20 ключами, вложенность до 3 уровней.
-- Создаём оба индекса CREATE INDEX idx_events_ops ON events USING GIN (payload); CREATE INDEX idx_events_path ON events USING GIN (payload jsonb_path_ops);
Размер индексов (конкретные цифры зависят от данных):
idx_events_ops: 1.8 GB idx_events_path: 620 MB
Разница почти в 3 раза. Для jsonb_path_ops меньше места на диске, меньше страниц в shared_buffers, быстрее VACUUM.
Запрос с оператором @>:
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM events WHERE payload @> '{"event_type": "purchase", "source": "mobile"}';
С jsonb_ops: Bitmap Index Scan, 1200 страниц, 45 мс. С jsonb_path_ops: Bitmap Index Scan, 380 страниц, 18 мс.
На простом запросе jsonb_path_ops быстрее в 2–2.5 раза. Причина в том, что у нас меньше объектов которые нужно перепроверять на основной таблице.
Partial GIN index: ещё точнее
Если запросы всегда фильтруют по определённому подмножеству данных, partial index ужимает индекс ещё сильнее:
-- Индексируем только активные товары категории electronics CREATE INDEX idx_products_electronics ON products USING GIN (data jsonb_path_ops) WHERE (data->>'status') = 'active' AND (data->>'category') = 'electronics';
Этот индекс будет использован только для запросов, в WHERE которых есть те же условия. Зато он в десятки раз меньше полного индекса.
Индекс на выражение: когда не нужен весь документ
Если запросы всегда обращаются к одному ключу, GIN‑индекс на весь JSONB уже какой‑то перегруз. Проще и эффективнее индексировать конкретное выражение:
-- Вместо GIN на всё CREATE INDEX idx_brand ON products USING GIN (data); -- B-tree на конкретный ключ CREATE INDEX idx_brand ON products ((data->>'brand')); -- Для запросов вида: SELECT * FROM products WHERE data->>'brand' = 'Apple';
B‑tree‑индекс на data->>'brand' будет в 10–50 раз меньше GIN‑индекса на весь data, и для равенства по одному ключу быстрее. GIN нужен, когда запросы разнообразные или используют containment на вложенных структурах.
Для поиска по диапазону во вложенном числовом поле:
CREATE INDEX idx_price ON products ((((data->'specs'->>'price')::numeric))); SELECT * FROM products WHERE (data->'specs'->>'price')::numeric BETWEEN 1000 AND 2000;
Комбинирование с jsonpath (PostgreSQL 12+)
С PostgreSQL 12 появились jsonpath‑операторы @? и @@. Они дают более выразительные запросы, но с GIN‑индексами работают только через jsonb_ops:
-- jsonpath: найти товары, где цена > 1000 SELECT * FROM products WHERE data @? '$.specs ? (@.price > 1000)'; -- Этот запрос может использовать GIN (jsonb_ops), но НЕ jsonb_path_ops
Если вам нужны jsonpath‑запросы с GIN‑индексом, jsonb_ops уже единственный вариант. Впрочем, для таких запросов часто эффективнее B‑tree или BRIN‑индекс на конкретное выражение.
Вообще, такие кейсы быстро показывают разницу между знанием синтаксиса SQL и рабочим пониманием того, как запросы ведут себя на реальных данных. Когда нужно не просто написать условие, а выбрать подход к индексированию, оценить план выполнения и не перегрузить систему лишней универсальностью, базовых знаний уже мало. Это как раз та практическая зона, на которую рассчитан курс «SQL для разработчиков и аналитиков».
JSON_TABLE: влияние на стратегию индексирования
PostgreSQL 17 добавил JSON_TABLE — функцию, которая разворачивает JSON‑массивы и объекты в реляционные строки. С тех пор вместо хранения всего в одном JSON‑столбце и хитрых индексов, можно развернуть данные в виртуальную таблицу и применить стандартные реляционные методы.
SELECT jt.* FROM orders, JSON_TABLE( order_data, '$.items[*]' COLUMNS ( product_name VARCHAR(100) PATH '$.name', quantity INT PATH '$.qty', price NUMERIC PATH '$.price' ) ) AS jt WHERE jt.price > 1000;
JSON_TABLE часто проще и понятнее, чем jsonb_array_elements + кастинг. Индексирование здесь работает на уровне внешних условий, а не на уровне содержимого JSON.
Как выбирать: алгоритм решения
Вопрос первый: запросы используют только
@>(containment)? Если да —jsonb_path_ops. Он меньше, быстрее, да и точнее. Нет причин использоватьjsonb_ops, если вам не нужны операторы?,?|,?&.Вопрос второй: нужны ли проверки наличия ключей (
?,?|,?&)? Если да — толькоjsonb_ops.jsonb_path_opsих не поддерживает.Вопрос третий: запросы всегда обращаются к одному‑двум конкретным ключам? Тогда, возможно, GIN не нужен вообще. B‑tree на
(data->>'field')будет компактнее и быстрее для equality и range запросов.Вопрос четвёртый: данные большие, вложенность глубокая, запросы разнообразные? Рассмотрите два индекса:
jsonb_path_opsдля containment‑запросов (основная нагрузка) и B‑tree‑индексы на конкретные поля для точечных запросов. Это дешевле одного раздутогоjsonb_ops.
И в любом случае — EXPLAIN (ANALYZE, BUFFERS) перед тем как решать.

А если хочется посмотреть смежные практические разборы, можно обратить внимание на открытые уроки:
5 мая, 20:00. «Postgres + JSON: реляционная мощь, документная гибкость».
Записаться21 мая, 20:00. «SQL: Обобщенное табличное выражение (CTE) — как писать сложные запросы просто».
Записаться
Если вам в целом интересна аналитика данных, SQL и прикладная работа с хранилищами, можно посмотреть и другие курсы из каталога по аналитике — там собраны программы для разных уровней и задач, от работы с запросами и данными до более широких аналитических инструментов. ☛
[Перейти к каталогу]