Learning Platform
Урок 12.02 · 19 мин
Средний
PRIMARY KEYFOREIGN KEYCHECKUNIQUENOT NULLON DELETE CASCADEReferential integrity

Что такое constraint и зачем он нужен

Ограничение целостности
— это правило, которое СУБД проверяет на каждой записи. Если правило нарушено, операция падает. Constraint живёт в схеме базы, а значит работает для всех клиентов: backend-сервиса, миграции, человека через psql, ETL-скрипта.

В этом уроке разберём пять типов ограничений и поймём, какие из них стоит обязательно дублировать в приложении, а какие — наоборот, делегировать базе и убрать из кода.

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

Самое простое. Запрещает класть NULL в колонку.

CREATE TABLE t (
  required_field TEXT NOT NULL
);

Семантика: «у каждой строки в этой колонке должно быть конкретное значение». Если приложение делает INSERT без этой колонки и у неё нет DEFAULT — Postgres откажет.

Когда NOT NULL критичен:

  • FK-колонки, где «нет родителя» бессмысленно (order.customer_id).
  • Timestamps вроде created_at.
  • Поля, на которых строится бизнес-логика и NULL-проверки утомительны.

UNIQUE — запрет на дубликаты

UNIQUE гарантирует, что значения в колонке (или комбинации колонок) не повторяются. Под капотом Postgres создаёт уникальный B-tree индекс, который и обеспечивает гарантию, и попутно ускоряет поиск.

CREATE TABLE users (
  id    INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email TEXT NOT NULL UNIQUE
);

-- Многоколоночный UNIQUE — пара (a, b) уникальна
CREATE TABLE memberships (
  user_id INT NOT NULL,
  group_id INT NOT NULL,
  UNIQUE (user_id, group_id)
);

Главная ловушка UNIQUE: по стандарту SQL NULL = NULL это NULL, а не TRUE. Значит, две строки с NULL в одной и той же колонке не считаются дубликатами, и UNIQUE пустит их обе.

С Postgres 15 можно явно изменить это: UNIQUE NULLS NOT DISTINCT (email) — теперь NULL считается равным себе же, и второй NULL уже не пройдёт.

PRIMARY KEY — главный идентификатор

Под капотом PRIMARY KEY это NOT NULL UNIQUE плюс автоматический индекс плюс пометка «это главный ключ таблицы». На таблицу допустим только один PRIMARY KEY.

-- Простой PK
CREATE TABLE products (
  id  INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  sku TEXT NOT NULL UNIQUE
);

-- Составной PK — два поля вместе образуют ключ
CREATE TABLE order_items (
  order_id   INT NOT NULL,
  product_id INT NOT NULL,
  qty        INT NOT NULL,
  PRIMARY KEY (order_id, product_id)
);

Составные ключи — норма для таблиц связи «многие ко многим». Если у вас есть отдельная сущность «позиция в заказе» с собственной жизнью (история изменений, ссылки извне) — добавляйте суррогатный id. Если позиция полностью определена парой (order_id, product_id) — этой пары как PK достаточно.

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

Ссылочная целостность
— самое мощное ограничение в реляционной модели. FOREIGN KEY говорит: «значения в этой колонке должны существовать в другой таблице».

CREATE TABLE orders (
  id          INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id INT NOT NULL REFERENCES customers(id)
);

Что Postgres проверяет:

  1. На INSERT/UPDATE в orders: значение customer_id должно существовать в customers(id) (или быть NULL, если колонка nullable).
  2. На DELETE/UPDATE в customers: если кто-то ссылается на удаляемую строку — нужно решить, что делать. По умолчанию операция падает.

Пять режимов ON DELETE / ON UPDATE

Что делать с дочерними строками, когда родителя удаляют или меняют его id:

Режимы ON DELETE / ON UPDATE

Пять стандартных реакций FK на удаление/изменение родителя. NO ACTION — поведение по умолчанию.

NO ACTION (default)ошибка: violates foreign key constraintПроверка откладывается до конца транзакции (если DEFERRABLE). Самый безопасный режим.
RESTRICTошибка немедленно, без отложенной проверкиПохоже на NO ACTION, но проверка происходит сразу — не отложишь через SET CONSTRAINTS DEFERRED.
CASCADEдочерние строки удаляются/обновляются автоматическиОпасно при глубоких иерархиях — одно DELETE может снести половину базы. Используйте, когда уверены в инвариантах.
SET NULLдочерние FK-колонки заменяются на NULLТребует, чтобы FK-колонка была nullable. Подходит для слабых связей: 'был такой автор, потом ушёл'.
SET DEFAULTдочерние FK-колонки заменяются на свой DEFAULTРедкий режим. Полезно если есть строка-«заглушка» вроде customer_id=0 'удалённый клиент'.

Как выбирать:

  • CASCADE — когда дочерние записи бессмысленны без родителя. Удалили заказ — order_items должны исчезнуть. Удалили пользователя — его сессии и токены не нужны.
  • RESTRICT / NO ACTION — когда удаление родителя должно быть осознанным. Удалить категорию, в которой есть товары, нельзя — сначала разберитесь с товарами.
  • SET NULL — когда дочерняя сущность переживёт смерть родителя. Удалили автора — статьи остаются, но author_id становится NULL.

