Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
🧱
Данные
Опубликовано:
19.06.2026
Обновлено:
09.07.2026

Когда JSONB помогает, а когда тормозит: полный разбор

Данил Мануйлов

Одно поле data JSONB - и не нужны миграции. Звучит соблазнительно. Но через полгода такое решение может превратить быстрый SELECT в полное сканирование таблицы. Разберём, где JSONB реально экономит время, а где тихо убивает производительность.

Что такое JSONB и чем он отличается от JSON

JSONB - тип данных PostgreSQL, который хранит JSON в бинарном формате. При вставке PostgreSQL разбирает текст и сохраняет структуру напрямую в двоичном виде. Это убирает парсинг при каждом чтении, поэтому запросы к JSONB быстрее, чем к обычному JSON.

Хранение: бинарный формат против текстового

JSON хранит текст «как есть», сохраняя порядок ключей и дублирующиеся ключи. JSONB не гарантирует порядок ключей и убирает дубли - при совпадении ключей выигрывает последний. Если порядок ключей важен для логики приложения, используйте JSON. В остальных случаях JSONB предпочтительнее.

JSON vs JSONB: таблица отличий

Характеристика JSON JSONB
Хранение Текст (как есть) Бинарный формат
Запись Быстрее Чуть медленнее (парсинг при вставке)
Чтение и запросы Медленнее Быстрее
GIN-индексы Нет Да
Порядок ключей Сохраняется Не гарантирован
Дублирующиеся ключи Сохраняются Последний побеждает
-- JSONB поддерживает индекс, JSON - нет
CREATE INDEX idx_data_gin ON events USING GIN (data); -- работает только с JSONB

Когда JSONB реально помогает

Главное условие - данные самостоятельны и не требуют связей с другими таблицами. Если сущность не участвует в JOIN и не нужна для фильтрации по индексу, JSONB сэкономит время на разработке.

Произвольные атрибуты и нестабильная схема

Классический пример - интернет-магазин с разными категориями товаров. Ноутбук имеет поля ram, processor, battery_life. Кроссовки - size, color, sole_material. Создавать отдельные таблицы под каждую категорию дорого. EAV-паттерн (отдельная таблица «ключ-значение») решает проблему, но делает запросы громоздкими:

-- EAV: некрасиво и медленно
SELECT p.name,
       MAX(CASE WHEN a.key = 'ram' THEN a.value END) AS ram,
       MAX(CASE WHEN a.key = 'color' THEN a.value END) AS color
FROM products p
JOIN product_attributes a ON p.id = a.product_id
GROUP BY p.name;

-- JSONB: один запрос
SELECT name,
       attributes ->> 'ram' AS ram,
       attributes ->> 'color' AS color
FROM products;

JSONB заменяет EAV и возвращает читаемость кода.

Настройки пользователя и конфигурации

Настройки пользователя редко читаются по одному ключу и почти никогда не участвуют в WHERE или JOIN. Отдельная таблица user_settings с 20 полями избыточна. JSONB в строке пользователя делает то же самое, но без дополнительной таблицы и JOIN.

-- Один запрос вместо JOIN
SELECT id,
       settings ->> 'theme' AS theme,
       settings ->> 'language' AS lang
FROM users
WHERE id = 42;

Снимки (snapshot) и аудит-лог

При аудите нужно сохранить объект в момент изменения - не ссылку, а копию. Если потом изменится основная запись, аудит-лог должен показывать старое состояние. JSONB идеален: фиксирует структуру целиком без зависимости от внешних ключей.

CREATE TABLE audit_log (
  id BIGSERIAL PRIMARY KEY,
  entity_type TEXT NOT NULL,
  entity_id BIGINT NOT NULL,
  changed_at TIMESTAMPTZ DEFAULT now(),
  snapshot JSONB NOT NULL -- полная копия объекта
);

Данные внешних API без фиксированной схемы

Ответы сторонних API меняются без предупреждения. Добавился новый ключ - и приложение ломается, если схема таблицы жёсткая. JSONB принимает любую структуру и не требует ALTER TABLE при каждом изменении апстрима.

Когда JSONB вредит и нужны отдельные таблицы

Признак опасности - поле из JSONB начинает регулярно появляться в WHERE, ORDER BY, JOIN или агрегациях. В этот момент JSONB превращается из помощника в тормоз.

Частые запросы по полю внутри JSONB

Без индекса PostgreSQL делает seq scan всей таблицы даже для запроса, который возвращает одну строку.

-- Плохо: без индекса - seq scan
SELECT *
FROM products
WHERE data ->> 'price' > '1000';

-- Лучше: вынести price в отдельную колонку
ALTER TABLE products ADD COLUMN price NUMERIC;
UPDATE products SET price = (data ->> 'price')::NUMERIC;
CREATE INDEX idx_products_price ON products (price);

