Ограничения (constraints)

Предпосылки: реляционная модель (первичный ключ, внешний ключ, ссылочная целостность), таблицы и типы (CREATE TABLE, типы данных, DEFAULT), типы данных и NULL.

Таблицы и типы | Партиционирование

Три микросервиса пишут в одну таблицу orders. Один вставляет заказы с отрицательной суммой, другой — заказы без пользователя, третий — дубликаты по external_id. Валидация на уровне приложения не спасла: один сервис обновили, в другом забыли проверку, третий вставлял через ручной SQL.

Ограничения (constraints, англ. «ограничения, условия») решают эту проблему на уровне данных: база сама удерживает инварианты, на которые опирается код.

NOT NULL — запрет отсутствия значения

CREATE TABLE users (
    id    integer,
    name  text NOT NULL,
    email text NOT NULL
);

INSERT с NULL в name или email вернёт ошибку. NOT NULL — самый дешёвый способ гарантировать наличие обязательных данных.

PRIMARY KEY — уникальный идентификатор строки

PRIMARY KEY (англ. «первичный ключ») = UNIQUE + NOT NULL. Гарантирует, что каждая строка имеет уникальный, непустой идентификатор:

CREATE TABLE users (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    email TEXT NOT NULL
);

PostgreSQL автоматически создаёт уникальный индекс для PRIMARY KEY. Почти каждая таблица должна иметь PRIMARY KEY. Подробнее о индексах и их роли в производительности см. в соответствующей заметке.

UNIQUE — уникальность значений

CREATE TABLE users (
    id    BIGINT PRIMARY KEY,
    email TEXT NOT NULL UNIQUE
);

UNIQUE запрещает дубликаты в столбце (или комбинации столбцов). PostgreSQL реализует UNIQUE через уникальный индекс. Подробнее о индексах.

UNIQUE и NULL

UNIQUE не запрещает несколько NULL — потому что NULL = NULL в SQL возвращает NULL, не TRUE (трёхзначная логика), и два NULL не считаются дубликатами:

CREATE TABLE emails (email TEXT UNIQUE);
INSERT INTO emails(email) VALUES (NULL), (NULL);  -- оба вставятся

Если нужен «уникальный и не пустой» — это всегда связка NOT NULL UNIQUE.

Частичная уникальность

Уникальность только среди активных записей (soft delete):

CREATE UNIQUE INDEX users_email_active_uq
ON users(email)
WHERE deleted_at IS NULL;

Это частичный уникальный индекс — не CONSTRAINT, но выполняет ту же работу.

FOREIGN KEY — ссылочная целостность

FOREIGN KEY (англ. «внешний ключ») гарантирует, что ссылка указывает на существующую строку:

CREATE TABLE orders (
    id      BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id)
);

Заказ не может ссылаться на несуществующего пользователя. При попытке вставить user_id, отсутствующий в users — ошибка.

Индекс на дочерней стороне FK

Для корректности FK достаточно индекса на родительской стороне (users(id)) — он есть из PRIMARY KEY. Но для производительности почти всегда нужен индекс на дочерней стороне:

CREATE INDEX orders_user_id_idx ON orders(user_id);

Без него удаление или обновление users.id вызывает полный скан orders для проверки ссылок.

ON DELETE / ON UPDATE — каскадные действия

Поведение при удалении/изменении родительской строки:

CREATE TABLE orders (
    id      BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE
);
  • NO ACTION (по умолчанию) — запретить, если есть ссылки. Проверка откладывается до конца оператора, поэтому совместим с DEFERRABLE.
  • RESTRICT — запретить немедленно, до завершения оператора. Не совместим с DEFERRABLE.
  • CASCADE (англ. «каскад, цепная реакция») — удалить/обновить дочерние строки автоматически.
  • SET NULL — разорвать ссылку (поставить NULL).
  • SET DEFAULT — поставить значение по умолчанию.

CASCADE удобен, но опасен: одно удаление может превратиться в тысячи DELETE.

CHECK — произвольное условие на строку

CHECK (англ. «проверка») проверяет условие при INSERT и UPDATE:

CREATE TABLE payments (
    id           BIGINT PRIMARY KEY,
    amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0),
    currency     TEXT NOT NULL CHECK (currency IN ('USD', 'EUR', 'RUB'))
);

CHECK — самый дешёвый способ закрепить доменные правила прямо в данных.

CHECK и NULL

CHECK не отвергает NULL: если условие вернуло NULL (а не FALSE), строка пропускается. Для столбца без NOT NULL:

CREATE TABLE t (x integer CHECK (x > 0));
INSERT INTO t(x) VALUES (NULL);  -- пройдёт! NULL > 0 --> NULL, не FALSE

Если NULL недопустим — добавьте NOT NULL.

Для инвариантов сложнее равенства (запрет пересечений по времени, перекрытий по диапазонам) PostgreSQL добавляет EXCLUSION constraint.

DEFERRABLE — отложенная проверка

По умолчанию ограничения проверяются немедленно на каждом операторе. DEFERRABLE позволяет отложить проверку до COMMIT:

ALTER TABLE orders
ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users(id)
DEFERRABLE INITIALLY DEFERRED;

Удобно при переносе данных, когда порядок вставки не совпадает с порядком зависимостей. Цена: ошибка обнаруживается не на INSERT, а на COMMIT.

Практические правила

  1. Почти у каждой таблицы — PRIMARY KEY.
  2. У каждого FOREIGN KEY — индекс на дочерней стороне.
  3. UNIQUE без NOT NULL редко делает то, что кажется.
  4. CHECK — для простых доменных правил.
  5. Ограничения стоят I/O и создают ожидания блокировок между транзакциями — это нормальная цена за корректность.
  6. Добавление ограничений на таблицу с данными и трафиком требует осторожности — безопасные паттерны.

Sources


Таблицы и типы | Партиционирование