JSONB: хранение и доступ

Как хранить в одной колонке целый документ и доставать из него любое поле без отдельных таблиц.

jsonb — бинарный тип PostgreSQL для хранения JSON-документов: данные разбираются при вставке и хранятся в дереве, благодаря чему доступ к полям и индексация работают быстро.

Реляционная модель любит, когда у каждого факта своя колонка. Но в реальных проектах бывают данные с плавающей структурой: настройки пользователя, атрибуты товара из разных категорий, ответ внешнего API, лог события. Заводить под каждое возможное поле колонку — значит плодить десятки NULL-ов. PostgreSQL предлагает компромисс: положить такой документ целиком в одну колонку типа jsonb и всё равно обращаться к его полям прямо в SQL.

Зачем это на практике

Три типичных повода. Первый — полуструктурированные данные: у товара «ноутбук» есть диагональ и вес, у товара «книга» — автор и число страниц; единой схемы нет. Второй — гибкие настройки: профиль пользователя, где набор полей со временем растёт, а городить миграцию на каждый чекбокс не хочется. Третий — сырой ответ интеграции: сохранить JSON от платёжного шлюза как есть, чтобы потом разобраться, что именно он прислал.

json против jsonb

В PostgreSQL два типа: json и jsonb. Тип json хранит текст дословно — с пробелами, порядком ключей и даже дублями ключей. Тип jsonb при вставке разбирает документ в бинарное дерево: пробелы теряются, ключи упорядочиваются, дубли схлопываются (остаётся последний). За разбор платят чуть более медленной вставкой, зато доступ к полям, операторы поиска и, главное, индексы работают только с jsonb.

Свойствоjsonjsonb
Хранениетекст как естьразобранное дерево
Дубли ключейсохраняютсяостаётся последний
Порядок ключейсохраняетсяне гарантирован
Индексация (GIN)нетда
Когда братьаудит/лог «байт-в-байт»почти всегда

Практическое правило: если вы собираетесь искать или фильтровать по содержимому — берите jsonb. Чистый json оправдан, лишь когда документ нужно вернуть точно в том виде, в каком он пришёл.

Создаём таблицу и кладём документы

Объявить колонку просто. Литерал JSON — это обычная строка, которую PostgreSQL приводит к jsonb.

CREATE TABLE product (
  id     bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title  text NOT NULL,
  attrs  jsonb NOT NULL DEFAULT '{}'
);

INSERT INTO product (title, attrs) VALUES
  ('Ноутбук Pro', '{"brand": "Acme", "ram": 16, "screen": {"size": 14, "oled": true}, "tags": ["work", "travel"]}'),
  ('Книга по SQL', '{"author": "К. Дейт", "pages": 560, "tags": ["db", "reference"]}');

Обратите внимание: у двух строк разный набор ключей внутри attrs — это и есть гибкость JSONB. Схему таблицы это не нарушает.

Операторы доступа: -> и ->>

Два главных оператора отличаются типом результата. Стрелка -> достаёт поле как jsonb (можно идти глубже), а двойная стрелка ->> достаёт поле как text (для сравнения, вывода, приведения к числу).

SELECT
  attrs -> 'brand'        AS brand_jsonb,   -- "Acme" (с кавычками, тип jsonb)
  attrs ->> 'brand'       AS brand_text,    -- Acme (тип text)
  attrs ->> 'ram'         AS ram_text,      -- 16, но это ТЕКСТ
  (attrs ->> 'ram')::int  AS ram_int        -- 16 как число
FROM product
WHERE title = 'Ноутбук Pro';

Вывод:

brand_jsonb | brand_text | ram_text | ram_int
"Acme"      | Acme       | 16       | 16

Ключевая мысль: число внутри JSON, добытое через ->>, приходит как текст. Чтобы сравнить его как число (например, ram > 8), нужно явно привести: (attrs ->> 'ram')::int. Без приведения сравнение пойдёт лексикографически, и '9' > '16' окажется истиной.

Вложенность: цепочки и оператор #>

Стрелки -> можно выстраивать в цепочку, спускаясь по дереву. Для массивов индекс задаётся числом (нумерация с нуля). А оператор #> (и его текстовый собрат #>>) принимает путь массивом ключей сразу.

