JSONB — мощный тип, который часто используют не по назначению: «сделаем jsonb, потом разберёмся». Через год это превращается в «реляционная БД с нетипизированной кашей внутри». Эта статья — про границу: где JSONB решает реальную задачу, где — анти-паттерн «всё равно мы пишем монгу».

1. jsonb vs json

PG-T-060 Всегда jsonb. json — почти никогда.

jsonjsonb
Хранениекак текст, целикомразобранное двоичное представление
Скорость чтения / поискамедленно (парсинг при каждом обращении)быстро (готовые ключи)
Сохраняет порядок ключей и пробелыданет
Дубликаты ключейсохраняет (последний выигрывает при чтении)не сохраняет
Индексы (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 Хорошие случаи:

  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    -- содержимое события — полиморфное по event_type
);
  1. Полиморфные атрибуты товара — у обуви размер и материал, у электроники — мощность и тип разъёма. Ставить 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}
);
  1. Конфиги и интеграционные настройки — каждый интеграционный канал имеет свой набор полей.
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}
);
  1. Кеш-копии данных из внешних 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 — где провести границу между доменом и его «опциональными» атрибутами.