DanLevy.net

JSONB: Лучший способ угробить базу данных

JSONB — мощный и полезный инструмент, который очень легко использовать во вред, когда blob становится вашей настоящей схемой.

PostgreSQL добавил JSONB, чтобы вы могли хранить слабоструктурированные данные без жёстких схем на старте. Идея была здравая: иногда вы действительно не знаете, как будут выглядеть данные, или они меняются слишком часто, чтобы для них имели смысл традиционные колонки.

Это важно, потому что JSONB — не ошибка. Во многих системах это самое чистое представление предметной области. Если вы храните полезные нагрузки вебхуков сторонних сервисов, версионированные тела событий, функциональные флаги или конфиги LLM, где каждый провайдер и модель выставляют разный и постоянно меняющийся набор опций, загонять всё в первоклассные колонки может быть более неудобно, чем полезно.

Проблема в том, что JSONB — это ещё и самый простой способ отложить решения по схеме, не признаваясь себе, что вы их откладываете. Где-то между намерением и реализацией он стал эквивалентом «я приберусь в комнате потом» в мире баз данных. То временное решение, к которому вы потянулись полгода назад? Оно всё ещё там, и теперь от него зависит продакшн.

Я постоянно вижу один и тот же паттерн. Команда добавляет JSONB-колонку, потому что не уверена в требованиях. Они обещают себе нормализовать её, когда всё устаканится. Три года спустя в этой колонке лежит сорок разных версий того, что должно было быть профилем пользователя, и к ним обращаются пятнадцать сервисов, каждый со своими предположениями о содержимом.

Технический долг — не в самом JSONB. Он в разрыве между тем, что вы себе обещали построить, и тем, что построили на самом деле: недокументированной схемой на стороне чтения.

Что обычно происходит

Вы добавляете функциональность и не уверены, понадобится пользователям twitter_handle или bluesky_handle или что-то совершенно другое. Вместо того чтобы продумать схему, вы делаете так:

CREATE TABLE users (
id SERIAL PRIMARY KEY,
profile JSONB
);

Это работает. Вы выкатываете фичу, переходите к следующей, потом к следующей. JSONB-колонка тихо растёт в фоне.

Это развилка. Если profile остаётся непрозрачным blob’ом, получаемым по user.id, вы, скорее всего, в порядке. Если он начинает становиться основным местом хранения бизнес-данных, компромиссы меняются быстро.

Продукт спрашивает: «Сколько пользователей в Нью-Йорке?»

Вы пишете:

SELECT count(*) FROM users WHERE profile->>'location' = 'New York';

Postgres делает полное сканирование таблицы. Каждую строку.

Поэтому вы добавляете GIN-индекс. Возможно, это всё ещё приемлемо. Иногда так и есть. Но теперь вы платите реальную цену сложностью и хранилищем за то, что поле, ведущее себя как первоклассные реляционные данные, так и не стало первоклассной колонкой.

Год 1: Дрейф схемы

В одной и той же колонке у вас три версии данных.

Ваш прикладной код теперь выглядит так:

const city = user.location || user.city || user.address?.city || "Unknown";

Вы не удалили схему. Вы просто перенесли проверки и обеспечение целостности из базы данных в разрозненный прикладной код.


Когда JSONB действительно стоит использовать

У JSONB есть оправданные сценарии использования. Во многих случаях он совершенно нормален, а иногда — лучший из доступных вариантов.

Ключевое различие не в том, что «структурированное — хорошо, JSON — плохо». Скорее, вот так:

