JSONB в PostgreSQL это не индекс листинга
GIN на payload выглядит полным. WHERE title->>'ru' всё равно идёт seq scan. Containment и extract это разные работы.
Листинг каталога в магазине, который переехал на PostgreSQL, всё ещё шёл seq scan. В магазинах, где уже был JSON в MySQL, каталог иногда переносили в PostgreSQL ради JSONB и GIN. Листинг по-прежнему фильтровал опубликованные строки и сортировал поле редактора. В WHERE стояло title->>'ru'. EXPLAIN показывал Seq Scan. GIN не использовался.
Редакторы ждали публичную сетку. PostgreSQL жег CPU на extract. Vue SSR печатал сорок плиток и платил чтением всей таблицы. GIN на JSONB собран под containment: payload @> '{"tag":"sale"}'. Оператор ->> даёт текст. Этот текст GIN не хранит, пока нет expression-индекса на extract.
JSONB оставляю документом админки. Публичный список его не читает.
Сгенерированный текст, btree для сетки
title_ru это stored generated-колонка из JSONB. SELECT листинга это id, slug, price, title_ru. Индекс (is_published, sort_order, id) покрывает эти поля. Сортировка на маленьком целом, его ставит редактор. Сортировка по title живёт в админке, не на публичной сетке.
Поиск, которому нужен JSON, держит GIN и использует @> или jsonb_path_exists. Смешать оба в одном запросе это seq scan и gin scan, который всё равно сортирует в work_mem.
Я пробовал expression-индекс на (title->>'ru'). Он помог фильтру одной локали. Листинг всё равно сортировал sort_order и всё равно нуждался в published. Два индекса, два плана, а третья локаль значит ещё два expression-индекса. Generated-колонки плюс один btree проще держать в голове. Записи чуть тяжелее. Сетка получила план, который я мог прочитать.
Что в JSONB не кладу
Цена, флаг публикации и порядок сортировки остаются типизированными колонками. Vue SSR получает одну строку локали. PHP не делает json_decode трёх языков, чтобы напечатать сорок плиток. MySQL и PostgreSQL с листинга выглядят одинаково: покрывающий btree, компактные строки, JSON вне горячего пути.
На этом сайте по-прежнему MySQL. PostgreSQL это другой каталог. Правило от движка не меняется. JSON это форма. Список это таблица.
После появления GIN я снял pg_stat_user_indexes. idx_scan у GIN на маршруте листинга оставался около нуля. idx_scan у btree пошёл. Это измерение закрыло спор, что JSONB уже проиндексирован.
Какой опыт из этого
На маршруте листинга читаю EXPLAIN и pg_stat_user_indexes, не факт наличия GIN. Неиспользуемый GIN плюс Seq Scan это обычная форма.
title->>'ru' я не верю, что он возьмёт GIN с payload. Containment это @>. Extract нужен generated-колонке или expression-индексу.
Правило, которое оставляю: типизированные колонки для цены, публикации, сортировки. Одна строка локали в Vue. JSONB остаётся документом админки. Сортировку листинга и JSON-поиск в одном запросе не смешивать.