Сравни поведение CASCADE и RESTRICT

PostgreSQL

Производительность FK

Маленький, но важный нюанс: FK сам по себе не создаёт индекс на колонке-ссылке. Postgres строит индекс только на стороне родителя (customers.id уже под PK). А вот orders(customer_id) остаётся без индекса, если не создать его руками. Это приводит к двум проблемам:

  1. JOIN customers + orders идёт через sequential scan по orders.
  2. DELETE FROM customers WHERE id = 5 запускает проверку «есть ли заказы у этого клиента» — без индекса это full scan.

Правило: на каждую FOREIGN KEY колонку обычно нужен явный индекс.

CHECK — произвольные правила на значения

CHECK проверяет произвольный булев предикат. Это «всё остальное» — то, что не выражается через NOT NULL, UNIQUE, FOREIGN KEY.

CREATE TABLE products (
  id          INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  price_cents INT NOT NULL CHECK (price_cents > 0),
  discount    NUMERIC(3, 2) CHECK (discount BETWEEN 0 AND 1),
  status      TEXT NOT NULL CHECK (status IN ('draft','active','archived'))
);

Многоколоночные CHECK (на уровне таблицы):

CREATE TABLE bookings (
  id         INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  starts_at  TIMESTAMPTZ NOT NULL,
  ends_at    TIMESTAMPTZ NOT NULL,
  CHECK (ends_at > starts_at)
);

Ограничения CHECK:

  • Предикат должен быть детерминированным. Не используйте now(), random(), обращения к другим таблицам. (Для «значения, зависящего от других таблиц» есть FOREIGN KEY и триггеры.)
  • CHECK пропускает строку, если предикат вернул NULL или TRUE. То есть CHECK (x > 0) пустит строку с x = NULL. Если такого быть не должно — добавьте NOT NULL.

CHECK и его ловушка с NULL

PostgreSQL

Constraint vs application-level validation

Большой философский вопрос: какие проверки делать в базе, а какие в коде приложения?

Constraint в БД — гарантия для всех клиентов. Даже если завтра придёт стажёр и напишет миграцию, обходящую ваш ORM, ограничение его остановит. Минусы: ошибка приходит «снизу», с не очень дружелюбным текстом; некоторые правила в SQL выражать неудобно.

Валидация в приложении — гибче и даёт лучшие сообщения пользователю. Минусы: можно обойти; разработчики из другой команды могут не знать ваших правил.

Эмпирическое правило, которое работает:

ЧтоГде
FK, PK, UNIQUEобязательно в БД
NOT NULL для критичных полейобязательно в БД
Простые инварианты (price > 0, статус из enum)в БД через CHECK, в приложении дублируется для UX
Сложная бизнес-логика («у этого тарифа максимум 5 проектов»)в приложении; в БД только если можно выразить через UNIQUE/CHECK
Кросс-табличные правилав приложении или через триггер

Главный принцип: критичные инварианты — всегда в БД. Удобство и UX — в приложении.

Referential integrity: внешние ключи и ON DELETE/UPDATE Уровни блокировок в PostgreSQL

Чек-лист

  • NOT NULL, UNIQUE, PRIMARY KEY, FOREIGN KEY, CHECK — пять стандартных ограничений целостности в Postgres.
  • PRIMARY KEY = NOT NULL + UNIQUE + индекс + «главный ключ». Один на таблицу.
  • У FOREIGN KEY пять вариантов ON DELETE/UPDATE: NO ACTION, RESTRICT, CASCADE, SET NULL, SET DEFAULT. Выбор зависит от того, может ли дочерняя сущность пережить смерть родителя.
  • FK не создаёт индекс на дочерней колонке — почти всегда нужен явный CREATE INDEX.
  • CHECK принимает любой детерминированный предикат. Главная ловушка — NULL не валит CHECK, нужен отдельный NOT NULL.
  • Критичные инварианты (ссылочная целостность, уникальность, обязательность) — всегда в БД. Сложная бизнес-логика — в приложении.
Проверка знанийKnowledge check
У тебя есть таблица invoices (id, customer_id REFERENCES customers ON DELETE RESTRICT, amount_cents). Команда продукта хочет «удалять клиентов, но историю их счетов сохранять». Какой ON DELETE-режим выбрать и что ещё нужно изменить?
ОтветAnswer
ON DELETE RESTRICT не даст удалить клиента, у которого есть счета — это противоречит требованию. Нужен либо ON DELETE SET NULL (счета остаются, но customer_id у них становится NULL — теряется информация о владельце), либо ON DELETE NO ACTION при условии что вы вообще не удаляете клиентов физически, а помечаете их как deleted_at. Чаще правильный путь — soft delete: добавить customers.deleted_at TIMESTAMPTZ и никогда не делать физический DELETE; тогда ссылочная целостность сохраняется естественно. Если всё-таки физическое удаление — SET NULL + invoices.customer_id должна быть nullable.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. У таблицы orders есть FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT. Что произойдёт при DELETE FROM customers WHERE id = 5, если у клиента 5 есть три заказа?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6