Опирается на правила:
PG-T-060…PG-T-067из PostgreSQL Style Guide → раздел JSONB — когда оправдан, когда нет.
Важно знать
- Всегда
jsonb, неjson(текстовое хранение, медленный парсинг, нет GIN).- Если по полю фильтруют, сортируют, джойнят — это колонка, не JSON-ключ.
- Хорошие случаи: журнал аудита, полиморфные атрибуты товара, интеграционные конфиги, кеш-копии external API.
- Плохо: все основные поля в одном
data jsonb; JSONB чтобы избежать миграций.- Базовые операторы:
->(jsonb),->>(text),#>(path jsonb),#>>(path text),@>(содержит).- GIN с
jsonb_path_opsдля@>; functional index дешевле для конкретного ключа.- Не писать в jsonb бинарь, большие тексты, base64.
- JSONB не должен расти > нескольких KB на запись.
JSONB — частая ловушка: соблазн «сделаем jsonb, потом разберёмся». Через год — «реляционная БД с нетипизированной кашей внутри». UCP формулирует чёткую границу: где JSONB решает реальную задачу, где — антипаттерн.
jsonb, не json
PG-T-060:
json | jsonb | |
|---|---|---|
| Хранение | текст целиком | двоичное декомпозированное |
| Скорость чтения / поиска | медленно (парсинг каждый раз) | быстро (готовые ключи) |
| Сохраняет порядок ключей | да | нет |
| Дубликаты ключей | сохраняет | не сохраняет |
| GIN индексы | нет | да |
json оправдан только когда важно бит-в-бит представление документа (audit-журнал, требующий точный raw input). В 99% случаев — jsonb.
Колонка vs JSON-ключ
PG-T-061: главное правило.
-- ✗ — псевдо-document store
CREATE TABLE customer (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
data jsonb NOT NULL
);
SELECT * FROM customer WHERE data->>'email' = 'ivan@example.com'; -- seq scan
SELECT * FROM customer WHERE data->>'status' = 'ACTIVE'; -- seq scan
SELECT * FROM customer ORDER BY data->>'created_at' DESC; -- seq scan + sort
-- ✓ — колонки с типами и индексами, jsonb для полиморфного
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
);
Правило: если по полю регулярно фильтруют, сортируют или джойнят — это колонка, не JSON-ключ.
JSONB — для редко-используемых, опциональных, специфичных атрибутов.
Хорошие случаи
PG-T-062:
1. Журнал аудита / содержимое события
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
);
payload полиморфен по event_type — содержимое события. Колонками — невозможно (200+ event types × 5-15 полей = тысячи колонок).
2. Полиморфные атрибуты товара
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
);
-- shoes: {"size": 42, "material": "leather"}
-- electronics: {"power_w": 1500, "voltage": 220}
200 nullable-колонок неудобно, 50 таблиц по категориям тоже.
3. Интеграционные конфиги
CREATE TABLE integration_channel (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind varchar(20) NOT NULL, -- 'SMTP', 'TELEGRAM', 'WEBHOOK'
config jsonb NOT NULL
);
-- SMTP: {"host", "port", "username"}
-- Telegram: {"bot_token", "chat_id"}
-- Webhook: {"url", "secret"}
4. Кеш-копии external API
Структура диктуется внешним сервисом, мы не контролируем — JSONB фиксирует «что отдал API», без миграций при изменении.
Плохие случаи
PG-T-063:
- Все основные поля сущности в одном
data jsonb— это не «гибкая схема», это потеря всех плюсов реляционной БД. - Поле, по которому регулярно ищут/сортируют, лежит в JSONB — каждый запрос seq scan.
- JSONB чтобы не писать миграции — самый коварный антипаттерн. Через полгода никто не помнит, какие ключи могут быть, какие обязательны, какие deprecated. Schema-validation на стороне приложения, расходится между сервисами.
Базовые операторы
PG-T-064:
| Оператор | Возвращает | Назначение |
|---|---|---|
-> | jsonb | Доступ по ключу/индексу |
->> | text | Доступ по ключу/индексу |
#> | jsonb | Доступ по пути |
#>> | text | Доступ по пути |
SELECT
payload -> 'customer' AS customer_obj,
payload ->> 'event_type' AS type,
payload #> '{order, items, 0}' AS first_item,
payload #>> '{order, total}' AS total_str
FROM event_log;
Фильтрация:
-- содержит 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'];
Индексы по JSONB
GIN — для @> (subset)
PG-T-065:
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).
Functional index — для конкретного ключа
PG-T-066:
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 — когда фильтрация по разным ключам или содержимому вложенных объектов.
Что не класть в JSONB
PG-T-067:
- Бинарь →
bytea. - Большие тексты (статьи) →
text(TOAST сам разделит). - Base64 — выходит из «декомпозированного двоичного» в обычный текст с overhead 33%.
JSONB не должен расти больше нескольких KB на запись. Для media — отдельная storage (S3-like).
Java-сторона
Через jOOQ + 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); }
}
}
Или жёсткий тип под конкретное содержимое:
public record EventPayload(UUID customerId, String eventType, Instant occurredAt, Map<String, Object> details) {}
Жёсткий тип возвращает контроль: видна структура, видно что обязательно. Минус — нужно синхронизировать с реальным содержимым в БД.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
json вместо jsonb | PG-T-060 | jsonb |
Все поля сущности в одном data jsonb | PG-T-061 | колонки + metadata jsonb |
| Email/status в JSONB | PG-T-061 | колонка с индексом |
| JSONB чтобы не писать миграции | PG-T-063 | явная схема, migration |
WHERE jsonb_field->>'key' = ... без functional index | PG-T-066 | functional index |
WHERE jsonb @> '...' без GIN | PG-T-065 | GIN jsonb_path_ops |
| Бинарь в JSONB | PG-T-067 | bytea |
| Большие тексты в JSONB | PG-T-067 | text |
Default GIN (column) когда нужен только @> | PG-T-065 | jsonb_path_ops |
Map<String, Object> на Java без структуры | PG-T-062 | typed record / DTO |
Куда дальше
- PG → JSONB — нормативные формулировки.
- Строковые типы — TOAST для длинных значений.
- Массивы и range-типы — array vs jsonb для списков.
- Типы индексов в PostgreSQL — GIN детали.
- Полнотекстовый поиск (FTS) — tsvector vs JSONB для search.
- Kafka → event design — event payload как JSONB-storage.
- Антипаттерны типов — сводка.