Опирается на правила: PG-T-060PG-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:

jsonjsonb
Хранениетекст целикомдвоичное декомпозированное
Скорость чтения / поискамедленно (парсинг каждый раз)быстро (готовые ключи)
Сохраняет порядок ключейданет
Дубликаты ключейсохраняетне сохраняет
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 вместо jsonbPG-T-060jsonb
Все поля сущности в одном data jsonbPG-T-061колонки + metadata jsonb
Email/status в JSONBPG-T-061колонка с индексом
JSONB чтобы не писать миграцииPG-T-063явная схема, migration
WHERE jsonb_field->>'key' = ... без functional indexPG-T-066functional index
WHERE jsonb @> '...' без GINPG-T-065GIN jsonb_path_ops
Бинарь в JSONBPG-T-067bytea
Большие тексты в JSONBPG-T-067text
Default GIN (column) когда нужен только @>PG-T-065jsonb_path_ops
Map<String, Object> на Java без структурыPG-T-062typed record / DTO

Куда дальше

  • PG → JSONB — нормативные формулировки.
  • Строковые типы — TOAST для длинных значений.
  • Массивы и range-типы — array vs jsonb для списков.
  • Типы индексов в PostgreSQL — GIN детали.
  • Полнотекстовый поиск (FTS) — tsvector vs JSONB для search.
  • Kafka → event design — event payload как JSONB-storage.
  • Антипаттерны типов — сводка.