Гайды
JSONB в PostgreSQL: когда использовать и как индексировать
json vs jsonb, операторы, GIN и выраженные индексы, jsonb_set, валидация и типичные ошибки при смешанной модели.
~14 мин чтения
JSONB в PostgreSQL: когда и как использовать
JSONB — бинарное представление JSON: нормализация ключей, эффективный доступ, индексы (GIN, btree по выражению и др.). Тип json хранит текст и парсит при чтении — почти всегда для новых проектов выбирайте jsonb, если нет узкой причины оставаться на json.
Сравнение json и jsonb
json | jsonb | |
|---|---|---|
| Хранение | Исходный текст | Декомпозированное бинарное дерево |
| Вставка | Часто быстрее | Чуть дороже (нормализация) |
| Чтение / поиск | Медленнее | Быстрее |
| Индексы | Ограниченно | GIN, btree по выражению, частичные |
Когда JSONB уместен
- Часто меняющаяся или заранее нефиксируемая схема — настройки, метаданные, атрибуты продуктов.
- Сырой ответ внешнего API — сохранить как есть для аудита и последующей нормализации.
- EAV-подобные «доп. поля» без сотен nullable-колонок.
- Логи и события с разной структурой payload.
- Прототипирование до стабилизации требований.
- «Ленивая» нормализация — сначала документ, потом витрина.
Пример: настройки пользователя
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
settings JSONB NOT NULL DEFAULT '{}'
);
INSERT INTO users (name, settings) VALUES
('Alice', '{"theme": "dark", "notifications": {"email": true, "push": false}}'),
('Bob', '{"theme": "light", "locale": "ru"}');
Пример: лог API
CREATE TABLE api_logs (
id BIGSERIAL PRIMARY KEY,
endpoint TEXT NOT NULL,
request_time TIMESTAMPTZ DEFAULT now(),
request_data JSONB,
response_data JSONB
);
Когда не стоит злоупотреблять JSONB
- Жёстко структурированные поля (
email,phone) — теряются FK, строгая типизация на уровне столбцов, проще оптимизировать JOIN. - Частые точечные обновления без
jsonb_set— перезапись большого документа дороже, больше bloat. - Высокая кардинальность «внутри» JSON — часто выгоднее вынести в столбец и индексировать его напрямую.
- Тяжёлый OLAP на миллиардах строк — специализированные движки / колоночные хранилища.
Правило: идентификаторы, FK и поля фильтрации отчётов — в нормальные колонки; «хвост» гибких полей — в jsonb.
Операторы и функции
Доступ по ключам
-- -> JSONB | ->> text
SELECT '{"a":1,"b":{"c":"hello"}}'::jsonb -> 'b' -> 'c';
SELECT '{"a":1,"b":{"c":"hello"}}'::jsonb #>> '{b,c}';
Наличие ключей
SELECT * FROM users WHERE settings ? 'locale';
SELECT * FROM users WHERE settings ?| ARRAY['theme', 'timezone'];
Обновление
UPDATE users
SET settings = jsonb_set(settings, '{notifications,email}', 'false', true)
WHERE name = 'Alice';
UPDATE users SET settings = settings - 'locale';
Объединение
SELECT '{"a":1}'::jsonb || '{"b":2}'::jsonb;
SELECT '{"a":1}'::jsonb || '{"a":99}'::jsonb; -- ключ a перезаписан
Агрегация
SELECT jsonb_agg(settings) FROM users;
SELECT jsonb_object_agg(name, settings) FROM users;
Индексы
GIN по всему документу
CREATE INDEX idx_users_settings ON users USING gin (settings);
Ускоряет @>, ?, ?|, ?&. Не заменяет btree для условий вида settings->>'theme' = 'dark' — для этого нужен выраженный индекс или переписывание запроса под @>.
B-tree по выражению
CREATE INDEX idx_users_theme ON users ((settings->>'theme'));
jsonb_path_ops (компактнее для @>)
CREATE INDEX idx_users_settings_path ON users USING gin (settings jsonb_path_ops);
Не поддерживает ? — выбирайте операторный класс под реальные запросы.
Массив внутри документа
CREATE INDEX idx_docs_tags ON documents USING gin ((data->'tags'));
Производительность (порядок величин)
- Поиск
->>по 10M строк без индекса — секунды (Seq Scan). - С btree на
(settings->>'theme')— миллисекунды. jsonb_setна большом документе переписывает весь JSONB — следите за размером документа (часто >10–20 КБ уже боль).
Валидация
ALTER TABLE users ADD CONSTRAINT settings_theme_valid
CHECK ((settings->>'theme') IS NULL OR length(settings->>'theme') > 0);
Для строгих JSON Schema смотрите расширения вроде pg_jsonschema (отдельная установка, не «из коробки» в vanilla).
Разворачивание в реляционный вид
SELECT id, elem->>'tag' AS tag, (elem->>'count')::int AS cnt
FROM documents,
jsonb_array_elements(documents.data->'items') AS elem;
SELECT *
FROM users,
jsonb_to_recordset(users.settings->'preferences') AS pref(foo text, bar int);
Типичные ошибки
| Ошибка | Как лучше |
|---|---|
Вся сущность в одном data | Вынести ключи, FK, фильтры в колонки |
| JSONB как первичный ключ | Использовать surrogate key (int / UUID) |
GIN создан, а фильтр через ->> | B-tree на выражение или запрос через @> |
| Большие документы целиком обновлять | jsonb_set или вынести горячие поля |
Нет DEFAULT '{}' / NOT NULL | Защита от NULL в приложении |
Пример: товары и атрибуты
CREATE TABLE products (
id SERIAL PRIMARY KEY,
sku TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
price NUMERIC(10,2),
attributes JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_products_attrs ON products USING gin (attributes);
CREATE INDEX idx_products_weight ON products ((attributes->>'weight'));
SELECT * FROM products
WHERE attributes @> '{"brand": "Apple"}'
AND (attributes->>'weight')::float > 0.5;
Итог
JSONB — расширение реляционной модели, а не замена: фундамент таблицы — нормализованные столбцы; JSONB — для гибкости, логов и полусхематичных полей. Индексируйте под реальные предикаты запросов.