open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 06 · 04

Массивы и enum: когда каждый бьёт строку

Нативные массивы (ANY, unnest, @> с GIN) и enum (упорядоченные, компактные) мощны на верной границе — но боль ALTER у enum и враждебность массивов к джойнам решают, когда побеждает lookup-таблица.

SQL Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Команда задала 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. 1 Создай lookup-таблицу: statuses(code text PRIMARY KEY, label text, sort_order int)
  2. 2 Засей её текущими значениями enum плюс новые колонки метаданных
  3. 3 Добавь в orders колонку status_code text с FK на statuses(code)
  4. 4 Backfill status_code из существующей enum-колонки
  5. 5 Переключи чтения/записи на status_code, затем удали старую enum-колонку и тип
Викторина

Почему массив tag_id int[] — плохой способ смоделировать связь многие-ко-многим товар-к-тегу?

Викторина

Набор статусов постоянно меняется — значения добавляются, переупорядочиваются, иногда удаляются, и продукт хочет метку для показа на значение. Лучшая модель?

Вспомните перед уходом
  1. 01
    Каковы ключевые операторы массивов и что массивы не могут, что решает против них для связей?
  2. 02
    Что легко и что больно в изменении enum и что это значит для моделирования?
  3. 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-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.