JSONB стал тем самым ответом, который тихо закрыл тысячи споров о том, не пора ли тащить в проект отдельную документную базу. Зачем городить новый стек, если Postgres умеет хранить полуструктурированные данные прямо в колонке. Беда в том, что лёгкость внедрения оборачивается лёгкостью злоупотребления. Команда сваливает в JSONB-колонку целиком ответ от внешнего сервиса, навешивает индекс и считает себя застрахованной. Через полгода те же люди смотрят на запрос, который выполняется двенадцать секунд на таблице в два миллиона строк, и не понимают, что пошло не так. Разберём честно, где JSONB реально оправдан, а где он становится путём к деградации.
Чем JSONB отличается от текстового JSON и почему это важно
Прежде чем сравнивать с отдельными таблицами, стоит развести два типа, которые путают из-за похожих имён. Текстовый тип хранит точную копию введённого текста, и при каждом чтении Postgres разбирает этот текст заново. Плюс в верности: порядок ключей сохраняется, дубликаты ключей остаются, пробелы лежат как есть. Минус в том, что накладные расходы на разбор бьют по каждому запросу, касающемуся колонки.
JSONB хранит данные в разобранном двоичном формате. Разбор происходит один раз при записи, а дальше чтение идёт без повторного парсинга. За это приходится платить при записи и в нюансах обновления, к которым вернёмся. В подавляющем большинстве случаев для полуструктурированных данных правильный ответ это именно JSONB. Но трактовать его как волшебную палочку без понимания внутреннего устройства означает напрашиваться на серьёзные узкие места по производительности.
Где JSONB действительно на своём месте
У JSONB есть честная ниша, и в ней он великолепен. Первый случай это данные по-настоящему переменной формы. Ответы пользовательских форм, где каждая форма своя. Полезные нагрузки событий от внешних сервисов, приходящие через обратные вызовы и не имеющие фиксированной структуры. Гибкие атрибуты товаров на торговой площадке, где у каждой категории свой набор свойств. Загонять всё это в жёсткую реляционную схему означало бы плодить десятки почти всегда пустых колонок.
Второй уместный случай это разреженные атрибуты. Поле, которое применимо к трём процентам записей, не стоит выносить в почти всегда пустую колонку, JSONB здесь экономнее. Третий случай это связанные метаданные, которые всегда читаются вместе и целиком. Заголовки запроса для журнала аудита, снимок адреса на момент оформления заказа, набор переопределений флагов под конкретного пользователя. Их незачем дробить на колонки, раз они всегда берутся одним куском.
Объединяет эти случаи одно: данные либо не имеют устойчивой формы, либо читаются целиком и редко участвуют в фильтрации, соединениях и сортировке поодиночке.
Где JSONB превращается в свалку и когда нужны нормальные колонки
А теперь обратная сторона. Если у данных фиксированная форма, которую вы всегда и читаете, и пишете, то отдельные колонки выигрывают почти по всем статьям. Они типизированы, на них работают ограничения, под них есть внешние ключи. JSONB не умеет обеспечивать ссылочную целостность, в нём нельзя объявить внешний ключ, и проверка типов ложится либо на приложение, либо отсутствует вовсе.
Поля, которые постоянно фильтруются, соединяются, сортируются или ограничиваются, почти всегда лучше держать обычными реляционными колонками. Использованный плохо, JSONB превращает базу в свалку, доступную только на запись. Особенно показателен реальный случай: торговая площадка хранила заказы единой JSONB-нагрузкой, а дашборд фильтровал по продавцу, статусу и временному окну и показывал топ товаров. Фильтры шли по JSONB-полям без индексной поддержки, агрегация разворачивала позиции при каждом запросе. План запроса представлял собой огромное сканирование JSONB. После рефакторинга, о котором ниже, план сменился на индексные обращения и агрегацию по маленькой свёртке.
Почему обновление одного ключа в JSONB обходится дороже, чем кажется
Тут скрыта самая недооценённая проблема, которую почти не упоминают вводные статьи. Postgres не умеет частично обновлять JSONB на уровне хранения. Даже если выполнить точечное изменение одного ключа через специальную функцию, база вынуждена прочитать весь документ целиком, изменить его в памяти и записать новую полную копию документа по правилам многоверсионности.
Последствия серьёзные. Если JSONB-документ весит пять мегабайт, база перезаписывает все пять мегабайт даже при смене одного логического значения. Это порождает высокую генерацию журнала предзаписи, раздувание таблицы, активную возню со служебным хранилищем больших значений и замедление обновлений под нагрузкой. Документы, превышающие лимит страницы, уезжают в это служебное хранилище, и частые обновления одного ключа в большом документе вызывают там бурную текучку.
Вывод практичный: если в документе есть часто обновляемое значение вроде счётчика, его нужно вынести в типизированную колонку, а не держать внутри крупного JSONB. Полнодокументная перезапись при точечном изменении больно ударит на масштабе.
Как правильно индексировать JSONB и почему GIN не панацея
Обычные индексы на JSONB-колонке напрямую не работают. Для поиска внутри документов служит обобщённый инвертированный индекс. Вместо индексации документа как единого целого он разбирает его на части и индексирует ключи и значения внутри, фактически создавая под капотом гигантскую таблицу соответствий.
У этого индекса два класса операторов, и разница между ними принципиальна. Класс по умолчанию поддерживает операторы существования ключа и оператор вложенности, то есть проверку, содержит ли документ заданную пару ключ-значение. Но за гибкость платят размером: такой индекс способен разрастись до шестидесяти-восьмидесяти процентов размера таблицы. Второй класс заточен только под оператор вложенности и хеширует полные пути вместо индексации каждого ключа, благодаря чему индекс получается в разы компактнее, занимая порядка двадцати-тридцати процентов размера таблицы, и быстрее на своей задаче. На больших таблицах со сложными документами разница в размере доходит до трёх-четырёх раз.
-- Гибкий вариант: поддерживает вложенность и проверку существования ключей
CREATE INDEX idx_events_gin
ON events USING gin (metadata);
-- Компактный вариант: только вложенность, но в разы меньше и быстрее
CREATE INDEX idx_events_pathops
ON events USING gin (metadata jsonb_path_ops);
Главное заблуждение состоит в том, что обобщённый индекс ускоряет любые запросы к JSONB. Это не так. Он работает с оператором вложенности, но не ускоряет извлечение значения через оператор извлечения текста. Это самая частая ошибка при индексации JSONB. Запрос на равенство по извлечённому полю обобщённый индекс не поддержит.
Когда выручают выражательные индексы и генерируемые колонки
Если вы всегда фильтруете по одним и тем же одному-трём полям, выгоднее не общий обобщённый индекс, а выражательный индекс на B-дереве по конкретному извлечённому значению. Он строго привязан к форме выражения, поэтому идеален для статичных, заранее известных запросов, зашитых в код приложения, но бесполезен для запросов, которые часто меняются. Важнейший нюанс: выражение в условии фильтрации должно совпадать с определением индекса вплоть до явного приведения типа.
-- Точечный индекс под повторяющийся фильтр по статусу
CREATE INDEX idx_status
ON events ((metadata ->> 'status'));
-- Частичный индекс ещё уже: только для событий покупки
CREATE INDEX idx_purchase_user
ON events ((metadata ->> 'user_id'))
WHERE metadata ->> 'type' = 'purchase';
Для диапазонных запросов по числовому или дате внутри JSONB нужен выражательный индекс с явным приведением типа, либо генерируемая колонка нужного типа, по которой строится обычный индекс. Генерируемые колонки это элегантный компромисс: JSONB остаётся источником истины, а часто используемые поля проецируются в типизированные колонки с быстрыми реляционными индексами. Именно так чинилась та торговая площадка: добавили генерируемые колонки продавца, статуса и времени с индексами на B-дереве, частичный обобщённый индекс на нагрузку только для нужного статуса и ночное задание, разворачивающее позиции в узкую отдельную таблицу для горячего агрегата.
Чем платит запись за обобщённый индекс
Цена обобщённого индекса не только в размере, но и в скорости записи. Его обслуживание дорого: каждая вставка или обновление, затрагивающее JSONB-колонку, обязано разобрать весь документ заново, чтобы обновить индекс. На таблицах с активной записью и крупными документами это способно срезать пропускную способность вставки на тридцать-пятьдесят процентов.
Отсюда разумная практика. Если вы запрашиваете лишь два-три ключа, выражательные индексы по ним обслуживать заметно дешевле, чем общий обобщённый индекс по всему документу. После добавления любого индекса на JSONB стоит понаблюдать за задержкой вставки: если она заметно выросла, имеет смысл уйти от широкого индекса к точечным выражательным или частичным. И добавлять такие индексы на боевых таблицах нужно без блокировки, в фоновом режиме.
Какой стратегии держаться при проектировании
Здравый подход к JSONB строится на одной мысли: это гибкое расширение реляционной модели, а не замена ей. Postgres с JSONB работает прекрасно ровно до тех пор, пока вы используете его как дополнение к нормальной схеме, а не как способ свалить всё в одну колонку и забыть про проектирование.
Полезно держать в голове короткий чеклист. Документировать, какие ключи JSONB будут запрашиваться, ещё в момент добавления колонки, чтобы выбор индекса шёл с первого дня, а не латался задним числом после деградации. Для гибкого поиска по разным ключам брать обобщённый индекс класса по умолчанию, для запросов только на вложенность переключаться на компактный класс. Под постоянный фильтр по одним и тем же полям ставить выражательный индекс на B-дереве с точным приведением типа. На совсем маленьких таблицах в несколько тысяч строк индекс можно вообще не ставить, сэкономив на расходах записи. И, что важнее всего, продвигать часто обновляемые или фильтруемые JSONB-ключи в типизированные колонки, как только характер обращений к ним устаканился.
Зрелость в работе с JSONB наступает тогда, когда перестаёшь видеть в нём универсальное хранилище и начинаешь чувствовать границу между по-настоящему переменными данными и теми, что давно просятся в честные колонки. Тот, кто сваливает в JSONB всё подряд, рано или поздно получает двенадцатисекундный запрос на ровном месте. А тот, кто держит в JSONB только гибкое, а структурное выносит в типизированные колонки, получает и удобство схемы без миграций, и предсказуемую производительность под нагрузкой.