JSONB — мощный тип, который часто используют не по назначению: «сделаем jsonb, потом разберёмся». Через год это превращается в «реляционная БД с нетипизированной кашей внутри». Эта статья — про границу: где JSONB решает реальную задачу, где — анти-паттерн «всё равно мы пишем монгу».
1. jsonb vs json
PG-T-060 Всегда jsonb. json — почти никогда.
json | jsonb | |
|---|---|---|
| Хранение | как текст, целиком | разобранное двоичное представление |
| Скорость чтения / поиска | медленно (парсинг при каждом обращении) | быстро (готовые ключи) |
| Сохраняет порядок ключей и пробелы | да | нет |
| Дубликаты ключей | сохраняет (последний выигрывает при чтении) | не сохраняет |
| Индексы (GIN) | нет | да |
json оправдан только когда важно сохранить точное побайтовое представление документа — например, в журнале аудита, который должен быть бит-в-бит таким же, как пришёл. В 99% случаев нужен jsonb.
2. Главное правило: что класть в JSONB, а что в колонку
PG-T-061 Если по полю регулярно фильтруют, сортируют или джойнят — это колонка, не JSON-ключ.
Контр-пример (плохо):
CREATE TABLE customer (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
data jsonb NOT NULL
);
-- запросы:
SELECT * FROM customer WHERE data->>'email' = 'ivan@example.com';
SELECT * FROM customer WHERE data->>'status' = 'ACTIVE';
SELECT * FROM customer ORDER BY data->>'created_at' DESC;
Это не «гибкая схема», это «реляционная БД, которая делает вид, что она document store». Без специальных индексов — каждый запрос seq-scan по всем строкам.
Лучше:
CREATE TABLE customer (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email citext NOT NULL UNIQUE,
status varchar(20) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb -- то, что действительно полиморфно
);
metadata — для редко-используемых, опциональных, специфичных под клиента/тип атрибутов. Email/status/created_at — колонки с индексами и типами.
3. Когда JSONB действительно оправдан
PG-T-062 Хорошие случаи:
- Журнал аудита / содержимое события — пишем все поля события, не зная заранее, что в нём будет.
CREATE TABLE event_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
aggregate_id uuid NOT NULL,
event_type varchar(50) NOT NULL,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL -- содержимое события — полиморфное по event_type
);
- Полиморфные атрибуты товара — у обуви размер и материал, у электроники — мощность и тип разъёма. Ставить 200 nullable-колонок в
productнеудобно, делить на 50 таблиц — тоже.
CREATE TABLE product (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku varchar(50) NOT NULL UNIQUE,
name text NOT NULL,
category_code varchar(20) NOT NULL,
price numeric(15, 2) NOT NULL,
attributes jsonb NOT NULL DEFAULT '{}'::jsonb -- {size: 42, material: 'leather'} / {power_w: 1500}
);
- Конфиги и интеграционные настройки — каждый интеграционный канал имеет свой набор полей.
CREATE TABLE integration_channel (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind varchar(20) NOT NULL, -- 'SMTP', 'TELEGRAM', 'WEBHOOK'
config jsonb NOT NULL -- {host, port, username} / {bot_token, chat_id} / {url, secret}
);
- Кеш-копии данных из внешних API — структура диктуется внешним сервисом, мы не можем её фиксировать колонками.
PG-T-063 Плохие случаи:
- Все основные поля сущности в одном
data jsonb(«гибкая схема»). - Поле, по которому регулярно ищут/сортируют, лежит в JSONB.
- JSONB используют, чтобы не писать миграции — добавили ключ в коде, БД не знает.
Последнее — самый коварный антипаттерн. Через полгода никто не помнит, какие ключи могут быть, какие обязательны, какие deprecated. Поверка схемы — на стороне приложения, и она расходится между сервисами.
4. Базовые операторы
PG-T-064 Запомнить четыре основных:
| Оператор | Что делает | Возвращает |
|---|---|---|
-> | Доступ по ключу/индексу | jsonb |
->> | Доступ по ключу/индексу | text |
#> | Доступ по пути | jsonb |
#>> | Доступ по пути | text |
SELECT
payload -> 'customer' AS customer_obj, -- jsonb
payload ->> 'event_type' AS type, -- text
payload #> '{order, items, 0}' AS first_item, -- jsonb
payload #>> '{order, total}' AS total_str -- text
FROM event_log;
И операторы фильтрации:
-- содержит JSON (subset)
SELECT * FROM event_log WHERE payload @> '{"event_type": "ORDER_PAID"}'::jsonb;
-- содержит ключ
SELECT * FROM product WHERE attributes ? 'material';
-- содержит любой из ключей
SELECT * FROM product WHERE attributes ?| array['material', 'size'];
-- содержит все ключи
SELECT * FROM product WHERE attributes ?& array['material', 'size'];
5. Индексы по JSONB
PG-T-065 GIN-индекс с оператором jsonb_path_ops для поиска @>:
CREATE INDEX event_log_payload_gin
ON event_log USING gin (payload jsonb_path_ops);
-- ускорит:
SELECT * FROM event_log WHERE payload @> '{"event_type": "ORDER_PAID"}';
jsonb_path_ops меньше и быстрее, чем дефолтный GIN, но поддерживает только @> (содержит). Для ? / ?| / ?& нужен дефолтный gin (column).
PG-T-066 Если запрос всегда по конкретному ключу — functional index, а не GIN.
-- если регулярно ищем по 'event_type':
CREATE INDEX event_log_event_type
ON event_log ((payload ->> 'event_type'));
SELECT * FROM event_log WHERE payload ->> 'event_type' = 'ORDER_PAID';
Functional index дешевле, чем GIN, и быстрее на конкретном ключе. GIN брать, когда фильтрация идёт по разным ключам или по содержимому вложенных объектов.
6. Размер и хранение
JSONB значения хранятся как «декомпозированное двоичное представление». Длинные значения уходят в TOAST (см. Строки).
PG-T-067 Не пишите в jsonb бинарь / большие тексты / base64. Для бинарей — bytea, для большого текста — text. JSONB не должен расти больше нескольких килобайт на запись.
7. Java-сторона: jOOQ + JSONB
jOOQ умеет маппить jsonb на JSONB (свой обёрточный тип) или на любой тип через converter. Типичная связка — Jackson:
@Component
public class JsonbConverter implements Converter<JSONB, JsonNode> {
private final ObjectMapper mapper;
@Override
public JsonNode from(JSONB db) {
try { return db == null ? null : mapper.readTree(db.data()); }
catch (Exception e) { throw new IllegalStateException(e); }
}
@Override
public JSONB to(JsonNode node) {
try { return node == null ? null : JSONB.valueOf(mapper.writeValueAsString(node)); }
catch (Exception e) { throw new IllegalStateException(e); }
}
// ...
}
Или жёсткий тип под конкретное содержимое JSONB:
public record EventPayload(UUID customerId, String eventType, Instant occurredAt, Map<String, Object> details) {}
Жёсткий тип возвращает контроль: видна структура, видно, что обязательно. Минус — нужно поддерживать Java-DTO синхронно с тем, что реально пишут в БД.
Чек-лист на ревью
- [ ]
jsonb, неjson. - [ ] В
jsonbлежат только полиморфные / опциональные / редко-фильтруемые атрибуты. - [ ] Все поля, по которым фильтруют/сортируют/джойнят — отдельные колонки с типами.
- [ ] Если ищут по конкретному ключу JSONB — functional index, не GIN.
- [ ] Если ищут по
@>или ключам — GIN-индекс. - [ ] В JSONB не лежат бинари, большие тексты, base64.
- [ ] На Java-стороне — типизированный DTO, не сырой
Map<String, Object>в проде.
Связанные
- Антипаттерны.
- Уровень 4 UCP — где провести границу между доменом и его «опциональными» атрибутами.