open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 06 · 03

JSONB глубоко: операторы, GIN и граница

jsonb бинарный, дедуплицирует ключи и индексируем; json — это текст. Освой операторы и containment по GIN и пойми реальную границу — типизированные колонки для горячих фильтров, jsonb для разреженного хвоста.

SQL Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Команда смоделировала всю таблицу events как один jsonb-блоб — «бессхемно, на будущее». Через год дашборд с фильтром WHERE payload->>'tenant_id' = '7' делал sequential scan по 90 млн строк, потому что внутри JSON никто не построил индекс, а каждое обновление статуса переписывало весь документ. Фикс был не в отказе от JSONB, а в том, чтобы поднять горячие поля в реальные колонки и оставить JSONB для разреженного хвоста. JSONB — точный инструмент. Как свалка, он тихо становится самой медленной твоей таблицей.

jsonb против json: бинарный побеждает

У Postgres два JSON-типа, и они не взаимозаменяемы:

  • json хранит точный текст, что ты дал, — пробелы, порядок ключей и дубли ключей сохранены. Каждое чтение перепарсивает его. Полезных индексов по его содержимому нет.
  • jsonb парсит при записи в разложенную бинарную форму: ключи дедуплицируются (побеждает последнее значение), порядок не сохраняется, а чтения быстры, потому что нет перепарсинга. Критично: jsonb можно индексировать через GIN.

Для всего, что ты запрашиваешь, ответ — jsonb. Используй json только в редком случае, когда нужно вернуть точный исходный текст (например, сохранить подпись над сырыми байтами). Цена парсинга jsonb платится один раз; выигрыши чтения и индекса — навсегда.

Словарь операторов

Когда пишешь запрос к jsonb-колонке, неверный оператор тихо сравнивает не тот тип — и построенный тобой GIN-индекс не сработает. По jsonb навигируешь небольшим набором операторов, и ловушка, кусающая всех, такова: -> возвращает jsonb, ->> возвращает text.

SELECT
  payload -> 'user'             AS user_json,    -- jsonb (вложенный объект)
  payload -> 'user' ->> 'name'  AS user_name,    -- text  (цепочка -> затем ->>)
  payload #> '{user,roles,0}'   AS first_role_j, -- jsonb по пути
  payload #>> '{user,roles,0}'  AS first_role_t  -- text  по пути
FROM events;
  • -> получить поле/элемент как jsonb; ->> получить поле/элемент как text.
  • #> получить по пути как jsonb; #>> получить по пути как text.
  • @> containment — содержит ли левый документ правый? payload @> '{"tenant_id": 7}'.
  • ? есть ли в документе этот ключ верхнего уровня? payload ? 'tenant_id'.

Различие text против jsonb важно, потому что ->>'tenant_id' = '7' сравнивает текст, а @> '{"tenant_id": 7}' сравнивает структуру — и только форма containment хорошо использует GIN-индекс.

GIN-индексация для containment

Простой B-tree не умеет индексировать «есть ли этот ключ/значение где-то внутри документа». Для этого нужен GIN (Generalized Inverted Index): он индексирует содержимое каждого jsonb-значения, так что запросы containment (@>) и key-exists (?) минуют sequential scan.

-- дефолтный GIN: поддерживает @>, ?, ?|, ?& — больше индекс, больше операторов
CREATE INDEX ON events USING gin (payload);

-- jsonb_path_ops: поддерживает только @> — меньше и быстрее для containment
CREATE INDEX ON events USING gin (payload jsonb_path_ops);

-- теперь это использует индекс вместо скана 90M строк:
SELECT * FROM events WHERE payload @> '{"tenant_id": 7}';

Два варианта GIN — реальный компромисс. Дефолтный jsonb_ops индексирует каждый ключ и значение, поддерживая @>, ?, ?|, ?& — гибко, но крупнее. jsonb_path_ops индексирует только хешированные пути-к-значениям, так что поддерживает только @>, но индекс меньше, а поиск containment быстрее. Бери jsonb_path_ops, когда всё, что ты делаешь, — containment (частый случай); бери дефолт, когда нужны и операторы существования ключа.

Граница: где JSONB оправдывает место