Дополнительная сложность - типы. PostgreSQL не знает, что лежит в data ->> 'price'. Это текст, и его нужно приводить к числу при каждом сравнении. Отдельная колонка снимает проблему.

FOREIGN KEY, NOT NULL и другие ограничения

JSONB - чёрный ящик для планировщика. PostgreSQL не может наложить FOREIGN KEY, NOT NULL, UNIQUE, CHECK на поля внутри JSONB. Вся валидация переходит на уровень приложения, и появляются грязные данные, когда кто-то забывает проверку.

-- Это работает - и это проблема:
INSERT INTO orders (data)
VALUES ('{"user_id": null, "amount": -500}');

-- PostgreSQL не возразит

-- В реляционной схеме:
CREATE TABLE orders (
  user_id BIGINT NOT NULL REFERENCES users(id),
  amount NUMERIC CHECK (amount > 0)
);

-- Некорректные данные не пройдут

Bloat при частых обновлениях

MVCC в PostgreSQL при каждом UPDATE пишет новую версию всей строки, а не только изменённого поля. Если JSONB-поле большое (2+ KB), оно уходит в механизм TOAST, и каждое обновление пишет новый TOAST-объект. На горячих таблицах это ускоряет раздутие и требует частого VACUUM. Отдельная узкая колонка обновляется дешевле.

Авторская заметка: на одном из проектов таблица с JSONB-полем metadata размером около 5 KB росла на 40% быстрее аналогичной нормализованной схемы после двухнедельной работы под нагрузкой. После переноса «горячих» полей в колонки частота autovacuum упала вдвое.

Как правильно индексировать JSONB

Без индекса JSONB бесполезен при любых запросах с фильтрацией. Выбор типа индекса зависит от оператора в WHERE.

GIN по всему полю vs expression-индекс

Полный GIN покрывает операторы @>, ?, ?|, ?&. Он подходит, если запросы разнообразны и заранее неизвестно, по каким ключам будут фильтровать.

-- Полный GIN: покрывает все операторы поиска по ключам
CREATE INDEX idx_products_data ON products USING GIN (data);

-- Запросы используют индекс:
SELECT * FROM products WHERE data @> '{"brand": "Dell"}';
SELECT * FROM products WHERE data ? 'warranty';

Expression-индекс эффективнее, если запросы идут только по одному конкретному ключу.

-- Индекс только по brand: компактнее, быстрее для точечного поиска
CREATE INDEX idx_products_brand ON products ((data ->> 'brand'));

-- Запрос:
SELECT *
FROM products
WHERE data ->> 'brand' = 'Dell';

jsonb_path_ops: когда использовать

jsonb_path_ops создаёт компактный GIN, который поддерживает только оператор @>. Если containment-запросы - единственный тип поиска, этот вариант уменьшает размер индекса.

CREATE INDEX idx_products_path ON products USING GIN (data jsonb_path_ops);
-- Поддерживает только @>, но занимает меньше места

Диагностика медленных запросов: EXPLAIN ANALYZE

EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM products
WHERE data @> '{"brand": "Dell"}';

Если в выводе Seq Scan вместо Bitmap Index Scan on idx_products_data, индекс не используется. Причина может быть в неподходящем opclass или в том, что планировщик оценивает seq scan дешевле, потому что таблица маленькая. Для старых версий PostgreSQL известна проблема плохой оценки кардинальности при JSONB-индексах.

Гибридная схема: реляционное + JSONB

Практика на production-проектах показывает: лучший результат даёт не «всё в JSONB» и не «всё нормализовать», а гибридный подход. Фиксированные поля - в колонки, нестабильные расширения - в JSONB.

Паттерн: фиксированные колонки + JSONB для расширений

CREATE TABLE products (
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  price NUMERIC NOT NULL, -- горячее поле: фильтрация, сортировка
  category_id INT REFERENCES categories(id), -- JOIN
  created_at TIMESTAMPTZ DEFAULT now(), -- сортировка по времени
  attributes JSONB -- нестабильные атрибуты: ram, color, size...
);

-- Индексы: реляционные поля - B-tree, расширения - GIN
CREATE INDEX idx_products_price ON products (price);
CREATE INDEX idx_products_attrs ON products USING GIN (attributes);

Это даёт скорость реляционных JOIN и гибкость документной базы в одной таблице.

Когда выносить поле из JSONB в отдельную колонку

Сигналы к миграции:

  • Поле из JSONB появляется в WHERE/ORDER BY/GROUP BY чаще двух раз в неделю.
  • По нему нужен JOIN с другой таблицей.
  • Нужно ограничение NOT NULL, UNIQUE или FOREIGN KEY.
  • Expression-индекс стал большим и замедлил INSERT.

Миграция простая:

-- Добавить колонку
ALTER TABLE products ADD COLUMN brand TEXT;

