Продвинутые типы данных: JSONB, Array, UUID
PostgreSQL поддерживает JSONB, массивы, UUID, диапазоны (range types) и кастомные типы — это позволяет хранить сложные структуры данных с полноценной индексацией прямо в реляционной базе. JSONB с GIN-индексом даёт возможности document store без выхода за пределы SQL и ACID-транзакций. Массивы первого класса с операторами ANY, ALL, @>, unnest позволяют хранить наборы значений без join-таблиц. UUID решает проблему глобально уникальных идентификаторов в распределённых системах.
Почему это важно: В реальных продуктах почти всегда есть сущности с гибкой схемой: товары с разными атрибутами, пользователи с произвольными настройками, события с разными payload. Классическое решение — EAV-таблица (entity-attribute-value) или добавление десятков nullable столбцов — порождает ужасный SQL, плохую производительность и невозможность типизации. JSONB позволяет хранить гибкие данные в одном столбце с полноценным индексированием, операторами поиска и возможностью JOIN с обычными таблицами. Это конкретная бизнес-ценность: один разработчик за день реализует гибкие атрибуты товаров, которые без JSONB заняли бы неделю проектирования EAV.
Главная идея
JSONB (JSON Binary) хранит данные в бинарном, разобранном виде — не как строку, а как структуру. Это означает: нет необходимости парсить при каждом чтении, можно индексировать через GIN, можно извлекать отдельные ключи операторами -> и ->>, обновлять через jsonb_set(), проверять вхождение через @>. JSON-тип хранит строку as-is и поддерживает только базовые операции без индексов. Массивы в PostgreSQL — полноценный первый класс типов. Можно создать столбец integer[], text[], uuid[], jsonb[]. Операторы: @> (содержит), <@ (содержится в), && (пересечение), ANY() (есть ли элемент). GIN-индекс на массиве делает эти операции быстрыми даже на миллионах строк. Функции array_agg() и unnest() конвертируют между массивом и набором строк. UUID — 128-битный тип для глобально уникальных идентификаторов. UUID v4 — случайный, хорош для безопасности (нельзя угадать следующий ID), но плох для B-tree индекса: случайные вставки = фрагментация. UUID v7 — time-ordered, первые 48 бит = timestamp с миллисекундной точностью. Это даёт монотонно возрастающие значения как у BIGSERIAL, сохраняя глобальную уникальность.
Как это выглядит на практике
- Создай таблицу products с JSONB-атрибутами: CREATE TABLE products (id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, category TEXT, price NUMERIC(10,2), attributes JSONB DEFAULT '{}');. JSONB DEFAULT '{}' гарантирует, что атрибуты никогда не NULL.
- Вставь товары с разными схемами атрибутов: INSERT INTO products (name, category, price, attributes) VALUES ('iPhone 15 Pro', 'phones', 89990, '{"color": "titanium", "storage_gb": 256, "display_inches": 6.1}'), ('Футболка Uniqlo', 'clothing', 1490, '{"color": "white", "size": "M", "material": "cotton"}');. Оба товара в одной таблице с разными атрибутами.
- Создай GIN-индекс для быстрого поиска по JSONB: CREATE INDEX idx_products_attributes ON products USING GIN (attributes);. После этого поиск WHERE attributes @> '{"color": "white"}' использует индекс вместо sequential scan.
- Используй операторы JSONB: SELECT name, attributes->>'color' AS color, (attributes->>'storage_gb')::int AS storage FROM products WHERE attributes @> '{"color": "titanium"}';. Оператор -> возвращает JSONB, ->> возвращает TEXT. Для числовых операций обязательно приведение типа.
- Обнови конкретный ключ в JSONB: UPDATE products SET attributes = jsonb_set(attributes, '{price_usd}', '999') WHERE id = 1;. jsonb_set не перезаписывает весь объект — только обновляет указанный путь. Это безопасно для конкурентных обновлений разных ключей.
- Используй массивы для тегов: ALTER TABLE products ADD COLUMN tags TEXT[] DEFAULT '{}'; UPDATE products SET tags = ARRAY['новинка', 'хит продаж'] WHERE id = 1; SELECT * FROM products WHERE 'хит продаж' = ANY(tags);. Оператор ANY проверяет вхождение элемента.
- Создай таблицу с UUID первичным ключом: CREATE TABLE events (id UUID DEFAULT gen_random_uuid() PRIMARY KEY, user_id BIGINT, event_type TEXT, payload JSONB, created_at TIMESTAMPTZ DEFAULT now());. gen_random_uuid() генерирует UUID v4. В PostgreSQL 17+ доступна функция uuidv7() для time-ordered UUID.
- Используй диапазонные типы для временных слотов: CREATE TABLE booking_slots (id BIGINT PRIMARY KEY, resource_id BIGINT, during TSTZRANGE NOT NULL); INSERT INTO booking_slots VALUES (1, 42, '[2024-06-01 10:00, 2024-06-01 11:00)');. TSTZRANGE хранит временной интервал с timezone-aware timestamps.
- Проверь пересечение диапазонов для предотвращения двойного бронирования: SELECT * FROM booking_slots WHERE resource_id = 42 AND during && '[2024-06-01 10:30, 2024-06-01 11:30)'::tstzrange;. Оператор && проверяет пересечение — возвращает все слоты, конфликтующие с запрошенным.
- Индексируй диапазонный тип через GiST: CREATE INDEX idx_booking_slots_during ON booking_slots USING GiST (during); CREATE INDEX idx_booking_slots_resource_during ON booking_slots USING GiST (resource_id, during);. GiST — правильный индекс для диапазонных операций, GIN здесь не подойдёт.
- Разверни массив в строки для аналитики: SELECT unnest(tags) AS tag, count(*) FROM products GROUP BY tag ORDER BY count DESC LIMIT 10;. unnest() разворачивает массив в набор строк — каждый тег становится отдельной строкой для агрегации.
- Используй jsonb_array_elements для JSONB-массивов: SELECT p.name, feature->>'name' AS feature FROM products p, jsonb_array_elements(attributes->'features') AS feature WHERE feature->>'available' = 'true';. jsonb_array_elements разворачивает JSONB-массив в строки.
Примеры кода
JSONB: поиск, фильтрация и извлечение данных
-- Найти все телефоны с хранилищем >= 128GB
SELECT
id,
name,
price,
(attributes->>'storage_gb')::int AS storage_gb,
attributes->>'color' AS color
FROM products
WHERE
category = 'phones'
AND (attributes->>'storage_gb')::int >= 128
ORDER BY price DESC;
-- Containment: найти все красные товары любой категории
SELECT id, name, category, price
FROM products
WHERE attributes @> '{"color": "red"}'::jsonb;
-- Проверка существования ключа
SELECT id, name
FROM products
WHERE attributes ? 'storage_gb';
Оператор @> проверяет containment — 'содержит ли JSONB этот подграф'. С GIN-индексом это O(log N). Оператор ? проверяет наличие ключа. Числовые значения из JSONB нужно приводить к нужному типу через ::int, ::numeric и т.д.
Обновление JSONB без перезаписи всего объекта
-- jsonb_set: обновить конкретный ключ
UPDATE products
SET attributes = jsonb_set(
attributes,
'{discount_pct}',
'15'::jsonb
)
WHERE id = 42;
-- Удалить ключ из JSONB
UPDATE products
SET attributes = attributes - 'old_field'
WHERE attributes ? 'old_field';
-- Слить два JSONB объекта (правый побеждает при конфликте ключей)
UPDATE products
SET attributes = attributes || '{"in_stock": true, "warehouse": "Moscow"}'::jsonb
WHERE id = 42;
jsonb_set(target, path, new_value) обновляет конкретный путь без перезаписи всего объекта. Оператор - удаляет ключ. Оператор || сливает два JSONB-объекта — аналог Object.assign в JavaScript.
Массивы: поиск, фильтрация и агрегация
-- Создать GIN-индекс на массив тегов
CREATE INDEX idx_products_tags ON products USING GIN (tags);
-- Найти продукты с тегом 'новинка'
SELECT id, name, tags
FROM products
WHERE tags @> ARRAY['новинка'];
-- Найти продукты с ЛЮБЫМ из нескольких тегов (пересечение)
SELECT id, name, tags
FROM products
WHERE tags && ARRAY['хит продаж', 'скидка'];
-- Статистика по тегам через unnest
SELECT
unnest(tags) AS tag,
count(*) AS product_count
FROM products
GROUP BY tag
ORDER BY product_count DESC
LIMIT 10;
Оператор @> для массивов проверяет 'содержит все элементы'. Оператор && проверяет пересечение — 'есть хотя бы один общий элемент'. unnest() — это set-returning function, она работает как LATERAL JOIN и разворачивает массив в строки для GROUP BY.
UUID как первичный ключ
-- UUID v4 (случайный) — для таблиц без высокой нагрузки на вставку
CREATE TABLE notifications (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id BIGINT NOT NULL,
message TEXT,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Индекс на user_id для быстрой выборки уведомлений пользователя
CREATE INDEX idx_notifications_user_id ON notifications (user_id, created_at DESC);
-- Вставка
INSERT INTO notifications (user_id, message)
VALUES (1001, 'Ваш заказ отправлен')
RETURNING id;
gen_random_uuid() генерирует UUID v4. UUID PK безопасен для публичного API — нельзя угадать следующий ID и нельзя определить порядок создания записей. Однако случайные UUID фрагментируют B-tree индекс при высоком insert rate — в этом случае используй UUID v7 или BIGSERIAL.
Диапазонные типы для бронирований
CREATE TABLE room_bookings (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id BIGINT NOT NULL,
guest_name TEXT NOT NULL,
stay DATERANGE NOT NULL,
EXCLUDE USING GIST (room_id WITH =, stay WITH &&)
);
CREATE INDEX idx_room_bookings_room_stay
ON room_bookings USING GIST (room_id, stay);
-- Проверить доступность комнаты на период
SELECT room_id
FROM room_bookings
WHERE room_id = 5
AND stay && '[2024-07-01, 2024-07-07)'::daterange;
-- Найти все бронирования, пересекающиеся с июлем 2024
SELECT *
FROM room_bookings
WHERE stay && '[2024-07-01, 2024-08-01)'::daterange;
DATERANGE хранит диапазон дат. EXCLUDE USING GIST — ограничение исключения, гарантирующее что нет двух бронирований одной комнаты на пересекающиеся периоды на уровне БД. Это более надёжно, чем проверка в коде приложения.
Что происходит под капотом
- JSON vs JSONB на уровне хранения: JSON хранит оригинальную строку as-is, включая пробелы и порядок ключей. JSONB парсит JSON при вставке и хранит в бинарном формате: ключи отсортированы, дублирующиеся ключи удалены, числа хранятся как native types. JSONB занимает чуть больше места при хранении, но значительно быстрее при чтении и поддерживает индексирование.
- GIN-индекс на JSONB: создаёт инвертированный индекс — mapping от каждого ключа и значения к списку row ID. Это позволяет WHERE attributes @> '{"color": "red"}' выполняться за O(log N + K) вместо O(N). GIN медленнее обновляется (при каждом UPDATE JSONB все изменившиеся ключи нужно переиндексировать), но быстрее для поиска.
- Оператор @> (containment): проверяет, является ли правый JSONB подграфом левого. '{"a":1, "b":2}' @> '{"a":1}' — true. Работает рекурсивно: '{"a":{"b":1}}' @> '{"a":{"b":1}}' — true. Для массивов: '[1,2,3]' @> '[1,3]' — true (порядок не важен). Это основной оператор для GIN-поиска.
- Массивы как first-class type: PostgreSQL хранит массив как отдельный тип с собственным OID. Размерность не фиксирована при объявлении (integer[] и integer[3] — один тип). Массивы могут быть многомерными (integer[][]). Функции: array_length(arr, 1), array_dims(arr), array_append(arr, elem), array_remove(arr, elem).
- UUID v4 vs v7 для производительности: UUID v4 — 128 случайных бит. При INSERT в B-tree индекс случайный UUID вставляется в случайную позицию, что вызывает page splits и фрагментацию. При большом объёме вставок (> 10K/сек) фрагментация приводит к росту индекса и замедлению. UUID v7 — первые 48 бит это timestamp с миллисекундной точностью, остальные биты случайны. Вставки монотонно возрастающие → нет page splits → компактный индекс как у BIGSERIAL.
- Оператор ->> vs ->: -> возвращает JSONB ('{"a":1}'->'a' = '1'::jsonb), ->> возвращает TEXT ('{"a":1}'->>''a' = '1'::text). Для числовых операций всегда нужно явное приведение: (attributes->>'price')::numeric. Нет автоматического преобразования — это защита от типовых ошибок.
- jsonb_path_query (SQL/JSON path, PostgreSQL 12+): более мощный механизм запросов к JSONB. SELECT * FROM products WHERE jsonb_path_exists(attributes, '$.colors[*] ? (@ == "red")') — поиск в массивах внутри JSONB. Поддерживает фильтры, методы, arithmetic.
- Диапазонные типы (range types): встроенные int4range, int8range, numrange, daterange, tsrange, tstzrange. Операторы: && (перекрытие), @> (содержит значение/диапазон), <@ (содержится в), -|- (смежные). EXCLUDE USING GIST — ограничение исключения, которое через GiST-индекс гарантирует отсутствие пересечений без дополнительных CHECK-запросов.
- Частичное обновление JSONB: || оператор создаёт новый JSONB-объект (shallow merge). jsonb_set для точечных обновлений. jsonb_insert для вставки в массив. Нет оператора для deep merge — для вложенных структур нужно использовать jsonb_set с путём.
- Производительность GIN vs GiST для JSONB: GIN быстрее для поиска (lookup), медленнее для вставки. GiST быстрее для вставки, медленнее для поиска. Для JSONB и массивов в OLTP-нагрузке (много reads, некоторые writes) GIN — правильный выбор. GiST используй для диапазонных типов и геометрических данных.
Типичные ошибки и заблуждения
- Ошибка: JSONB полностью заменяет нормализованную схему и можно хранить всё в JSONB. Данные в JSONB сложно JOIN'ить (нет foreign key constraints, нет typed joins), нельзя применить NOT NULL, UNIQUE, CHECK constraints к внутренним ключам, сложно рефакторить схему. Используй JSONB для действительно гибких, вариативных данных; реляционные связи держи в нормализованных таблицах.
- Ошибка: SELECT * FROM products WHERE attributes->>'price' > '100' работает корректно. Сравнение строк лексикографическое: '9' > '100' = true. Обязательно приводи к числовому типу: (attributes->>'price')::numeric > 100. Эта ошибка приводит к неверным результатам запросов, которые сложно обнаружить.
- Ошибка: массивы в PostgreSQL медленные и их не надо использовать. С GIN-индексом операции @>, &&, ANY работают быстро даже на миллионах строк. Массивы подходят для небольших наборов значений (теги, роли, permissions) без необходимости JOIN к ним как к самостоятельной сущности.
- Ошибка: UUID v4 — всегда правильный выбор для первичного ключа. UUID v4 случаен, что приводит к фрагментации B-tree и увеличению размера индекса при высоком insert rate. Для таблиц с активной вставкой (> 1000 rows/сек) используй UUID v7 или BIGSERIAL. UUID v4 отлично подходит для таблиц с редкими вставками где важна непредсказуемость ID.
- Ошибка: data->>'count' + 1 работает для инкремента счётчика. ->>'count' возвращает TEXT; арифметика текста не работает. Правильно: jsonb_set(data, '{count}', ((data->>'count')::int + 1)::text::jsonb). Или лучше: использовать отдельный integer столбец для счётчиков, а не JSONB.
- Ошибка: JSONB медленнее JSON при чтении. Наоборот: JSON хранит строку и при каждом -> операторе парсирует её заново. JSONB хранит бинарную структуру и чтение ключа — это прямое обращение к бинарным данным. JSONB быстрее при любом доступе к данным, медленнее только при вставке (нужно парсить).
- Ошибка: индекс на JSONB покрывает все запросы к нему. GIN-индекс CREATE INDEX ON products USING GIN (attributes) работает для @>, ?, ?|, ?&. Но WHERE attributes->>'price' > '100' не использует этот индекс — нужен отдельный индекс на выражение: CREATE INDEX ON products ((attributes->>'price')::numeric).
- Ошибка: диапазонные типы — редкая экзотика. Диапазонные типы решают реальные бизнес-задачи: временные слоты, периоды подписок, ценовые диапазоны, географические координаты. EXCLUDE USING GIST гарантирует отсутствие пересечений на уровне БД — это надёжнее, чем SELECT ... FOR UPDATE + проверка в коде.
Ключевые выводы
- JSONB с GIN-индексом даёт MongoDB-like гибкость внутри PostgreSQL с сохранением ACID-транзакций и возможностью JOIN с обычными таблицами.
- Оператор ->> всегда возвращает TEXT; для числовых операций обязательно приведение типа: (attributes->>'count')::int; без этого сравнения и арифметика работают некорректно.
- Массивы подходят для небольших наборов значений (теги, роли, permissions) без сложных JOIN; GIN-индекс делает @> и && быстрыми.
- UUID v4 — для безопасности и непредсказуемости; UUID v7 — для высокой нагрузки вставок, где нужен монотонно возрастающий ID без фрагментации B-tree.
- jsonb_set обновляет конкретный путь в JSONB, не перезаписывая весь объект — используй вместо read-modify-write в приложении.
- Диапазонные типы (daterange, tstzrange) с EXCLUDE USING GIST гарантируют отсутствие пересечений на уровне БД без дополнительной логики в коде.
- GIN-индекс на конкретное JSONB-выражение ((attributes->>'price')::numeric) нужен для range-запросов по значениям ключей; общий GIN не покрывает такие запросы.
Термины урока
Связь с работой backend-разработчика
JSONB наиболее полезен для трёх паттернов: конфигурации пользователей (разные настройки для каждого), атрибуты продуктов (разные атрибуты для разных категорий), event log (разные payload для разных типов событий). Для всех трёх случаев GIN-индекс + оператор @> даёт приемлемую производительность. Не используй JSONB для данных, по которым нужен JOIN с другими таблицами или которые требуют строгих constraints. Массивы — правильный выбор для тегов, списков разрешений, наборов значений, которые не являются самостоятельными сущностями. Если нужно хранить только список и делать 'содержит ли X' — массив проще и быстрее join-таблицы. Если нужно хранить дополнительные данные об отношении или делать сложные запросы — нужна отдельная таблица. Для ID: в Rails-приложениях хорошей практикой стало использование UUID PK в комбинации с has_secure_token для публичных API, чтобы не раскрывать порядок создания записей. Если таблица имеет высокий insert rate (заказы, события, логи), используй BIGSERIAL или UUID v7 для предотвращения фрагментации индекса.
Мини-разбор реальной ситуации
E-commerce платформа с каталогом из 500K товаров в 200 категориях. Изначальная схема: таблица product_attributes с полями (product_id, attribute_name, attribute_value TEXT) — классический EAV. Проблемы: запрос 'найди красные телефоны дороже 50000₽' требует двух JOIN к product_attributes с разными условиями — 8 секунд на 500K товаров. Поддержка новых атрибутов требует изменений в коде, но не в схеме БД. Невозможно задать типизацию (все значения TEXT). Переход на JSONB: ALTER TABLE products ADD COLUMN attributes JSONB DEFAULT '{}'. Скрипт миграции переносит данные из EAV в JSONB за 20 минут. CREATE INDEX idx_products_attrs ON products USING GIN (attributes). Дополнительный индекс: CREATE INDEX idx_products_price ON products ((attributes->>'price')::numeric). После миграции тот же запрос (красные телефоны > 50000₽) выполняется за 12мс против 8 секунд. Добавление нового атрибута — просто вставка в JSONB, никаких изменений схемы. Специализированные индексы на часто запрашиваемые атрибуты позволяют точечно ускорить конкретные фильтры.
Что запомнить
- JSONB + GIN-индекс — замена EAV и document store для гибких атрибутов; одна таблица вместо сложной схемы.
- ->> всегда возвращает TEXT; никогда не делай числовые сравнения без ::numeric или ::int.
- UUID v4 для безопасности и малого объёма вставок; UUID v7 или BIGSERIAL для высоконагруженных insert-heavy таблиц.
- Общий GIN-индекс покрывает @>, ?, ?|; для range-запросов по конкретному ключу нужен индекс на выражение.
- jsonb_set для атомарного обновления ключа; оператор || для shallow merge двух объектов.
- Диапазонные типы с EXCLUDE USING GIST — надёжная защита от double-booking на уровне БД.
Итог
PostgreSQL — не просто реляционная БД. JSONB, массивы, UUID и диапазонные типы дают гибкость document store и специализированных баз данных без потери ACID-гарантий, транзакций и мощи SQL. Знание этих типов позволяет решать реальные задачи проще и быстрее.