SELECT
  attrs -> 'screen' ->> 'size'        AS size_chain,  -- через цепочку
  attrs #>> '{screen,size}'           AS size_path,   -- через путь
  attrs -> 'tags' ->> 0               AS first_tag,   -- первый элемент массива
  attrs #> '{screen}'                 AS screen_obj   -- вложенный объект как jsonb
FROM product
WHERE title = 'Ноутбук Pro';

Вывод:

size_chain | size_path | first_tag | screen_obj
14         | 14        | work      | {"size": 14, "oled": true}

Путь '{screen,size}' — это литерал массива текстов. Он удобен, когда глубина известна заранее и не хочется писать длинную цепочку стрелок. Если по пути чего-то нет, оператор вернёт NULL, а не ошибку — это важно для документов с плавающей структурой.

Как это работает под капотом

При INSERT в колонку jsonb PostgreSQL не хранит вашу строку дословно. Парсер разбирает её в дерево объектов, массивов и скаляров и сериализует в компактный бинарный формат, где ключи объекта отсортированы, а к значениям есть быстрый доступ по смещению. Поэтому при чтении поля движку не нужно заново парсить текст — он идёт сразу к нужной ветке. Отсюда же три «побочных эффекта» jsonb: исчезают незначащие пробелы, теряется исходный порядок ключей и схлопываются дубликаты. Для json ничего этого не происходит: там лежит ваша строка байт-в-байт, а каждый доступ к полю заново её разбирает.

Когда JSONB вместо отдельных колонок

JSONB — мощный инструмент, но не замена нормализации. Ориентир такой:

  • Берите JSONB, когда набор полей плавающий, разрежённый или заранее неизвестен; когда данные приходят документом извне; когда поля редко участвуют в строгих ограничениях и связях.
  • Берите обычные колонки для атрибутов, по которым вы постоянно фильтруете, джойните, накладываете FOREIGN KEY, CHECK или UNIQUE. Тип колонки даёт строгую валидацию и предсказуемые планы запросов «из коробки».

Частый рабочий гибрид: важные поля (price, category_id, created_at) — отдельными колонками со связями и индексами, а «всё остальное и переменчивое» — в одной колонке attrs jsonb.

Частые ошибки

  • Путают -> и ->>. -> возвращает jsonb (строка остаётся в кавычках: "Acme"), ->> — чистый text. Для WHERE и вывода почти всегда нужен ->>.
  • Сравнивают числа как текст. attrs ->> 'ram' > '8' сравнивает строки, а не числа. Приводите: (attrs ->> 'ram')::int > 8.
  • Выбирают json вместо jsonb «по привычке». По json нельзя построить GIN-индекс и нет операторов поиска. Если не нужен дословный текст — это всегда jsonb.
  • Ждут ошибку на отсутствующем ключе. attrs ->> 'missing' вернёт NULL, а не упадёт. Это удобно, но требует аккуратной обработки NULL в условиях.

Итоги

  • jsonb хранит документ разобранным деревом — быстрый доступ и индексируемость; json хранит текст дословно.
  • -> достаёт поле как jsonb, ->> — как text; для сравнений берите ->> и приводите тип.
  • В глубину идут цепочкой стрелок или путём через #> / #>>; индекс массива — число с нуля.
  • JSONB — для плавающих и разрежённых данных; то, по чему фильтруете и связываете, лучше держать отдельными колонками.
Проверьте себя
1. Чем оператор ->> отличается от -> при доступе к полю JSONB?
A->> возвращает значение как text, а -> возвращает его как jsonb
B->> работает только с массивами, а -> только с объектами
C->> возвращает jsonb, а -> возвращает text
DНикакой разницы нет, это синонимы
2. Почему условие (attrs ->> 'ram') > '8' может работать неверно?
AПотому что ->> не существует для чисел
BПотому что ->> возвращает текст, и сравнение идёт лексикографически, а не как числа
CПотому что JSONB не хранит числа
DПотому что нужно писать -> вместо ->>
3. В каком случае разумнее выбрать json, а не jsonb?
AКогда нужно строить GIN-индекс по содержимому
BКогда требуется быстрый доступ к вложенным полям
CКогда документ нужно вернуть дословно, с исходными пробелами и порядком ключей
DКогда в документе есть массивы