-- Заполнить из JSONB
UPDATE products SET brand = attributes ->> 'brand';

-- Добавить индекс
CREATE INDEX idx_products_brand ON products (brand);

-- Убрать поле из JSONB (опционально)
UPDATE products SET attributes = attributes - 'brand';

JSONB и ORM: что поддерживают SQLAlchemy, Prisma, GORM

Современные ORM работают с JSONB нативно. SQLAlchemy (Python) имеет тип JSONB в sqlalchemy.dialects.postgresql. Prisma (TypeScript/JS) сопоставляет PostgreSQL Json с нативным объектом. GORM (Go) использует datatypes.JSON из пакета gorm.io/datatypes. Все три поддерживают частичное обновление через jsonb_set при использовании raw-запросов.

Чек-лист: JSONB или отдельная таблица?

Пройдитесь по каждому пункту перед принятием решения.

Используйте JSONB, если:

  • Структура данных отличается от строки к строке (разные атрибуты)
  • Поле редко участвует в WHERE, ORDER BY, JOIN.
  • Данные приходят из внешнего API с нестабильной схемой.
  • Нужен snapshot объекта для аудит-лога.
  • Хотите избежать EAV-антипаттерна.
  • Нужны настройки или конфигурации на уровне строки.

Используйте отдельные таблицы, если:

  • Поле появляется у большинства строк.
  • Нужны FOREIGN KEY, NOT NULL, CHECK, UNIQUE.
  • Поле регулярно участвует в WHERE, ORDER BY, GROUP BY, JOIN.
  • Нужна агрегация по значению (SUM, AVG, COUNT).
  • Таблица обновляется часто и поле большое (риск TOAST-bloat).
  • Важна строгая типизация на уровне БД.

Авторская ремарка: правило «не уверен - начни с JSONB, потом мигрируй» работает, если есть тесты и скрипты миграции. Без них такое откладывание превращается в технический долг.

Практический пример: каталог товаров

Задача: интернет-магазин, 50 категорий, у каждой свой набор атрибутов.

CREATE TABLE
    products (
        id BIGSERIAL PRIMARY KEY,
        name TEXT NOT NULL,
        price NUMERIC(12, 2) NOT NULL,
        category_id INT NOT NULL REFERENCES categories (id),
        is_active BOOLEAN DEFAULT true,
        created_at TIMESTAMPTZ DEFAULT now (),
        attributes JSONB -- RAM для ноутбуков, размер для одежды, etc.
    );

-- Поиск по атрибуту с GIN
CREATE INDEX idx_products_attrs ON products USING GIN (attributes);

-- Поиск ноутбуков с RAM >= 16 GB (хранится как число в JSONB)
SELECT
    name,
    price
FROM
    products
WHERE
    category_id = 5
    AND (attributes ->> 'ram')::INT >= 16
    AND is_active = true
ORDER BY
    price;

Результат: одна таблица вместо 50 схем. При росте категории или добавлении нового атрибута не нужны ALTER TABLE.

FAQ

Что лучше: JSONB или отдельные таблицы в PostgreSQL?

Зависит от задачи. JSONB выигрывает при нестабильной схеме, произвольных атрибутах и снимках данных. Отдельные таблицы лучше, когда поле участвует в WHERE, JOIN, агрегациях или нужны ограничения NOT NULL, FOREIGN KEY.

Как индексировать JSONB в PostgreSQL?

Для операторов @>, ?, ?|, ?& - GIN по всему полю. Для конкретного ключа - expression-индекс ((data ->> 'key')). Для только containment-запросов - GIN с jsonb_path_ops.

Почему JSONB медленнее при частых обновлениях?

PostgreSQL через MVCC при UPDATE записывает новую версию всей строки. Большое JSONB-поле уходит в TOAST, и каждое обновление создаёт новый TOAST-объект. Это ускоряет раздутие таблицы.

Можно ли добавить FOREIGN KEY внутрь JSONB?

Нет. PostgreSQL не поддерживает FOREIGN KEY, NOT NULL, UNIQUE или CHECK на поля внутри JSONB. Валидация переходит на приложение.

Чем JSONB отличается от JSON в PostgreSQL?

JSONB хранит данные в бинарном формате и поддерживает GIN-индексы. JSON хранит текст как есть, сохраняет порядок ключей, но медленнее при запросах.

Что такое гибридная схема?

Таблица, где фиксированные поля (id, price) хранятся в обычных колонках с B-tree индексами, а нестабильные атрибуты - в JSONB с GIN-индексом. Это даёт скорость реляционных операций и гибкость документной БД.

Как перенести поле из JSONB в отдельную колонку?

ALTER TABLE ADD COLUMN, затем UPDATE SET col = (data ->> 'key'), затем CREATE INDEX. После проверки можно убрать ключ из JSONB: UPDATE SET data = data - 'key'.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также