Это сеньорское суждение. JSONB верен для разреженного, меняющего схему длинного хвоста — метаданных на событие, различающихся по типу события, опциональных атрибутов, заданных лишь у 2% строк, сторонних payload, которые ты не контролируешь. Он неверен как замена колонкам, по которым ты фильтруешь, сортируешь, джойнишь или ставишь ограничения. Они принадлежат типизированным колонкам: tenant_id bigint, status text, created_at timestamptz — типизированные, способные быть NOT NULL, CHECK-нутыми и индексируемые дешёвым B-tree.

А связи — вовсе не работа JSONB. Список id тегов внутри документа нельзя связать внешним ключом, нельзя эффективно джойнить, и он вынуждает переписать весь документ, чтобы добавить один тег. Многие-ко-многим принадлежит боковой таблице (event_tags(event_id, tag_id)) с настоящими внешними ключами. (В главе relational-model трека databases есть отдельный урок о jsonb и массивах со стороны хранения.)

Обновления: jsonb_set и цена всего документа

Поле jsonb обновляют через jsonb_set (или операторы слияния || / удаления -):

UPDATE events
SET payload = jsonb_set(payload, '{status}', '"shipped"', true)  -- true = создать, если нет
WHERE id = 42;

Цена, удивляющая людей: нет правки одного ключа на месте. jsonb хранится как единое значение, так что смена одного поля читает весь документ, строит новую версию и пишет её обратно — а MVCC делает это совершенно новой версией строки (мёртвый кортеж для вакуума). Документ в 30 КБ, обновляемый 100 раз в секунду, — это 3 МБ/с переписи плюс bloat. Это сильнейший аргумент держать горячие, часто меняемые поля своими колонками: обновление колонки status text тоже переписывает строку, но ты избегаешь сериализации и перепарсинга толстого документа при каждом изменении.

Граничные случаи

Тонкая ловушка корректности: jsonb_set возвращает NULL, если целевой документ — SQL NULL, тихо стирая колонку. Защищайся через COALESCE(payload, '{}'::jsonb). Также ->> по отсутствующему ключу возвращает SQL NULL (не ошибку), так что WHERE payload->>'flag' = 'true' тихо исключает строки, где flag отсутствует — что может быть, а может и не быть тем, что ты хочешь. Снисходительность JSONB удобна, пока не прячет баг; именно эта снисходительность — причина, почему горячие, обязанные быть корректными поля безопаснее как колонки с ограничениями.

Викторина

В чём ключевая практическая разница между json и jsonb?

Викторина

Ты фильтруешь events по тенанту в большинстве запросов, и таблица — 90M строк. Где должен жить tenant_id?

Закончи аналогию

Заполни пропуск: чтобы сделать WHERE payload @> '{"tenant_id": 7}' быстрым на большой таблице, ты создаёшь _______ индекс на jsonb-колонке, индексирующий содержимое документа для containment.

Вспомните перед уходом
  1. 01
    Почему предпочесть jsonb json и что возвращают -> и ->>?
  2. 02
    Как сделать containment-запрос по jsonb быстрым и в чём компромисс jsonb_path_ops против дефолтного GIN?
  3. 03
    Где граница между типизированной колонкой и jsonb и почему jsonb_set churn'ит?
Итог

jsonb парсит при записи в разложенную бинарную форму — дедуплицированные ключи, без перепарсинга при чтении и индексируем GIN — поэтому он бьёт json (сырой текст, перепарсивается, неиндексируем) для всего, что запрашиваешь; резервируй json для редкого возврата точного текста. Навигируй через ->/->> (jsonb против text), #>/#>> (по пути), @> (containment) и ? (существование ключа), помня, что хорошо индексируется только форма containment. GIN-индекс заставляет @> и ? минуть sequential scan; выбирай jsonb_path_ops (меньше, только @>) или дефолт (крупнее, больше операторов). Сеньорское суждение — граница: поднимай горячие поля фильтра/джойна/ограничения в типизированные колонки, держи jsonb для разреженного длинного хвоста, а связи многие-ко-многим помещай в боковые таблицы с настоящими внешними ключами — никогда массивы id, зарытые в документ. Наконец, каждый jsonb_set переписывает весь документ в новую версию строки MVCC, так что толстые, часто меняемые документы churn’ят и пухнут — ещё причина держать горячие поля своими колонками. Теперь, когда увидишь payload->>'tenant_id' = '7' в фильтре по таблице из 90 млн строк, сразу задашь вопрос: это горячее поле, заслуживающее типизированной колонки и B-tree? Если да — поднимай.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.