Прямой ответ
JSONB — бинарный JSON с индексируемостью: в отличие от текстового json, он парсится при записи (дороже вставка, дёшево чтение), не сохраняет порядок ключей/пробелы и поддерживает индексы — практически всегда выбирайте jsonb (json — редкие кейсы «сохранить исходный текст дословно»). Базовые операторы: -> (элемент как jsonb), ->> (как text — для сравнений/вывода), #>/#>> (по пути), @> (содержит — главный для фильтров: WHERE attrs @> '{"color":"red"}'), ?/?|/?& (наличие ключей), jsonb_path_query/@@ (JSONPath — сложные условия: attrs @@ '$.tags[*] == "vip"'), конкатенация || и удаление - (частичные обновления: UPDATE t SET attrs = attrs || '{"flag":true}'). Индексирование — два пути: GIN на всю колонку (CREATE INDEX ON t USING gin (attrs) — покрывает @>/existence-запросы произвольных ключей — универсальность ценой размера/скорости записи; jsonb_path_ops — компактнее и быстрее, но только @>) и выраженные B-tree (CREATE INDEX ON t ((attrs->>'email')) — конкретное поле в фильтре/сортировке — точечно, дёшево, быстро — как обычный индекс); выбор: 2–3 известных поля в запросах — выраженные; произвольные фильтры по динамическим атрибутам — GIN. Граница применимости (главное решение): JSONB — для полуструктуренного (атрибуты товара, метаданные события, payload вебхука, настройки пользователя — схема редкая/меняющаяся/разная у строк); данные, по которым идут JOIN/агрегации/FK/уникальность — обычные колонки (в JSONB нет внешних ключей, уникальность через выраженные индексы — костыль, JOIN по jsonb-полям — боль и seq scan — «спрятали order_id в JSON» — аналитика встанет). Правило команды: колонка — если поле есть у большинства строк и участвует в запросах-фильтрах/связях; JSONB — «остальные» атрибуты; гибрид — норма (orders: колонки status/total/created_at + jsonb shipping_details — лучшие из обоих — схемное мышление — миграции колонок обычны, JSONB-поля меняются без DDL). Типизация приложения: Pydantic-модель на JSONB-поле (валидация — «свободный JSON» без схемы на входе — мусор накапливается — схема в коде обязательна даже без схемы в БД); SQLAlchemy — JSONB тип + .astext/операторы в запросах (ORM).
Запросы: от простых к JSONPath
Паттерны: чтение поля — attrs->>'email' (text — сравнивается/сортируется обычно; attrs->'address'->>'city' — вложенность), фильтрация по вложению — @> ('{"tags":["vip"]}' — массив содержит — вхождение элемента тоже через @>), поиск по массиву — jsonb_array_elements(attrs->'items') (развёртка в строки — LATERAL-запросы — агрегация по элементам массива: сумма позиций всех заказов — «JSON как таблица» — работает, но на больших объёмах — планы покажут цену — регулярную аналитику по массивам лучше нормализовать в строки); JSONPath (12+): jsonb_path_query(attrs, '$.items[*].price ? (@ > 100)') — фильтрация/проекция внутри JSON (мощно для ad-hoc; в WHERE — attrs @@ '$.items[*].status == "pending"' — GIN jsonb_path_ops поддерживает часть предикатов — план проверять); обновление: jsonb_set(attrs, '{address,city}', '"Berlin"') (точечное поле — без перезаписи всего документа), || слияние, - 'key'/#- '{path}' удаление; частичный UPDATE — миф в контексте MVCC (jsonb_set перезаписывает всю строку (TOAST-нюансы aside) — частые мелкие правки большого jsonb — раздувание/нагрузка — vacuum-последствия — «лог изменений в jsonb-поле» — антипаттерн); агрегации — jsonb_agg/jsonb_object_agg (сборка JSON-ответа прямо в SQL — «заказ с позициями одним запросом» — удобно для API-сериализации — API-практика); NULL-семантика — attrs->>'missing' → NULL (не ошибка — фильтры с NULL учитываем — обычная SQL-трёхзначность).
Индексы: GIN, выраженные, комбинации
GIN (Generalized Inverted Index) — инвертированный индекс по парам ключ-значение: один индекс на колонку покрывает @>/?-запросы по любым ключам; цена: размер (×2–5 к данным — метки в GIN больше), запись медленнее (fastupdate-буфер сглаживает, но сжатия/GC — фон — обслуживание); jsonb_path_ops — опция (только @>, но индекс ×2–3 компактнее и быстрее — если JSONPath/existence не нужны — берите); частичные GIN (WHERE условие — индекс по подмножеству строк — «GIN только на активные заказы» — размер вниз); выраженные B-tree — (attrs->>'status') — точечные фильтры/сортировки (equality/range/ORDER BY — GIN сортировки не умеет вовсе — «ORDER BY attrs->>'date'» — только B-tree выраженный); функциональные с приведением (((attrs->>'created')::timestamptz) — range-запросы по «дате внутри JSON» — работают как обычный индекс — тип в выражении стабилен — смена формата значения ломает индекс — схема в коде защищает); комбинированные — B-tree (колонка + jsonb-выражение) ((tenant_id, (attrs->>'priority')) — составные индексы — префиксное правило то же); проверка пользы — EXPLAIN (Index Scan против Bitmap/Seq — чтение планов) и pg_stat_user_indexes.idx_scan (ноль сканов за месяц — индекс-паразит — удаление); типичные промахи: GIN на колонку, где фильтруют 2 поля (выраженные дешевле в разы), -> вместо ->> в фильтре (jsonb = text сравнение не сработает — тип должен совпасть — «индекс есть, а план seq» — половина случаев — приведение/оператор), запрос по вложенному пути без индекса на путь (GIN покрывает containment, не «произвольный путь = X» — для путей — выраженный или @>-переформулировка: attrs @> '{"address":{"city":"Berlin"}}' вместо attrs#>>'{address,city}' = 'Berlin' — containment использует GIN, равенство пути — нет — переформулировка даёт индекс без нового DDL).
Практический сценарий: атрибуты товаров — от EAV к JSONB
Исходник: маркетплейс, у товаров «характеристики» в EAV-схеме (таблица attribute_values: product_id/key/value — классика 2010-х): фильтр «цвет=красный И размер=L» — два self-JOIN'а, три категории товаров — 40 ключей, план — seq-сканы на 4M строк, p95 поиска 2.4 с; добавление характеристики — строки, схема молчит, но запросы не читаются. Перевод на JSONB (итерация): (1) колонка attrs jsonb на products + Pydantic-схемы атрибутов по категориям (валидация на записи — «свободный JSON» закрыт — валидация; редкие/уникальные поля — остаются колонками (sku/brand_id — FK и уникальность живые); (2) миграция данных — батчами (UPDATE ... SET attrs = (SELECT jsonb_object_agg(key, value) FROM attribute_values ...) WHERE id BETWEEN — Alembic-батчи — по 50k строк, без долгих блокировок); (3) запросы: фильтры — containment (attrs @> '{"color":"red","size":"L"}' — один предикат вместо двух JOIN — читается и планируется лучше), сортировки по популярным полям — выраженные B-tree (price уже колонка — ((attrs->>'weight')::numeric) — для «лёгкие сначала»); (4) индексы: GIN jsonb_path_ops на attrs (фильтры произвольные — фасетный поиск — GIN оправдан размером 1.8 ГБ на 4M строк) + частичный GIN WHERE status='active' (поиск только по активным — индекс −40%); (5) проверка планов — Bitmap Index Scan на gin по всем фасетным запросам, p95 поиска 2.4 с → 90 мс; EAV-таблица оставлена read-only на месяц (откат-страховка), затем удалена; (6) границы зафиксированы в ADR: «JSONB — атрибуты категорий; колонки — всё с FK/уникальностью/частыми JOIN; новые фильтруемые поля высокой селективности — кандидаты в выраженные индексы»; находка на ревью: «рейтинг в attrs» хотели — не дали (агрегации/сортировки по нему постоянные — колонка с индексом — проектирование — JSONB не свалка для всего). Побочный выигрыш: onboard новой категории — JSON-схема атрибутов без DDL/миграции (раньше — EAV-настройка + новые JOIN-запросы — дни → часы — продуктовая скорость, ради которой JSONB и берут).
FAQ
JSONB против отдельной таблицы (нормализации) — как выбрать? Критерии за JSONB: схема варьируется по строкам (атрибуты категорий), поля читаются «целиком с объектом» (детальная карточка), изменения схемы часты (без DDL/миграций), запросы — containment-фильтры, не JOIN; за колонки/таблицы: FK-целостность, уникальность, JOIN-аналитика, агрегации, range-фильтры/сортировки как основной паттерн; «документная БД внутри Postgres» — работает для read-целостных объектов (событие/лог/настройка), не для связанных сущностей (заказ-позиции — таблицы — JSON-массив позиций внутри заказа убивает аналитику); гибрид «горячее в колонках, остальное в jsonb» — самый частый правильный ответ.
Почему запрос по jsonb-полю не использует GIN-индекс?
Чек-лист: оператор не индексный (GIN покрывает @>, ?, ?|, ?&, @@ (path_ops — только @>) — равенство ->>'значение' — не GIN-запрос (нужен выраженный B-tree или переформулировка в containment), типы не совпали (-> вернул jsonb, сравнили с text — приведение сломало соответствие индексу), запрос селективен слабо (планировщик честнее seq scan'ом — планы — GIN не «всегда быстрее»), индекс path_ops, а запрос — existence (jsonb_path_ops не поддерживает ? — обычный gin или B-tree), статистика устарела (ANALYZE после массовой загрузки — оценки врют); диагностика — EXPLAIN (ANALYZE, BUFFERS) — причина всегда видна в плане/условиях.
Можно ли в JSONB уникальность и внешние ключи?
Уникальность — частично: выраженный уникальный индекс UNIQUE ((attrs->>'email')) (одно-два поля — работает; «уникальность всего документа» — уникальность по md5(attrs — трюк — редко нужна); FK — нет (ссылка внутри JSON не проверяется констрейнтом — целостность на приложении или триггерах (боль — не делайте)); если FK/уникальность критичны — это колонка, не JSONB-поле (граница из первого вопроса); частичная замена — CHECK-констрейты на выражения (CHECK (attrs ? 'email') — присутствие ключа гарантировано — схема-минимум в БД — дополняет Pydantic-валидацию, не заменяет).
Что с производительностью записи при активной работе с JSONB?
Цены: парсинг при вставке (jsonb тяжелее json/текста — единицы процентов — незаметно), GIN-обслуживание (fastupdate-буфер → периодические слияния — пики латентности записи — gin_pending_list_limit тюнинг; на write-heavy — взвесить, нужен ли GIN вообще), перезапись строки при jsonb_set (частые мелкие правки большого документа — раздувание + WAL — vacuum не успевает — hot-поля выносить в колонки (counter/статус — колонки, «холодные» атрибуты — jsonb); TOAST (jsonb > ~2 КБ — выносится в TOAST-таблицу — чтение «одного поля» тянет весь документ (деточить/сжать — LZ по умолчанию) — огромные jsonb-документы (сотни КБ) — сигнал к нормализации/внешнему хранилищу (S3 + ссылка); мониторинг — размер колонки/TOAST + dead tuples — стандартные метрики.
Источники: документация PostgreSQL (datatype-json, json functions, indexes) — ссылки в шапке.