0 / 63 (0%)

Когда не нужен jsonb

В 07-01 jsonb выглядел почти бесплатным: положил документ — достал поле. Через пару спринтов в Brew карточка напитка целиком переехала в одну колонку doc jsonb — «так гибче, схему менять не надо». А потом начались странности: цена напитка вдруг стала отрицательной и никто не понял когда; апдейт одного флага в карточке внезапно стал тяжёлым по записи; аналитик не смог наложить простой CHECK на цену. Гибкость пришла со счётом, и счёт этот — из двух частей.

Цель юнита — увидеть обе цены jsonb на цифрах: физическую (write-amplification — изменить одно поле = переписать весь документ) и семантическую (внутри jsonb нет ни типа, ни CHECK, ни NOT NULL на отдельное поле). Это юнит-«стоп»: не «как делать на jsonb», а «где не надо».

Физика: одно поле — а переписан весь документ

В Postgres строки неизменяемы: любой UPDATE пишет новую версию строки целиком (это MVCC, см. 05-02). Но у jsonb есть свой множитель сверху: документ хранится как единое значение, и поменять в нём один ключ «на месте» нельзя — jsonb_set собирает и записывает новый полный документ. Если цена живёт в обычной колонке bigint, изменение касается 8 байт. Если та же цена — ключ внутри карточки на полкилобайта, то ради одного числа на диск (и в WAL, и в репликацию) уезжает весь полкилобайтный документ. На горячей таблице с частыми точечными апдейтами это и есть write-amplification: пишешь в разы больше, чем меняешь. А если документ перевалит за ~2 КБ, он уедет в TOAST, и апдейт потащит ещё и де/компрессию.

Семантика: внутри jsonb нет ограничений

Колонка — это контракт: тип отобьёт 'banana' в числовом поле, CHECK (price_cents > 0) — отрицательную цену, NOT NULL — пропуск, внешний ключ — ссылку в никуда. Внутри jsonb всех этих гарантий нет. doc || '{"price": -5}' запишет отрицательную цену молча; '{"price": "banana"}' — строку вместо числа, и БД не возразит. Нельзя одной строкой потребовать «ключ price обязателен и положителен»: придётся либо тащить CHECK (doc ? 'price' AND (doc->>'price')::numeric > 0) (хрупко, не отловит вложенное, легко обойти), либо валидировать в приложении — а это ровно та работа, которую за тебя делала схема. Поле, по которому есть инвариант, хочет быть колонкой.

Одно поле — весь документ

Почему точечный апдейт внутри jsonb дорогой, видно на картинке. Меняем цену 450 → 999:

plaintext
  обычная колонка bigint
    [ 450 ] ──UPDATE──▶ [ 999 ]        8 байт, число меняется на месте
 
  то же поле внутри doc (531 байт):
    { sku, name, price:450, nutrition, sizes, milk_options, allergens, i18n }
                       │  jsonb_set(doc, '{price}', '999')  — собрать заново

    { sku, name, price:999, nutrition, sizes, milk_options, allergens, i18n }
                                       ↑ НОВЫЙ документ целиком, снова 531 байт

Колонка трогает 8 байт; jsonb ради одного числа переписывает все 531 — и это уходит в кучу, в WAL и в репликацию. На горячей таблице с частыми правками это и есть write-amplification.

Что показывает наш код

Лабораторный стол menu_doc_lab: одна карточка напитка, где цена есть И отдельной колонкой price_cents (тип + CHECK), И ключом price внутри doc. Демонстрация на psql (escape-hatch: sqlc тут не нужен — смотрим байты и SQLSTATE):

sql
-- 1) сколько байт стоит «поменять одно поле»
SELECT pg_column_size(price_cents), pg_column_size(doc) FROM menu_doc_lab;
SELECT pg_column_size(jsonb_set(doc, '{price}', '999')) FROM menu_doc_lab;  -- новый ПОЛНЫЙ документ
 
-- 2) колонка отбивает мусор (печатаем SQLSTATE), 3) jsonb — проглатывает
INSERT INTO menu_doc_lab (price_cents, doc) VALUES (-5, '{}');        -- 23514 (CHECK)
INSERT INTO menu_doc_lab (price_cents, doc) VALUES ('banana', '{}');  -- 22P02 (тип)
UPDATE menu_doc_lab SET doc = doc || '{"price": "banana"}';           -- проходит молча

Запуск

sh
docker compose up -d
make lecture L=07-jsonb-arrays-and-search/07-02-when-not-to-use-jsonb T=db-reset
make lecture L=07-jsonb-arrays-and-search/07-02-when-not-to-use-jsonb

Вывод:

plaintext
== 1) write-amplification: байты на одно поле — колонка против jsonb ==
 price_column_bytes | doc_bytes 
--------------------+-----------
                  8 |       531
 
 doc_after_one_field_change_bytes 
----------------------------------
                              531
 
 
== 2) потеря ограничений: колонка отбивает мусор, jsonb — нет ==
колонка price_cents = -5      → SQLSTATE 23514 (CHECK price_cents > 0)
колонка price_cents = banana  → SQLSTATE 22P02 (invalid input for bigint)
 
== 3) тот же мусор ВНУТРИ jsonb проходит молча (ни типа, ни CHECK) ==
 doc_price_now | column_price_still 
---------------+--------------------
 banana        |                450

Колонка стоит 8 байт, документ — 531, и jsonb_set ради одного поля отдал снова 531-байтовый документ: вот цена точечного апдейта внутри jsonb. А отбить мусор смогла только колонка: -5 поймал CHECK (23514), banana — тип (22P02), тогда как те же значения внутри doc записались молча, и в карточке теперь висит цена banana при честной колоночной цене 450.

Заборчик

jsonb хорош ровно для одной задачи: по-настоящему бесформенные, разреженные данные, по которым ты не наводишь инварианты и редко делаешь точечные апдейты — входящие webhook'и, снапшоты внешних API, пользовательские настройки «как есть». Сигналы, что поле пора вынести в колонку:

  • по полю появился инвариант — обязательность, диапазон, ссылка (всё это умеет колонка, а jsonb нет);
  • по нему идёт частый точечный UPDATE (та самая write-amplification) или регулярный фильтр/джойн;
  • «жирный jsonb, который всё время чуть-чуть правим» — в проде это раздутие таблицы и WAL, давление на автовакуум и потеря контроля над данными; твой DBA попросит нормализовать ровно эти поля.

Гибридная схема (стабильные поля — колонки, истинно бесформенный хвост — один jsonb) почти всегда бьёт крайности.

Что забрать с собой

  • Write-amplification: UPDATE одного ключа переписывает весь документ (jsonb_set отдаёт новый полный jsonb); колонка стоит байты, документ — сотни байт и TOAST.
  • Внутри jsonb нет типов, CHECK, NOT NULL и внешних ключей на отдельные поля — мусор записывается молча.
  • Поле с инвариантом / частым точечным апдейтом / регулярным фильтром хочет быть колонкой, а не ключом в jsonb.
  • jsonb уместен для бесформенного и разреженного; гибридная схема (колонки + один jsonb-хвост) бьёт обе крайности.

Дальше — 07-03 «SQL/JSON path и сборка»: раз уж бесформенное всё-таки легло в jsonb, научимся доставать из него по выражениям пути (jsonb_path_query), точечно править (jsonb_set) и собирать документы (jsonb_build_object/jsonb_agg) — и отметим, что JSON_TABLE появился в PG17.

·Модуль 08

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
JSONB, массивы и поиск / Когда не нужен jsonb