Легитимные сценарии использования JSONB

  1. Полезные нагрузки вебхуков: Вы получаете данные от Stripe, Slack или GitHub. У вас нет никакого контроля над схемой. Возможно, вы никогда не будете их опрашивать. Вам просто нужно сохранить их для отладки или воспроизведения. JSONB идеален.

  2. Логи и потоки событий: Логи приложений, аудиторные следы, контексты ошибок. Они тяжелы на запись, редко опрашиваются по конкретным полям и часто анализируются массово или экспортируются в аналитические платформы. JSONB здесь подходит.

  3. Пользовательские настройки и предпочтения: Объекты настроек, где у вас 100+ булевых флагов, большинство из которых false, и вы всегда получаете весь blob по ID пользователя. Вы не выполняете WHERE preferences->>'theme' = 'dark'. JSONB работает.

  4. Конфиги LLM-провайдеров и моделей: Это один из самых наглядных современных примеров. OpenAI, Anthropic, Gemini, локальные open-weight модели и вендорские шлюзы — все выставляют пересекающиеся, но разные параметры. Даже внутри одного провайдера возможности моделей и названия опций эволюционируют. JSONB-конфиг зачастую гораздо честнее, чем притворяться, что temperature, top_p, reasoning_effort, json_schema, tool_choice и ещё два десятка регуляторов должны быть универсальными колонками. JSONB часто оказывается верной абстракцией.

  5. Кэширование API-ответов: Вы кэшируете целые API-ответы. База данных — просто более быстрый Redis. Вы получаете данные по ключу кэша, никогда — по вложенным свойствам. JSONB уместен.

  6. Event Sourcing: Вы храните неизменяемые полезные нагрузки событий. Ваши запросы всегда вида «дай мне все события для агрегата X» в порядке времени. Вы никогда не выполняете WHERE по свойствам событий. JSONB подходит.

  7. Поверхности расширяемости: Интеграции, настройки плагинов, переопределения на арендатора, метаданные маркетплейса, возможности провайдеров или поля «extras», где вы явно ожидаете, что форма будет различаться в зависимости от подтипа. JSONB может быть верным контрактом, а не компромиссом.

Правило большого пальца: если приложение получает документ по известному ключу и само знает, как его валидировать/версионировать, JSONB может быть отличным выбором. Если бизнес постоянно задаёт реляционные вопросы о вложенных ключах — эти поля пытаются стать колонками.

Лучший паттерн — зачастую гибридный

Многие зрелые системы приходят к этому:

CREATE TABLE llm_requests (
id UUID PRIMARY KEY,
provider TEXT NOT NULL,
model TEXT NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
config JSONB NOT NULL
);

Обычно это лучше любой из крайностей.

Это не «неудачная нормализация». Это проведение границы в правильном месте.

На масштабе: Версионирование объектов > Нормализация

Вот где становится интересно. При достаточно большом масштабе «правильное» решение — не нормализация, а версионирование объектов.

Если у вас миллиарды строк и частая эволюция схемы, миграция колонок становится дорогой. Stripe, GitHub и Netflix не нормализуют всё подряд. Вместо этого:

CREATE TABLE entities (
id UUID PRIMARY KEY,
version INT NOT NULL,
data JSONB NOT NULL
);

Ваше приложение знает, как читать version: 1, version: 2, version: 3. Никаких миграций базы данных для новых полей. Код сам обеспечивает обратную совместимость.

Это архитектурное решение, а не лень. Оно обменивает сложность базы данных на сложность приложения. Иногда это именно тот обмен, который нужен, особенно когда документ по своей природе версионирован, а приложение — канонический интерпретатор.

Сценарий отказа — не «использование JSONB». Сценарий отказа — использование JSONB без версионирования, валидации, правил повышения и чёткой границы между документными и реляционными данными.

Вопросы, которые действительно важны

Прежде чем добавить JSONB-колонку, спросите:

  1. Будем ли мы опрашивать вложенные поля в WHERE, JOIN, GROUP BY или ORDER BY регулярно?
  2. Контролируем ли мы эту схему или она внешняя и изменчивая?
  3. Преднамеренно ли форма гетерогенна между записями?
  4. Есть ли у нас валидация и версионирование на уровне приложения?
  5. Какие поля, вероятно, станут операционными измерениями позже?

Если ответ на #1 — «да, постоянно», это сильный сигнал в пользу колонок.

Если ответы на #2 и #3 — «да», JSONB, вероятно, делает за вас реальную работу.


Как выбраться из ловушки

Если вы уже в этой яме — перестаньте копать.

  1. Аудит: Запустите jsonb_object_keys и изучите реальный дрейф форм, а не ту форму, которая, как вы предполагаете, существует.
  2. Повышение: Определите поля, по которым вы чаще всего фильтруете, объединяете, сортируете или строите отчёты. Сделайте их настоящими колонками.
  3. Валидация: Добавьте валидацию на уровне приложения или базы данных для того, что остаётся в JSONB.
  4. Версионирование: Если blob — настоящие доменные данные, версионируйте их явно.
  5. Очистка: Удалите дублированные ключи из blob’а после того, как повышенные колонки созданы.

Не говорите себе, что каждый blob обязан быть нормализован. Но и не говорите себе, что blob with постоянной бизнес-семантикой — это «временное решение».

JSONB отличный, когда документ действительно имеет форму документа. Он опасен, когда это реляционная схема в дурацких усах.

Ресурсы