Когда не нужен 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:
обычная колонка 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):
-- 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"}'; -- проходит молчаЗапуск
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Вывод:
== 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.