PostgreSQL
JSONB: документная модель в PostgreSQL
Что делать, когда каждый пользователь имеет разный набор атрибутов, а схема меняется каждую неделю? MongoDB? Нет - PostgreSQL с JSONB даёт документную гибкость без потери транзакций, JOIN и ACID. Shopify, GitLab, Notion - все используют JSONB в production.
- **Shopify:** метаданные товаров в JSONB - 80+ млн продуктов с произвольными атрибутами (таможенные коды, материалы, размерные сетки разных стран) без EAV-таблицы
- **GitLab:** JSONB для хранения метаданных CI/CD пайплайнов и переменных; GIN-индексы позволяют фильтровать 50+ млн записей за десятки миллисекунд
- **Notion:** блоки страниц хранятся в JSONB - каждый блок (текст, таблица, база данных) имеет разную структуру; PostgreSQL заменил документную БД без потери ACID-гарантий
Операторы JSONB
JSONB хранит данные в бинарном формате: при записи JSON парсится и сохраняется как дерево, что делает операции над ним быстрее, чем над обычным JSON-текстом. Ключи сортируются, дубликаты отбрасываются - в итоге запросы к полям не требуют парсинга строки каждый раз.
Shopify хранит метаданные товаров в JSONB: десятки миллионов SKU с произвольными атрибутами (цвет, размер, материал, таможенный код). Фиксированная схема потребовала бы сотни колонок или EAV-таблицу - оба варианта хуже и по производительности, и по поддержке.
Оператор `->` возвращает JSONB, а `->>` возвращает текст (text). При сравнении с текстовым литералом всегда используй `->>`; иначе придётся кастовать: `(data -> 'price')::numeric`.
Какой оператор вернёт текстовое значение поля `name` из JSONB-колонки `data`?
GIN-индексы для JSONB
GIN (Generalized Inverted Index) индексирует каждый ключ и значение внутри JSONB отдельно. Представь инвертированный индекс слов в книге - GIN делает то же самое для JSON-деревьев. Без него поиск по вложенным полям приводит к Seq Scan по всей таблице.
`jsonb_path_ops` создаёт индекс в ~3 раза меньший, чем стандартный GIN, и быстрее для containment-запросов (`@>`). Используй стандартный GIN только если нужны операторы `?` (существование ключа) или `?|`, `?&`.
GitLab использует GIN-индексы на JSONB-колонках таблицы `issues` для хранения метаданных и фильтров. При базе в 10+ млн issues это разница между 50 мс и 15 секундами на фильтрацию по атрибутам.
Какой класс оператора GIN-индекса следует выбрать, если нужно только containment (`@>`), но важен минимальный размер индекса?
JSONPath: XPath для JSON
JSONPath (SQL/JSON, стандарт SQL:2016) позволяет писать сложные запросы к JSON-деревьям с фильтрами, рекурсивным спуском и арифметикой. PostgreSQL поддерживает JSONPath начиная с версии 12.
JSONPath работает напрямую с GIN-индексом (при `jsonb_path_ops`). Функции: `jsonb_path_exists` - булево, `jsonb_path_query` - набор строк, `jsonb_path_query_array` - массив, `jsonb_path_query_first` - первый элемент.
Какой JSONPath-запрос найдёт все элементы массива `tags` со значением `"premium"`?
Containment и existence операторы
Containment (`@>`) проверяет, содержит ли левый JSONB правый как подмножество. Existence (`?`) проверяет наличие ключа на верхнем уровне. Это два самых важных оператора для фильтрации по JSONB - оба используют GIN-индекс.
| Оператор | Значение | Индекс |
|---|---|---|
| @> | левый содержит правый | GIN (оба класса) |
| <@ | правый содержит левый | GIN (оба класса) |
| ? | ключ существует | GIN (стандартный) |
| ?| | любой ключ существует | GIN (стандартный) |
| ?& | все ключи существуют | GIN (стандартный) |
Какой запрос найдёт все строки, где JSONB-поле `data` содержит ключ `email` на верхнем уровне?
JSONB vs фиксированные колонки
JSONB и реляционные колонки решают разные задачи. Notion хранит контент блоков в JSONB (структура блоков меняется), но метаданные страниц (title, created_at, owner_id) - в обычных колонках. Смешение без понимания компромиссов ведёт к медленным запросам и сложным миграциям.
- Используй JSONB когда... — Схема данных нестабильна или уникальна для каждой строки. Атрибуты sparse (большинство строк имеют только часть из них). Данные приходят от внешних API без фиксированной структуры. Нужно хранить иерархические данные без нормализации.
- Используй колонки когда... — Поле есть у каждой строки. По полю часто делаются JOIN, GROUP BY, ORDER BY. Нужны строгие типы и ограничения (NOT NULL, CHECK, FK). Поле участвует в B-tree индексе для диапазонных запросов.
JSONB - это замена реляционным таблицам; можно хранить всё в одной таблице с одной JSONB-колонкой.
JSONB дополняет реляционную модель для случаев с нефиксированной схемой. Поля с фиксированной семантикой и типами всегда лучше как колонки.
JSONB не поддерживает FK, не гарантирует типы, не поддерживает B-tree для диапазонных запросов. JOIN по `(data ->> 'user_id')::int` работает в 5-10 раз медленнее, чем JOIN по обычной INT-колонке с индексом.
Instagram хранит фильтры, применённые к фото (пользователи выбирают разные наборы). Что лучше подходит?
Итоги
- **JSONB vs JSON:** JSONB хранит бинарное дерево - запросы быстрее, поддерживает индексы; JSON хранит текст verbatim - только для аудит-логов и вывода
- **GIN-индексы:** `jsonb_path_ops` для containment/jsonpath (меньший размер), стандартный GIN для existence-операторов (`?`, `?|`, `?&`)
- **Правило выбора:** фиксированные поля - колонки (тип, FK, B-tree); нестабильные/sparse атрибуты - JSONB (гибкость + GIN)
Связанные темы
JSONB тесно связан с другими механизмами PostgreSQL:
- Индексы PostgreSQL — GIN - один из типов индексов PostgreSQL; понимание B-tree vs GIN vs GiST критично для выбора стратегии индексирования JSONB
- Полнотекстовый поиск — FTS также использует GIN-индексы для tsvector; архитектурно схоже с JSONB-индексированием
- Расширения PostgreSQL — pg_trgm расширяет возможности поиска по строкам внутри JSONB; pgvector хранит эмбеддинги в колонках рядом с JSONB-метаданными
Вопросы для размышления
- В таблице `users` есть поле `settings JSONB` с настройками уведомлений. Как добавить индекс, чтобы быстро находить пользователей с включённым `email_notifications`?
- Почему `WHERE data ->> 'user_id' = '42'` работает медленнее, чем `WHERE user_id = 42`, даже при наличии индекса на `(data ->> 'user_id')`?
- Shopify добавляет новый атрибут товара `carbon_footprint`. Нужна ли миграция схемы или достаточно просто начать писать в JSONB-колонку?