GIN, который не видел запрос листинга
WHERE title->>'ru' ILIKE сканировал пятьдесят тысяч строк JSONB. GIN был для тегов. Каталог платил за extract.
Каталог жил на PostgreSQL, потому что JSONB и GIN казались бесплатным индексом поиска. Публичная сетка фильтровала опубликованные SKU и искала русский title через title->>'ru' ILIKE. EXPLAIN сказал Seq Scan, rows 50000. CPU сидел на extract. GIN на payload в план не входил.
GIN закрывает containment. Листинг просил текст. Vue SSR всё равно получал весь JSONB и выбирал локаль в PHP. Кэш буферов держал документы, которые страница выбрасывала.
Проблема была в том, что GIN приняли за индекс title. Посетители платили seq scan по пятидесяти тысячам строк JSONB. Нужна stored text-колонка, btree на сетке и extract вне горячего пути.
Сгенерированная колонка
title_ru это stored generated-колонка. SELECT листинга это id, slug, price, title_ru. Btree (is_published, sort_order, id) покрывает сетку. Поиск по title, когда он есть, идёт по этой текстовой колонке, не через extract в WHERE. Теги держат GIN и @>.
Что PHP перестал делать
json_decode ушёл с листинга. Воркер отдаёт одну строку локали в Vue SSR. Админка по-прежнему правит JSONB. Публичный путь как магазин на MySQL на этом сайте: компактные строки, покрывающий индекс, JSON вне горячего пути.
ILIKE по generated text может взять trigram-индекс, если поиск остаётся. Фильтр листинга его не берёт. Published плюс sort_order хватает для сетки. Поиск это второй запрос, не нагрузка на каждую страницу.
Какой опыт из этого
GIN это containment. title->> ILIKE это seq scan, пока текст не живёт в своей колонке.
JSONB это формат записи. Публичная сетка это типизированные колонки. Кейс filesort в MySQL на этом сайте это тот же урок в другом движке.
EXPLAIN дешевле нового кэша. GIN был здоров и не использовался. Запрос его не просил.
