Массивы и enum: когда каждый бьёт строку
Нативные массивы (ANY, unnest, @> с GIN) и enum (упорядоченные, компактные) мощны на верной границе — но боль ALTER у enum и враждебность массивов к джойнам решают, когда побеждает lookup-таблица.
Команда задала status как enum с тремя значениями, выкатила, а через полгода продукт попросил четвёртое значение — между двумя существующими. Добавить в конец — одна строка; вставить в середину, а позже вообще удалить значение — это превратилось в многочасовую миграцию, переписавшую зависимое представление и функцию. Тем временем другая команда взяла text[] для «ролей» и обнаружила, что не может поставить внешний ключ на id ролей. Оба типа отличны — на границе, где их компромиссы это фичи, а не сюрпризы. Этот урок о том, как найти эту границу.
Нативные массивы: настоящий первоклассный тип
Массивы Postgres — не хак: любой тип может быть массивом (int[], text[], даже jsonb[]), с синтаксисом литерала и богатым набором операторов. Они блестят для маленького, упорядоченного, однотабличного списка, к которому ты никогда не джойнишь: теги строки, набор флагов, путь.
CREATE TABLE products (
id bigint PRIMARY KEY,
tags text[] NOT NULL DEFAULT '{}' -- литерал массива: пустой массив
);
INSERT INTO products (id, tags) VALUES (1, '{sale,featured,new}');
SELECT * FROM products WHERE 'sale' = ANY(tags); -- проверка вхождения
SELECT * FROM products WHERE tags @> '{sale,new}'; -- содержит ВСЕ из этих
SELECT id, unnest(tags) AS tag FROM products; -- развернуть в строки
SELECT array_agg(id) FROM products WHERE 'sale' = ANY(tags); -- свернуть в массивКлючевые операторы: ANY(arr) / ALL(arr) для «совпадает с каким-то / каждым элементом», @> containment («содержит все из»), unnest() развернуть массив в строки и array_agg() свернуть строки обратно в массив. И массивы индексируемы — GIN (Generalized Inverted Index, обобщённый инвертированный индекс) на text[] делает запросы containment @> и = ANY быстрыми, ровно как у JSONB.
CREATE INDEX ON products USING gin (tags); -- теперь @> и = ANY используют индексКогда ловишь себя на том, что хочешь сохранить список связанных id в массиве, — сделай паузу здесь. Загвоздка, решающая всё: нельзя поставить внешний ключ на элемент массива. Поле tag_id int[] может держать id, которых нет ни в одной таблице tags, и Postgres не остановит это. Массивы меняют ссылочную целостность и производительность джойнов на локальность и простоту. Отлично для самодостаточного списка; неверно для связи.
Enum: упорядоченный и компактный
enum — кастомный тип с фиксированным, упорядоченным набором меток-значений. Он компактен (хранится как 4-байтовый oid, а не полный текст), и порядок осмыслен — сравнения следуют порядку объявления, что идеально для вещей вроде степени серьёзности или стадий workflow.
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'delivered');
CREATE TABLE orders (
id bigint PRIMARY KEY,
status order_status NOT NULL DEFAULT 'pending'
);
-- упорядочивание следует порядку объявления, не алфавиту:
SELECT * FROM orders WHERE status >= 'shipped'; -- shipped + deliveredЭто упорядочивание — суперсила enum над CHECK (status IN (...)) на текстовой колонке: произвольные строки нельзя естественно упорядочить по стадии workflow, а сравнения enum просто работают.
Боль ALTER у enum
Вот компромисс, кусающий в проде. ADD VALUE в enum прост и онлайн в современном Postgres:
ALTER TYPE order_status ADD VALUE 'returned'; -- в конец, нормально
ALTER TYPE order_status ADD VALUE 'cancelled' BEFORE 'paid'; -- с позицией, тоже нормальноНо нельзя удалить или переименовать-прочь значение без фактической пересборки типа: создать новый enum, изменить каждую колонку, использующую его, удалить старый тип и пересоздать зависимые представления и функции — настоящая миграция, не однострочник. Enum — храповик в одну сторону: легко добавлять, больно сжимать. Если твой набор значений действительно фиксирован на жизнь системы (уровни серьёзности, группы крови), компактность и порядок enum — чистый выигрыш. Если он churn’ит — категории, которые бизнес постоянно переустраивает, — жёсткость становится повторяющимся налогом.
Enum против CHECK против lookup-таблицы
Три способа смоделировать «колонку из небольшого набора», на спектре от жёстко-и-быстро до гибко-и-реляционно:
- Enum — набор значений фиксирован и порядок важен. Компактно, быстро, самодокументируемо; боль ALTER при удалении.
- CHECK (
status IN ('a','b')) на колонкеtext— маленький набор, порядок не нужен, легко править (просто измени ограничение). Без порядка, чуть больше хранения, без метаданных. - Lookup-таблица (
statuses(code text PK, label text, sort_order int, is_active bool)) с внешним ключом — реляционный ответ, когда набор развивается, нужны метаданные или нужен внешний ключ. Платишь джойном, но можешь добавлять/удалять/переименовывать значения обычнымиINSERT/UPDATE/DELETEи прикрепить сколько угодно атрибутов.
Сеньорская эвристика: фиксировано + упорядочено → enum; маленькое + гибкое → CHECK; развивается или нужны метаданные → lookup-таблица. Дефолт на lookup-таблицу редко неверен; дефолт на enum для churn’ящего набора — это будущая миграция, которую ты сам себе запланировал.
▸Частая ошибка
Классическое злоупотребление массивами: хранение tag_ids int[] для моделирования товар-к-тегу. Кажется опрятным, но нельзя поставить внешний ключ на id (заводятся сироты), нельзя эффективно ответить «у каких товаров этот тег» без GIN-индекса, да и с ним это неуклюжее, чем джойн, а добавление одного тега переписывает весь массив (и строку). Реляционный ответ — джойн-таблица product_tags(product_id, tag_id) — даёт целостность, двусторонние джойны и дешёвые однострочные вставки. Резервируй массивы для списков, которые ты никогда не связываешь с другими таблицами.
Небольшой набор значений развивается и теперь нужны метка для показа и порядок сортировки. Упорядочь шаги перехода с enum на lookup-таблицу:
- 1 Создай lookup-таблицу: statuses(code text PRIMARY KEY, label text, sort_order int)
- 2 Засей её текущими значениями enum плюс новые колонки метаданных
- 3 Добавь в orders колонку status_code text с FK на statuses(code)
- 4 Backfill status_code из существующей enum-колонки
- 5 Переключи чтения/записи на status_code, затем удали старую enum-колонку и тип
Почему массив tag_id int[] — плохой способ смоделировать связь многие-ко-многим товар-к-тегу?
Набор статусов постоянно меняется — значения добавляются, переупорядочиваются, иногда удаляются, и продукт хочет метку для показа на значение. Лучшая модель?
- 01Каковы ключевые операторы массивов и что массивы не могут, что решает против них для связей?
- 02Что легко и что больно в изменении enum и что это значит для моделирования?
- 03Дай эвристику enum против CHECK против lookup-таблицы.
Массивы Postgres — настоящий первоклассный тип — любой тип элемента, с вхождением = ANY/ALL, containment @>, unnest, array_agg и GIN-индексацией — идеальны для маленького, самодостаточного списка на строку, к которому ты никогда не джойнишь. Их жёсткий предел — элемент массива не может нести внешний ключ, так что массив id тихо допускает сирот и неверен для связи; многие-ко-многим — всегда джойн-таблица. Enum компактны (4-байтовый oid) и упорядочены (сравнения следуют порядку объявления, бьют текстовый CHECK, когда важен порядок стадий), но это храповик в одну сторону: ADD VALUE прост и онлайн, а удаление значения — болезненная пересборка типа, тянущая за собой зависимые представления и функции. Так что моделируй небольшой набор значений осознанно — фиксированное и упорядоченное благоволит enum, маленькое и гибкое — CHECK, а развивающийся набор или такой, что нуждается в метаданных, — lookup-таблице с внешним ключом, где добавление/удаление/переименование — обычный DML (Data Manipulation Language: INSERT/UPDATE/DELETE). Теперь, когда увидишь колонку tag_ids int[] или enum для набора значений, который продукт продолжает пересматривать — ты будешь знать, какой инструмент несёт скрытую цену и к чему тянуться вместо него.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.