惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

A
Arctic Wolf
M
MIT News - Artificial intelligence
博客园_首页
人人都是产品经理
人人都是产品经理
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
The Cloudflare Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
W
WeLiveSecurity
酷 壳 – CoolShell
酷 壳 – CoolShell
Apple Machine Learning Research
Apple Machine Learning Research
Last Week in AI
Last Week in AI
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
SecWiki News
SecWiki News
Help Net Security
Help Net Security
云风的 BLOG
云风的 BLOG
Blog — PlanetScale
Blog — PlanetScale
H
Heimdal Security Blog
Jina AI
Jina AI
Hacker News: Ask HN
Hacker News: Ask HN
阮一峰的网络日志
阮一峰的网络日志
WordPress大学
WordPress大学
博客园 - 【当耐特】
Engineering at Meta
Engineering at Meta
TaoSecurity Blog
TaoSecurity Blog
T
Troy Hunt's Blog
T
Threatpost
AWS News Blog
AWS News Blog
H
Help Net Security
L
LINUX DO - 最新话题
有赞技术团队
有赞技术团队
A
About on SuperTechFans
G
GRAHAM CLULEY
The GitHub Blog
The GitHub Blog
P
Proofpoint News Feed
Hugging Face - Blog
Hugging Face - Blog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Recorded Future
Recorded Future
L
Lohrmann on Cybersecurity
Webroot Blog
Webroot Blog
O
OpenAI News
Schneier on Security
Schneier on Security
月光博客
月光博客
P
Privacy International News Feed
博客园 - 聂微东
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Stack Overflow Blog
Stack Overflow Blog
aimingoo的专栏
aimingoo的专栏
L
LangChain Blog
罗磊的独立博客

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Скрытая цена JSONB в PostgreSQL: что происходит при обновлении больших документов
PPR (Програм · 2026-04-29 · via Все публикации подряд на Хабре

Время на прочтение11 мин

Охват и читатели330

Кейс

jsonb легко полюбить. Он даёт гибкую схему, позволяет не плодить десятки мелких колонок, хорошо подходит для полуструктурированных данных и обычно очень удобен на старте проекта. Пока документы маленькие и меняются редко, кажется, что всё отлично.

Проблемы начинаются позже. Данные растут, в документ добавляются новые поля, часть из них начинает часто обновляться, а потом внезапно выясняется, что вроде бы безобидный UPDATE одного ключа внутри jsonb стоит заметно дороже, чем ожидалось.

В этой статье мы не собираемся доказывать, что jsonb плохой тип данных. Наоборот: jsonb – один из самых полезных инструментов PostgreSQL. Хотелось бы разобраться в более узком вопросе:

что именно происходит, когда мы обновляем один ключ внутри большого JSONB-документа, и чем это отличается от обновления обычной колонки рядом с таким же большим документом?

При первом рассмотрении кажется, что на операцию не потребуется много ресурсов: меняется всего один ключ, например status. В голове разработчика это выглядит примерно так:

  • нашли нужный кусочек документа;

  • заменили "new" на "done";

  • записали обратно.

Но PostgreSQL работает не так. И если документ уже большой, у этой разницы появляется вполне измеримая цена.

Что именно мы хотели проверить

Мы взяли четыре сценария:

  1. Маленький документ, а часто меняющееся поле хранится в обычной колонке;

  2. Маленький документ, а то же поле хранится внутри jsonb;

  3. Большой документ, а часто меняющееся поле хранится в обычной колонке;

  4. Большой документ, а то же поле хранится внутри jsonb.

Смысл такого сравнения простой. Мы хотели отделить два фактора:

  • влияет ли сам размер документа;

  • влияет ли то, что изменяемый атрибут лежит внутри jsonb, а не рядом с ним.

Если в обоих “больших” сценариях рядом лежит один и тот же крупный JSON-документ, но в одном случае мы обновляем обычную колонку status, а в другом — делаем jsonb_set(payload, '{status}', ...), то разница между ними уже очень показательна.

Почему здесь вообще может быть цена

На SQL-уровне функция jsonb_set возвращает новое значение jsonb, а не “чинит пару байт на месте”. Поэтому логическая операция “поменять один ключ” для PostgreSQL превращается в запись нового значения столбца. А дальше вступает обычная физика UPDATE в PostgreSQL: создаётся новая версия строки. Если значение большое, к картине добавляется TOAST – механизм хранения крупных значений вне основной heap-страницы. Иными словами, удобство jsonb не отменяет того, что база всё равно работает с версиями строк и физическим хранением данных.

Из-за этого была гипотеза:

чем больше документ и чем чаще изменяемое поле спрятано внутри него, тем заметнее будет разница между “обновить обычную колонку” и “обновить один ключ внутри jsonb».

Отдельно было интересно посмотреть на HOT update. Если никакие индексируемые столбцы не меняются и на странице хватает места, PostgreSQL может сделать HOT update и избежать создания новых записей в индексах. Но если мы индексируем выражение вроде (payload->>'status'), то для такого обновления этот бонус уже исчезает.

Стенд

Все примеры тестировали локально на PostgreSQL 16.4.

Чтобы не спорить о “магии окружения”, специально сделали стенд максимально простым:

  • одна отдельная схема для эксперимента;

  • одинаковое количество строк;

  • одинаковая логика данных;

  • отличие только в размере документа и в том, где именно лежит часто обновляемое поле.

У этого эксперимента есть ограничения: локальный стенд, PostgreSQL 16.4, 5000 строк, обновление половины таблицы, fillfactor = 70, без конкурентной нагрузки. Поэтому наши цифры – не универсальный бенчмарк, а иллюстрация механики и относительной разницы между сценариями.

Вот полный DDL и генерация данных.

drop schema if exists jsonb_price cascade;
create schema jsonb_price;
set search_path = jsonb_price, public;

create or replace function make_noise(seed int, chunks int)
returns text
language sql
immutable
as $$
  select string_agg(md5(seed::text || ':' || g::text), '')
  from generate_series(1, chunks) as g
$$;

create table docs_rel_small (
    id bigint generated always as identity primary key,
    status text not null,
    payload jsonb not null
) with (fillfactor = 70);

create table docs_json_small (
    id bigint generated always as identity primary key,
    payload jsonb not null
) with (fillfactor = 70);

create table docs_rel_big (
    id bigint generated always as identity primary key,
    status text not null,
    payload jsonb not null
) with (fillfactor = 70);

create table docs_json_big (
    id bigint generated always as identity primary key,
    payload jsonb not null
) with (fillfactor = 70);

create table docs_json_big_idx (
    id bigint generated always as identity primary key,
    payload jsonb not null
) with (fillfactor = 70);

insert into docs_rel_small (status, payload)
select
    'new',
    jsonb_build_object(
        'customer_id', gs,
        'source', 'api',
        'flags', jsonb_build_array('web', 'promo'),
        'amount', gs % 1000
    )
from generate_series(1, 5000) as gs;

insert into docs_json_small (payload)
select
    jsonb_build_object(
        'status', 'new',
        'customer_id', gs,
        'source', 'api',
        'flags', jsonb_build_array('web', 'promo'),
        'amount', gs % 1000
    )
from generate_series(1, 5000) as gs;

insert into docs_rel_big (status, payload)
select
    'new',
    jsonb_build_object(
        'customer_id', gs,
        'source', 'api',
        'flags', jsonb_build_array('web', 'promo'),
        'amount', gs % 1000,
        'description', make_noise(gs, 220),
        'audit', jsonb_build_object(
            'created_by', 'system',
            'request_id', md5(gs::text),
            'trace', make_noise(gs + 100000, 120)
        )
    )
from generate_series(1, 5000) as gs;

insert into docs_json_big (payload)
select
    jsonb_build_object(
        'status', 'new',
        'customer_id', gs,
        'source', 'api',
        'flags', jsonb_build_array('web', 'promo'),
        'amount', gs % 1000,
        'description', make_noise(gs, 220),
        'audit', jsonb_build_object(
            'created_by', 'system',
            'request_id', md5(gs::text),
            'trace', make_noise(gs + 100000, 120)
        )
    )
from generate_series(1, 5000) as gs;

insert into docs_json_big_idx (payload)
select
    jsonb_build_object(
        'status', 'new',
        'customer_id', gs,
        'source', 'api',
        'flags', jsonb_build_array('web', 'promo'),
        'amount', gs % 1000,
        'description', make_noise(gs, 220),
        'audit', jsonb_build_object(
            'created_by', 'system',
            'request_id', md5(gs::text),
            'trace', make_noise(gs + 100000, 120)
        )
    )
from generate_series(1, 5000) as gs;

vacuum analyze docs_rel_small;
vacuum analyze docs_json_small;
vacuum analyze docs_rel_big;
vacuum analyze docs_json_big;
vacuum analyze docs_json_big_idx;

Мы специально задали fillfactor = 70, чтобы у PostgreSQL было больше шансов сделать HOT update там, где он вообще возможен.

Сначала – быстрый sanity check

Перед тем как запускать UPDATE, посмотрели на средний размер полезной нагрузки:

select 'docs_rel_small' as table_name, avg(pg_column_size(payload))::bigint as avg_payload_bytes from jsonb_price.docs_rel_small
union all
select 'docs_json_small', avg(pg_column_size(payload))::bigint from jsonb_price.docs_json_small
union all
select 'docs_rel_big', avg(pg_column_size(payload))::bigint from jsonb_price.docs_rel_big
union all
select 'docs_json_big', avg(pg_column_size(payload))::bigint from jsonb_price.docs_json_big
union all
select 'docs_json_big_idx', avg(pg_column_size(payload))::bigint from jsonb_price.docs_json_big_idx
order by 1;

У нас получилось примерно так:

То есть стенд действительно разделил сценарии на “маленький JSONB” и “большой JSONB”, а не просто дал две почти одинаковые таблицы с разными именами.

После этого сняли размеры таблиц и связанных с ними TOAST-отношений:

select
    c.relname as table_name,
    pg_size_pretty(pg_relation_size(c.oid)) as heap,
    pg_size_pretty(pg_table_size(c.oid)) as table_with_toast,
    pg_size_pretty(pg_indexes_size(c.oid)) as indexes,
    pg_size_pretty(pg_total_relation_size(c.oid)) as total,
    pg_size_pretty(pg_total_relation_size(c.reltoastrelid)) as toast_total
from pg_class c
where c.oid in (
    'jsonb_price.docs_rel_small'::regclass,
    'jsonb_price.docs_json_small'::regclass,
    'jsonb_price.docs_rel_big'::regclass,
    'jsonb_price.docs_json_big'::regclass,
    'jsonb_price.docs_json_big_idx'::regclass
)
order by c.relname;

На этом шаге уже видно важную вещь: у больших сценариев заметная часть объёма живёт не в основном heap, а в TOAST.

Основной эксперимент

Теперь – самое интересное. Мы обновляли половину строк в каждой таблице.

1. Маленький документ, статус в отдельной колонке

explain (analyze, buffers)
update jsonb_price.docs_rel_small
set status = 'done'
where id <= 2500;

Результат: 9 мс.

После этого посмотрели статистику таблицы:

analyze jsonb_price.docs_rel_small;

select
    relname,
    n_tup_upd,
    n_tup_hot_upd,
    n_dead_tup
from pg_stat_user_tables
where schemaname = 'jsonb_price'
  and relname = 'docs_rel_small';

У нас получилось:

  • n_tup_upd = 2500

  • n_tup_hot_upd = 1122

  • n_dead_tup = 0

Это хороший базовый сценарий: маленький документ, меняется короткое поле, и никаких сюрпризов тут нет.

Сразу оговоримся: n_dead_tup в pg_stat_user_tables – это оценочный счётчик, а не точное физическое число мёртвых строк. Поэтому используем его только как вспомогательный сигнал и не делаем из него главный вывод статьи.

2. Маленький документ, статус внутри JSONB

explain (analyze, buffers)
update jsonb_price.docs_json_small
set payload = jsonb_set(payload, '{status}', to_jsonb('done'::text), false)
where id <= 2500;

Результат: 10 мс

Статистика после ANALYZE:

  • n_tup_upd = 2500

  • n_tup_hot_upd = 1110

  • n_dead_tup = 0

Вот тут уже начинается интересное. В маленьком документе разница между отдельной колонкой и jsonb_set оказалась небольшой: 9 мс против 10 мс, и на таком размере документа почти не влияла на общее время выполнения.

3. Большой документ, статус в отдельной колонке

explain (analyze, buffers)
update jsonb_price.docs_rel_big
set status = 'done'
where id <= 2500;

Результат: 8 мс

Статистика:

  • n_tup_upd = 2500

  • n_tup_hot_upd = 1096

  • n_dead_tup = 0

Именно этот сценарий был самым важным контрольным. Большой документ здесь тоже есть, но его не меняем. Меняем только короткую колонку status, которая лежит рядом.

Если смотреть только на логику приложения, то этот сценарий и следующий делают одно и то же – меняют статус. Но физически это разные операции: в docs_rel_big меняется короткая колонка рядом с большим документом, а в docs_json_big меняется сам payload.

4. Большой документ, статус внутри JSONB

explain (analyze, buffers)
update jsonb_price.docs_json_big
set payload = jsonb_set(payload, '{status}', to_jsonb('done'::text), false)
where id <= 2500;

Результат: 1090 мс

Статистика:

  • n_tup_upd = 2500

  • n_tup_hot_upd = 1096

  • n_dead_tup = 2500

При этом часть обновлений всё равно попала в HOT update: у таблицы не было индекса по payload, а fillfactor = 70 оставлял свободное место на странице. Это важный момент: даже наличие HOT не спасло сценарий, где пришлось переписывать большой payload.

После этого мы снова сняли размеры таблиц и TOAST:

select
    c.relname as table_name,
    pg_size_pretty(pg_relation_size(c.oid)) as heap,
    pg_size_pretty(pg_table_size(c.oid)) as table_with_toast,
    pg_size_pretty(pg_indexes_size(c.oid)) as indexes,
    pg_size_pretty(pg_total_relation_size(c.oid)) as total,
    pg_size_pretty(pg_total_relation_size(c.reltoastrelid)) as toast_total
from pg_class c
where c.oid in (
    'jsonb_price.docs_rel_small'::regclass,
    'jsonb_price.docs_json_small'::regclass,
    'jsonb_price.docs_rel_big'::regclass,
    'jsonb_price.docs_json_big'::regclass
)
order by c.relname;

И вот здесь эксперимент наконец стал действительно показательным.

На нашем стенде:

  • docs_rel_big обновлялась за 8 мс;

  • docs_json_big обновлялась за 1090 мс;

  • размер TOAST у docs_rel_big не изменился;

  • размер TOAST у docs_json_big вырос на 30 мб.

То есть сама по себе большая JSON-нагрузка рядом со строкой ещё не делает UPDATE автоматически дорогим. Самым дорогим оказался не факт наличия большого документа, а то, что часто изменяемый атрибут лежал внутри payload, который приходилось пересобирать и записывать заново.

Почему так вышло

Здесь и проявляется скрытая цена jsonb.

Когда большой jsonb лежит рядом с колонкой status, обновление статуса не требует менять сам payload. Это и объясняет, почему docs_rel_big осталась быстрой: большой документ физически присутствует в строке, но само обновление не заставляет PostgreSQL пересобирать и записывать его заново.

Когда же статус лежит внутри payload, операция уже выглядит иначе:

payload = jsonb_set(payload, '{status}', ...)

То есть мы создаём новое значение payload, а затем делаем обычный UPDATE строки. Если документ большой, в картину добавляется TOAST. Старые версии строки и старые данные не исчезают мгновенно: они остаются как минимум до VACUUM. В результате цена одной маленькой бизнес-операции распадается на несколько составляющих:

  • создаётся новая версия строки;

  • PostgreSQL приходится формировать новое значение payload;

  • при большом документе заметно растёт работа с TOAST;

  • накапливаются мёртвые версии строк;

  • увеличивается будущая нагрузка на VACUUM и повторное использование места.

Снаружи это всё ещё “обновить один статус”. Внутри – уже далеко не один флажок.

А что насчёт HOT update

Чтобы отдельно проверить влияние индекса на часто меняющийся ключ внутри JSONB, мы взяли ещё одну таблицу и добавил индекс на выражение:

create index docs_json_big_idx_status_idx
    on jsonb_price.docs_json_big_idx ((payload->>'status'));

vacuum analyze jsonb_price.docs_json_big_idx;

После этого повторили тот же UPDATE:

explain (analyze, buffers)
update jsonb_price.docs_json_big_idx
set payload = jsonb_set(payload, '{status}', to_jsonb('done'::text), false)
where id <= 2500;

Результат: 1070 мс.

А затем посмотрели статистику:

analyze jsonb_price.docs_json_big_idx;

select
    relname,
    n_tup_upd,
    n_tup_hot_upd,
    n_dead_tup
from pg_stat_user_tables
where schemaname = 'jsonb_price'
  and relname = 'docs_json_big_idx';

Статистика:

  • n_tup_upd = 2500

  • n_tup_hot_upd = 0

  • n_dead_tup = 0

Результат оказался ожидаемым, но от этого не менее полезным: после индекса на (payload->>'status') рассчитывать на HOT для такого UPDATE уже не приходится. На нашем стенде это выразилось в том, что:

  • общее время обновления почти не изменилось: 1090 мс против 1070 мс;

  • n_tup_hot_upd упал с 1096 до 0;

  • PostgreSQL полностью потерял возможность использовать HOT update для такого сценария.

Это важный практический вывод. Сам по себе индекс по jsonb или по выражению из jsonb нормальный инструмент. Но если вы индексируете поле, которое ещё и часто меняете, то нужно помнить: вы не просто ускоряете чтение, вы ещё и лишаете PostgreSQL возможности использовать HOT update. В коротком тесте это не дало заметного прироста времени, но в более длинных сериях обновлений такая потеря оптимизации может оказаться важной.

Что показал эксперимент

Если коротко, то у нас получилась такая картина:

  • На маленьких документах разница между “статус отдельной колонкой” и “статус внутри jsonb” есть, но в нашем тесте она оказалась минимальной: 9 мс против 10 мс;

  • На больших документах разница становится радикальной: 8 мс против 1090 мс;

  • в нашем коротком тесте индекс на (payload->>'status') почти не изменил общее время выполнения: доминирующей всё равно осталась стоимость переписывания большого payload;

  • при этом после индекса PostgreSQL полностью потерял возможность использовать HOT update;

  • сам факт использования большого jsonb не всегда проблема;

  • проблема начинается тогда, когда большой jsonb становится контейнером для часто меняющихся полей.

Именно это, на наш взгляд, и есть скрытая цена jsonb: проблема появляется не в момент проектирования схемы, а позже, когда данные уже выросли и обновления стали регулярными.

Практические выводы

После этого прогона у нас получились очень приземлённые правила.

1. Часто изменяемые поля лучше держать отдельно от большого JSONB.
Если status, retry_count, updated_at, last_error_code или похожие поля меняются часто, лучше не прятать их внутрь большого документа только ради “красоты схемы”.

2. Большой JSONB хорошо подходит для относительно стабильных данных.
Для редко меняющейся структуры, аудита, сырого ответа внешнего API, “длинного хвоста” редко используемых атрибутов — это отличный вариант.

3. Индекс по выражению из JSONB надо оценивать вместе со стоимостью записи.
Если чтения выигрывают, а обновления редкие – отлично. Если поле меняется часто, индекс может лишить PostgreSQL HOT update и тем самым сделать запись дороже, даже если в коротком тесте это не всегда сразу видно по времени.

4. Смотреть надо не только на время запроса, но и на размеры heap и TOAST.
Если измерять только EXPLAIN ANALYZE, можно упустить главный побочный эффект: рост таблицы и TOAST. Оценочные счётчики вроде n_dead_tup тоже полезны, но использовать их лучше как вспомогательный сигнал, а не как основной аргумент.

5. Не стоит надеяться, что VACUUM “магически всё схлопнет обратно”.
Обычный VACUUM нужен и полезен, но его основная задача – освобождать место для повторного использования, а не немедленно уменьшать размер файлов таблицы и TOAST.

Итоговая таблица результатов

Сценарий

Время UPDATE

n_tup_hot_upd

TOAST

small + status column

9 мс

1122

без заметных изменений

small + status in jsonb

10 мс

1110

без заметных изменений

big + status column

8 мс

1096

без изменений

big + status in jsonb

1090 мс

1096

+30 МБ

big + status in jsonb + expr index

1070 мс

0

не замеряли

jsonb остаётся очень сильным инструментом. Но за его удобством легко пропустить простую вещь: стоимость обновления определяется не только бизнес-логикой, но и физикой хранения.

Если поле меняется часто, лучше заранее спросить себя:

  • это действительно часть документа;

  • или это оперативный атрибут строки, который просто удобно было спрятать в JSONB?

Пока документ маленький, разница может быть незаметной. Когда документ становится большим, а обновления регулярными, ответ на этот вопрос начинает стоить вполне реальных миллисекунд, страниц heap, TOAST-чанков и работы VACUUM.

Для нас главный вывод именно в этом:
большой jsonb сам по себе не страшен. Опасным он становится тогда, когда превращается в контейнер для часто изменяемых полей, которые логически кажутся “мелочью”, а физически заставляют PostgreSQL переписывать большой документ заново.