Средний ~50 мин чтения

Продвинутые типы данных: JSONB, Array, UUID

Урок 2 из 10 в курсе PostgreSQL для backend-разработчика

Продвинутые типы данных: 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, сохраняя глобальную уникальность.

Как это выглядит на практике

  1. Создай таблицу 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.
  2. Вставь товары с разными схемами атрибутов: 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"}');. Оба товара в одной таблице с разными атрибутами.
  3. Создай GIN-индекс для быстрого поиска по JSONB: CREATE INDEX idx_products_attributes ON products USING GIN (attributes);. После этого поиск WHERE attributes @> '{"color": "white"}' использует индекс вместо sequential scan.
  4. Используй операторы JSONB: SELECT name, attributes->>'color' AS color, (attributes->>'storage_gb')::int AS storage FROM products WHERE attributes @> '{"color": "titanium"}';. Оператор -> возвращает JSONB, ->> возвращает TEXT. Для числовых операций обязательно приведение типа.
  5. Обнови конкретный ключ в JSONB: UPDATE products SET attributes = jsonb_set(attributes, '{price_usd}', '999') WHERE id = 1;. jsonb_set не перезаписывает весь объект — только обновляет указанный путь. Это безопасно для конкурентных обновлений разных ключей.
  6. Используй массивы для тегов: ALTER TABLE products ADD COLUMN tags TEXT[] DEFAULT '{}'; UPDATE products SET tags = ARRAY['новинка', 'хит продаж'] WHERE id = 1; SELECT * FROM products WHERE 'хит продаж' = ANY(tags);. Оператор ANY проверяет вхождение элемента.
  7. Создай таблицу с 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.
  8. Используй диапазонные типы для временных слотов: 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.
  9. Проверь пересечение диапазонов для предотвращения двойного бронирования: SELECT * FROM booking_slots WHERE resource_id = 42 AND during && '[2024-06-01 10:30, 2024-06-01 11:30)'::tstzrange;. Оператор && проверяет пересечение — возвращает все слоты, конфликтующие с запрошенным.
  10. Индексируй диапазонный тип через 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 здесь не подойдёт.
  11. Разверни массив в строки для аналитики: SELECT unnest(tags) AS tag, count(*) FROM products GROUP BY tag ORDER BY count DESC LIMIT 10;. unnest() разворачивает массив в набор строк — каждый тег становится отдельной строкой для агрегации.
  12. Используй 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 не покрывает такие запросы.

Термины урока

JSONB: бинарное JSON-хранилище PostgreSQL с поддержкой GIN-индексов, операторов containment, path-запросов и атомарных обновлений ключей.
GIN (Generalized Inverted Index): инвертированный индекс для составных типов — JSONB, массивы, tsvector; хранит mapping значение→список строк для быстрого поиска.
Array: встроенный тип PostgreSQL для одно- и многомерных массивов любого базового типа; поддерживает операторы @>, &&, ANY/ALL и функции unnest, array_agg.
UUID: 128-битный стандартизированный тип для глобально уникальных идентификаторов; v4 — случайный, v7 — time-ordered.
@> (containment): оператор проверки вхождения для JSONB и массивов; '{"a":1}' @> '{"a":1}' — true; используется GIN-индексом.
unnest(): set-returning функция, разворачивающая массив или JSONB-массив в набор строк; используется с GROUP BY для агрегации по элементам.
jsonb_set(target, path, new_value): функция для точечного обновления значения по пути в JSONB-объекте без перезаписи всего документа.
Range types: встроенные типы для диапазонов значений (daterange, tstzrange, int4range); поддерживают операторы пересечения, содержания и смежности.
EXCLUDE USING GIST: ограничение исключения, гарантирующее через GiST-индекс отсутствие строк, одновременно удовлетворяющих двум условиям (например, нет двух бронирований одной комнаты на пересекающиеся даты).
jsonb_path_query: функция SQL/JSON path (PostgreSQL 12+) для complex queries внутри JSONB-структур, поддерживающая фильтры, рекурсивный спуск и методы.

Связь с работой 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. Знание этих типов позволяет решать реальные задачи проще и быстрее.