JSONB в PostgreSQL: когда и как хранить JSON в таблице
Практика4 минРедакция Innovialabs
Коротко
Храните JSON в колонке jsonb, индексируйте её GIN-индексом и используйте для действительно гибких данных вроде атрибутов товаров и сырых webhook; всё, по чему вы фильтруете, связываете и считаете деньги, выносите в обычные колонки.
Коротко: json или jsonb
В PostgreSQL есть два типа для JSON:
json | jsonb | |
|---|---|---|
| Как хранится | Как исходный текст | В разобранном бинарном виде |
| Пробелы и порядок ключей | Сохраняются | Не сохраняются |
| Повторяющиеся ключи | Сохраняются | Остаётся последний |
| Запись | Чуть быстрее | Чуть медленнее (разбор при вставке) |
| Чтение и поиск | Каждый раз разбирается заново | Быстрее |
| Индексы GIN | Нет | Да |
Практическое правило: почти всегда выбирайте jsonb. json нужен, только если важно сохранить документ байт в байт, например для проверки подписи входящего запроса.
Основные операторы
Пусть есть таблица товаров:
CREATE TABLE products (
id bigserial PRIMARY KEY,
name text NOT NULL,
price numeric(14, 2) NOT NULL,
attrs jsonb NOT NULL DEFAULT '{}'
);
INSERT INTO products (name, price, attrs) VALUES
('Ноутбук', 1000.00,
'{"brand": "Lenovo", "ram_gb": 16, "colors": ["black", "grey"], "dims": {"weight_kg": 1.4}}');
->возвращает значение какjsonb:attrs -> 'dims'.->>возвращает значение какtext:attrs ->> 'brand'.#>и#>>— то же по пути:attrs #>> '{dims,weight_kg}'.@>— «содержит»:attrs @> '{"brand": "Lenovo"}'.?— есть ли ключ верхнего уровня:attrs ? 'ram_gb'.
SELECT name, attrs ->> 'brand' AS brand
FROM products
WHERE attrs @> '{"colors": ["black"]}'
AND (attrs ->> 'ram_gb')::int >= 16;
Обратите внимание: ->> всегда возвращает текст, поэтому для сравнения чисел нужно явное приведение типа.
Индексы
GIN-индекс на всю колонку ускоряет @>, ?, ?| и ?&:
CREATE INDEX products_attrs_gin ON products USING GIN (attrs);
Если вы ищете только через @>, используйте класс операторов jsonb_path_ops — индекс получается компактнее, но не поддерживает ?:
CREATE INDEX products_attrs_path ON products USING GIN (attrs jsonb_path_ops);
B-tree по выражению — для одного ключа, по которому часто фильтруют или сортируют:
CREATE INDEX products_ram ON products (((attrs ->> 'ram_gb')::int));
Запрос должен использовать то же выражение, что и индекс, иначе планировщик индекс не возьмёт.
Обновление вложенных полей
-- заменить значение по пути
UPDATE products SET attrs = jsonb_set(attrs, '{dims,weight_kg}', '1.3') WHERE id = 1;
-- добавить или перезаписать ключи верхнего уровня
UPDATE products SET attrs = attrs || '{"warranty_months": 12}' WHERE id = 1;
-- удалить ключ и вложенный путь
UPDATE products SET attrs = attrs - 'colors' WHERE id = 1;
UPDATE products SET attrs = attrs #- '{dims,weight_kg}' WHERE id = 1;
Важные детали:
jsonb_setсоздаёт только последний ключ пути. Если промежуточного объекта (dims) нет, документ останется без изменений.- Оператор
||объединяет только верхний уровень: вложенный объект заменяется целиком, а не сливается. - В новых версиях PostgreSQL можно обновлять через индексацию:
SET attrs['brand'] = '"HP"'. - Любое изменение переписывает весь документ. Большие JSON, которые часто правят по одному полю, создают лишнюю нагрузку.
Когда JSON-колонка уместна
- Атрибуты товаров: у телефона — память и диагональ, у обуви — размер и материал. Общие поля (название, цена, остаток, категория) — колонки, специфичные —
jsonb. - Сырые webhook-платежи от Payme, Click и других провайдеров. Сохраните полный payload для разбора спорных ситуаций, а идентификатор транзакции, сумму и статус вынесите в колонки:
CREATE TABLE payment_events (
id bigserial PRIMARY KEY,
provider text NOT NULL,
external_id text NOT NULL,
status text NOT NULL,
amount numeric(14, 2) NOT NULL,
payload jsonb NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (provider, external_id, status)
);
Уникальный ключ защищает от повторной обработки одного и того же уведомления, если провайдер пришлёт его дважды.
- Настройки и метаданные, которые читаются целиком и редко фильтруются.
- Ответы внешних API, формат которых вы не контролируете.
Когда это антипаттерн
Тревожные признаки:
- поле есть у всех строк и участвует почти в каждом
WHERE; - по полю делают JOIN или в нём лежат id других записей — теряются внешние ключи;
- нужны ограничения:
NOT NULL, уникальность, тип,CHECK; - по полю считают деньги и отчёты: суммы, группировки;
- разные части кода пишут в JSON одно и то же под разными ключами.
Ещё одна причина: планировщик не собирает подробную статистику по ключам внутри jsonb, поэтому оценки для сложных запросов бывают неточными. Если поле стало важным — выносите его в колонку миграцией.