Tento článok je momentálne v ruštine — anglický preklad sa pripravuje.
Когда мы связываем таблицы через внешний ключ, база данных не просто «запоминает», что одна таблица зависит от другой. Она ещё должна понимать, что делать, если родительскую запись удалят.
Например, у нас есть пользователь и его заказы. Пользователь хранится в таблице users, а заказы — в таблице orders. Каждый заказ ссылается на конкретного пользователя через user_id.
И вот вопрос: что должно произойти с заказами, если удалить пользователя?
Вариантов несколько:
- удалить заказы вместе с пользователем;
- оставить заказы, но убрать из них ссылку на пользователя;
- запретить удаление пользователя, пока у него есть заказы;
- отложить проверку до конца транзакции.
За это поведение отвечает часть внешнего ключа ON DELETE.
Базовый пример: пользователи и заказы
Представим простой интернет-магазин. У нас есть пользователи:
CREATE TABLE users (
id bigint PRIMARY KEY,
email text UNIQUE NOT NULL,
name text,
country text,
created_at timestamptz DEFAULT now()
);
И есть заказы:
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(12,2),
status text,
created_at timestamptz DEFAULT now(),
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE
);
Здесь поле orders.user_id ссылается на users.id.
Это значит: в таблице orders нельзя создать заказ с user_id, которого нет в таблице users.
Например, если пользователя с id = 7 не существует, такой запрос упадёт с ошибкой:
INSERT INTO orders (id, user_id, amount, status)
VALUES (1001, 7, 2500, 'new');
База скажет: «Нельзя создать заказ для пользователя, которого нет».
И это хорошо. Так мы защищаем данные от мусора и случайных ошибок.
Но внешний ключ проверяет не только вставку. Он ещё участвует в удалении.
Что произойдёт при удалении родителя
Допустим, у пользователя есть заказы:
SELECT *
FROM orders
WHERE user_id = 7;
А потом мы удаляем самого пользователя:
DELETE FROM users
WHERE id = 7;
Что теперь делать с его заказами?
Они ведь больше не смогут ссылаться на пользователя 7, потому что такого пользователя уже нет. Получается «висящая ссылка»: заказ указывает туда, где записи больше не существует.
Чтобы этого не было, база использует правило ON DELETE.
В нашем примере указано:
ON DELETE CASCADE
Это значит: если удалить пользователя, база автоматически удалит и все его заказы.
То есть один запрос:
DELETE FROM users
WHERE id = 7;
на самом деле приведёт к двум последствиям:
- пользователь
7 будет удалён из users;
- все его заказы будут удалены из
orders.
Именно это называется каскадным удалением.
ON DELETE CASCADE: удалить дочерние строки вместе с родителем
CASCADE означает: «если удаляем родителя, удаляем и всё, что от него зависит».
Пример:
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(12,2),
status text,
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE
);
Теперь удаление пользователя автоматически удалит его заказы:
DELETE FROM users
WHERE id = 7;
Это удобно, когда дочерние данные не имеют смысла без родительской записи.
Хороший пример — позиции заказа.
Допустим, есть таблица orders и таблица order_items:
CREATE TABLE order_items (
id bigint PRIMARY KEY,
order_id bigint NOT NULL,
product_id bigint NOT NULL,
quantity int NOT NULL,
CONSTRAINT fk_order_items_order
FOREIGN KEY (order_id)
REFERENCES orders(id)
ON DELETE CASCADE
);
Позиция заказа без самого заказа обычно не нужна. Если мы удаляем заказ, логично удалить и его позиции.
DELETE FROM orders
WHERE id = 1001;
После этого все строки из order_items, связанные с заказом 1001, тоже удалятся.
Вот здесь CASCADE выглядит естественно.
Где CASCADE может быть опасен
ON DELETE CASCADE выглядит очень соблазнительно: база сама всё подчистит, не надо писать дополнительные запросы, не надо думать о зависимых строках.
Но именно поэтому он опасен.
Представьте такой запрос:
DELETE FROM users
WHERE country = 'RU';
На первый взгляд мы просто удаляем пользователей из России. Но если у таблицы orders стоит ON DELETE CASCADE, вместе с пользователями удалятся и все их заказы.
А если у заказов есть позиции заказа с ON DELETE CASCADE, то удалятся ещё и они.
Получается цепочка:
users
-> orders
-> order_items
Один запрос может снести сразу несколько уровней данных.
На учебных примерах это выглядит безобидно. В реальной базе это может означать потерю финансовой истории, аналитики, чеков, аудита и данных, которые бизнес вообще не собирался удалять.
Поэтому важное правило:
CASCADE подходит для технически зависимых данных, но плохо подходит для ценных бизнес-сущностей.
Например:
- позиции заказа можно удалить вместе с заказом;
- временные токены можно удалить вместе с пользователем;
- черновики или настройки можно удалить вместе с аккаунтом.
Но вот заказы, платежи, документы, транзакции и история действий часто должны жить дольше пользователя. Даже если пользователь удалил аккаунт, бизнесу может быть нужно хранить историю покупок, возвратов, оплат и налоговых документов.
Для таких случаев лучше использовать RESTRICT, SET NULL или мягкое удаление через поле deleted_at.
ON DELETE SET NULL: оставить строку, но убрать ссылку
Иногда дочерняя запись должна остаться, даже если родитель удалён.
Например, у нас есть заказы. Пользователь удалил аккаунт, но сам заказ всё ещё важен: по нему была оплата, доставка, возврат, отчётность.
В такой ситуации можно сделать так, чтобы при удалении пользователя заказ остался, но ссылка на пользователя стала NULL.
Для этого используется:
ON DELETE SET NULL
Пример:
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint,
amount numeric(12,2),
status text,
created_at timestamptz DEFAULT now(),
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE SET NULL
);
Обратите внимание: здесь user_id уже не NOT NULL.
Это важно.
Если мы хотим, чтобы база могла записать в user_id значение NULL, колонка должна разрешать NULL.
Теперь при удалении пользователя:
DELETE FROM users
WHERE id = 7;
заказы не удалятся. Они останутся в таблице, но поле user_id станет NULL.
Условно было так:
| id |
user_id |
amount |
status |
| 1001 |
7 |
2500.00 |
paid |
| 1002 |
7 |
1100.00 |
shipped |
Станет так:
| id |
user_id |
amount |
status |
| 1001 |
NULL |
2500.00 |
paid |
| 1002 |
NULL |
1100.00 |
shipped |
Смысл такой: «заказы существуют, но пользователь, к которому они были привязаны, удалён».
Как изменить CASCADE на SET NULL
Допустим, сначала мы создали внешний ключ с CASCADE, а потом поняли, что заказы удалять нельзя. Их нужно оставлять, просто очищая ссылку на пользователя.
Тогда можно изменить ограничение.
Сначала нужно разрешить NULL в колонке user_id:
ALTER TABLE orders
ALTER COLUMN user_id DROP NOT NULL;
Потом удалить старый внешний ключ и создать новый:
ALTER TABLE orders
DROP CONSTRAINT fk_orders_user;
ALTER TABLE orders
ADD CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE SET NULL;
Теперь удаление пользователя не будет удалять заказы.
ON DELETE RESTRICT: запретить удаление, если есть зависимые строки
RESTRICT работает строже.
Он говорит базе: «нельзя удалить родительскую запись, если на неё кто-то ссылается».
Пример:
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(12,2),
status text,
created_at timestamptz DEFAULT now(),
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE RESTRICT
);
Теперь, если у пользователя есть хотя бы один заказ, такой запрос упадёт:
DELETE FROM users
WHERE id = 7;
База не даст удалить пользователя, потому что в orders есть строки, которые на него ссылаются.
Это хороший вариант для данных, которые нельзя случайно потерять.
Например:
- нельзя удалить автора, если у него есть опубликованные статьи;
- нельзя удалить категорию, если в ней есть товары;
- нельзя удалить клиента, если по нему есть финансовые документы;
- нельзя удалить склад, если на нём числятся остатки.
RESTRICT заставляет сначала разобраться с зависимыми данными. Например, перенести заказы, архивировать пользователя, закрыть документы или явно удалить связанные строки.
Это менее удобно, чем CASCADE, зато намного безопаснее.
А что будет, если ON DELETE вообще не указать?
Если не написать ON DELETE, поведение по умолчанию обычно будет таким: база не позволит удалить родительскую запись, пока на неё есть ссылки.
Например:
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
CONSTRAINT fk_orders_user
FOREIGN KEY (user_id)
REFERENCES users(id)
);
Если у пользователя есть заказы, удаление пользователя завершится ошибкой:
DELETE FROM users
WHERE id = 7;
То есть база защитит нас от появления «висящих» ссылок.
В PostgreSQL действие по умолчанию — NO ACTION.
На практике для обычного разработчика NO ACTION часто выглядит почти как RESTRICT: удалить родителя нельзя, если есть дочерние строки.
Разница проявляется в более продвинутых сценариях с отложенными проверками внутри транзакций. Для новичка главное запомнить так:
если ON DELETE не указан, база не будет автоматически удалять или обнулять дочерние строки.
Она скорее запретит удаление, если есть зависимые данные.
RESTRICT и NO ACTION: в чём разница
RESTRICT и NO ACTION действительно похожи. Оба варианта защищают данные и не дают удалить родителя, если на него есть ссылки.
Но есть тонкое отличие.
RESTRICT проверяется сразу.
NO ACTION в PostgreSQL может быть отложен до конца транзакции, если внешний ключ объявлен как DEFERRABLE.
Проще говоря:
RESTRICT говорит: «проверяем прямо сейчас»;
NO ACTION может сказать: «проверим позже, перед COMMIT».
В обычных таблицах без DEFERRABLE вы почти не заметите разницы.
DEFERRABLE: когда проверку можно отложить
Иногда нужно внутри одной транзакции временно нарушить порядок ссылок, а к концу транзакции привести всё в нормальное состояние.
Например, мы загружаем данные пачкой. В середине загрузки часть строк уже вставлена, часть ещё нет. На короткий момент ссылка может выглядеть неправильной, но к концу транзакции всё станет корректно.
Для таких случаев в PostgreSQL есть отложенные ограничения:
CREATE TABLE employees (
id bigint PRIMARY KEY,
name text,
manager_id bigint,
dept text,
salary numeric(12,2),
CONSTRAINT fk_emp_manager
FOREIGN KEY (manager_id)
REFERENCES employees(id)
ON DELETE SET NULL
DEFERRABLE INITIALLY DEFERRED
);
Здесь таблица employees ссылается сама на себя: у сотрудника есть руководитель, который тоже является сотрудником.
employees.manager_id -> employees.id
Если удалить руководителя, у его подчинённых manager_id станет NULL.
DELETE FROM employees
WHERE id = 10;
После этого сотрудники, у которых был manager_id = 10, останутся в таблице, но будут уже без руководителя.
DEFERRABLE INITIALLY DEFERRED означает, что проверка ограничения будет выполнена не сразу после каждого отдельного запроса, а ближе к концу транзакции — перед COMMIT.
Это нужно не каждый день, но полезно знать, что такая возможность существует.
Самоссылки: когда таблица ссылается сама на себя
Самоссылка — это внешний ключ, который ведёт не в другую таблицу, а в эту же самую таблицу.
Классический пример — сотрудники и руководители:
CREATE TABLE employees (
id bigint PRIMARY KEY,
name text NOT NULL,
manager_id bigint,
CONSTRAINT fk_emp_manager
FOREIGN KEY (manager_id)
REFERENCES employees(id)
ON DELETE SET NULL
);
Здесь manager_id хранит id руководителя.
Например:
| id |
name |
manager_id |
| 1 |
Анна |
NULL |
| 2 |
Иван |
1 |
| 3 |
Мария |
1 |
| 4 |
Олег |
2 |
Анна — руководитель Ивана и Марии. Иван — руководитель Олега.
Если удалить Анну:
DELETE FROM employees
WHERE id = 1;
у Ивана и Марии manager_id станет NULL.
Они не исчезнут из компании, просто останутся без указанного руководителя.
Это как раз хороший пример для ON DELETE SET NULL.
Индекс на колонке внешнего ключа
В PostgreSQL есть важный момент, о котором часто забывают начинающие.
Когда вы создаёте внешний ключ, PostgreSQL автоматически проверяет, что родительская колонка уникальна или является первичным ключом. Например, users.id уже индексируется, потому что это PRIMARY KEY.
Но колонку orders.user_id PostgreSQL автоматически не индексирует.
То есть вот здесь:
FOREIGN KEY (user_id)
REFERENCES users(id)
индекс на users.id уже есть, а на orders.user_id — нет.
Почему это важно?
Когда вы удаляете пользователя, базе нужно быстро понять, есть ли в таблице orders строки с этим user_id.
Например:
DELETE FROM users
WHERE id = 7;
База должна проверить:
SELECT *
FROM orders
WHERE user_id = 7;
Если таблица orders маленькая, проблемы не будет. Но если там миллионы строк, а индекса по user_id нет, база может просматривать большую часть таблицы.
Поэтому для внешних ключей почти всегда стоит создавать индекс на дочерней колонке:
CREATE INDEX idx_orders_user_id
ON orders (user_id);
Для самоссылки правило такое же:
CREATE INDEX idx_employees_manager_id
ON employees (manager_id);
Индекс особенно важен при CASCADE, SET NULL, RESTRICT и любых частых удалениях или обновлениях родительских строк.
А что насчёт ON UPDATE?
Кроме ON DELETE, у внешнего ключа есть похожее правило ON UPDATE.
Оно отвечает за ситуацию, когда меняется значение родительского ключа.
Например, если бы мы изменили users.id, база должна была бы решить, что делать с orders.user_id.
ON UPDATE CASCADE
означает: если изменился users.id, автоматически обновить orders.user_id.
Но на практике первичные ключи почти никогда не меняют. Особенно если это технический id.
Поэтому ON UPDATE встречается гораздо реже, чем ON DELETE.
Главное для начала — хорошо разобраться именно с удалением.
Как выбрать правильный ON DELETE
Можно думать так.
Используйте CASCADE, если дочерняя строка не имеет смысла без родителя
Например:
- позиции заказа без заказа;
- комментарии к удалённому черновику;
- временные токены пользователя;
- настройки, которые существуют только внутри аккаунта.
Пример:
FOREIGN KEY (order_id)
REFERENCES orders(id)
ON DELETE CASCADE
Используйте SET NULL, если дочерняя строка должна остаться, но связь можно потерять
Например:
- заказ остаётся, но пользователь удалён;
- сотрудник остаётся, но его руководитель уволен;
- задача остаётся, но исполнитель удалён из системы.
Пример:
FOREIGN KEY (manager_id)
REFERENCES employees(id)
ON DELETE SET NULL
Важно: колонка должна разрешать NULL.
Используйте RESTRICT или NO ACTION, если удаление должно быть запрещено
Например:
- нельзя удалить клиента с финансовыми документами;
- нельзя удалить товар, который есть в заказах;
- нельзя удалить категорию, пока в ней есть товары;
- нельзя удалить склад, пока на нём есть остатки.
Пример:
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE RESTRICT
Так база не даст случайно уничтожить важные данные.
Короткая шпаргалка
| Действие |
Что делает |
Когда использовать |
CASCADE |
Удаляет дочерние строки вместе с родителем |
Когда дочерние данные не нужны без родителя |
SET NULL |
Оставляет дочерние строки, но очищает ссылку |
Когда запись должна жить дальше без родителя |
RESTRICT |
Запрещает удаление родителя |
Когда зависимые данные важны и удаление должно быть явным |
NO ACTION |
По умолчанию запрещает нарушение ссылки |
Похоже на RESTRICT, но может работать с отложенной проверкой |
Особенности разных СУБД
В PostgreSQL доступны CASCADE, SET NULL, RESTRICT, NO ACTION и отложенные ограничения через DEFERRABLE.
В MySQL с движком InnoDB тоже есть основные действия для внешних ключей: CASCADE, SET NULL, RESTRICT, NO ACTION. Но отложенные ограничения DEFERRABLE в MySQL не поддерживаются: проверки выполняются сразу.
В ClickHouse внешних ключей в привычном смысле нет. Он не будет сам проверять ссылочную целостность между таблицами. Такие правила обычно контролируются на уровне приложения, пайплайна загрузки данных или архитектуры хранения.
Главное, что нужно запомнить
Внешний ключ — это не просто «связь между таблицами». Это правило, которое защищает данные от неправильных состояний.
ON DELETE отвечает на простой, но очень важный вопрос:
что делать с дочерними строками, когда удаляется родитель?
Для начала держите в голове три основных сценария:
CASCADE — удалить всё зависимое вместе с родителем;
SET NULL — оставить дочернюю строку, но убрать ссылку;
RESTRICT — запретить удаление, пока есть зависимые данные.
И ещё одно практическое правило: почти всегда создавайте индекс на колонке внешнего ключа.
CREATE INDEX idx_orders_user_id
ON orders (user_id);
Так база будет быстрее находить зависимые строки, а удаления и проверки внешних ключей не превратятся в тяжёлые операции на больших таблицах.
Хорошо выбранный ON DELETE делает схему не просто правильной, а предсказуемой. А предсказуемость — одна из главных вещей, за которые мы любим хорошие базы данных.
Когда мы связываем таблицы через внешний ключ, база данных не просто «запоминает», что одна таблица зависит от другой. Она ещё должна понимать, что делать, если родительскую запись удалят.
Например, у нас есть пользователь и его заказы. Пользователь хранится в таблице
users, а заказы — в таблицеorders. Каждый заказ ссылается на конкретного пользователя черезuser_id.И вот вопрос: что должно произойти с заказами, если удалить пользователя?
Вариантов несколько:
За это поведение отвечает часть внешнего ключа
ON DELETE.Базовый пример: пользователи и заказы
Представим простой интернет-магазин. У нас есть пользователи:
CREATE TABLE users ( id bigint PRIMARY KEY, email text UNIQUE NOT NULL, name text, country text, created_at timestamptz DEFAULT now() );И есть заказы:
CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint NOT NULL, amount numeric(12,2), status text, created_at timestamptz DEFAULT now(), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE );Здесь поле
orders.user_idссылается наusers.id.Это значит: в таблице
ordersнельзя создать заказ сuser_id, которого нет в таблицеusers.Например, если пользователя с
id = 7не существует, такой запрос упадёт с ошибкой:INSERT INTO orders (id, user_id, amount, status) VALUES (1001, 7, 2500, 'new');База скажет: «Нельзя создать заказ для пользователя, которого нет».
И это хорошо. Так мы защищаем данные от мусора и случайных ошибок.
Но внешний ключ проверяет не только вставку. Он ещё участвует в удалении.
Что произойдёт при удалении родителя
Допустим, у пользователя есть заказы:
SELECT * FROM orders WHERE user_id = 7;А потом мы удаляем самого пользователя:
DELETE FROM users WHERE id = 7;Что теперь делать с его заказами?
Они ведь больше не смогут ссылаться на пользователя
7, потому что такого пользователя уже нет. Получается «висящая ссылка»: заказ указывает туда, где записи больше не существует.Чтобы этого не было, база использует правило
ON DELETE.В нашем примере указано:
ON DELETE CASCADEЭто значит: если удалить пользователя, база автоматически удалит и все его заказы.
То есть один запрос:
DELETE FROM users WHERE id = 7;на самом деле приведёт к двум последствиям:
7будет удалён изusers;orders.Именно это называется каскадным удалением.
ON DELETE CASCADE: удалить дочерние строки вместе с родителем
CASCADEозначает: «если удаляем родителя, удаляем и всё, что от него зависит».Пример:
CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint NOT NULL, amount numeric(12,2), status text, CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE );Теперь удаление пользователя автоматически удалит его заказы:
DELETE FROM users WHERE id = 7;Это удобно, когда дочерние данные не имеют смысла без родительской записи.
Хороший пример — позиции заказа.
Допустим, есть таблица
ordersи таблицаorder_items:CREATE TABLE order_items ( id bigint PRIMARY KEY, order_id bigint NOT NULL, product_id bigint NOT NULL, quantity int NOT NULL, CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE );Позиция заказа без самого заказа обычно не нужна. Если мы удаляем заказ, логично удалить и его позиции.
DELETE FROM orders WHERE id = 1001;После этого все строки из
order_items, связанные с заказом1001, тоже удалятся.Вот здесь
CASCADEвыглядит естественно.Где CASCADE может быть опасен
ON DELETE CASCADEвыглядит очень соблазнительно: база сама всё подчистит, не надо писать дополнительные запросы, не надо думать о зависимых строках.Но именно поэтому он опасен.
Представьте такой запрос:
DELETE FROM users WHERE country = 'RU';На первый взгляд мы просто удаляем пользователей из России. Но если у таблицы
ordersстоитON DELETE CASCADE, вместе с пользователями удалятся и все их заказы.А если у заказов есть позиции заказа с
ON DELETE CASCADE, то удалятся ещё и они.Получается цепочка:
Один запрос может снести сразу несколько уровней данных.
На учебных примерах это выглядит безобидно. В реальной базе это может означать потерю финансовой истории, аналитики, чеков, аудита и данных, которые бизнес вообще не собирался удалять.
Поэтому важное правило:
CASCADEподходит для технически зависимых данных, но плохо подходит для ценных бизнес-сущностей.Например:
Но вот заказы, платежи, документы, транзакции и история действий часто должны жить дольше пользователя. Даже если пользователь удалил аккаунт, бизнесу может быть нужно хранить историю покупок, возвратов, оплат и налоговых документов.
Для таких случаев лучше использовать
RESTRICT,SET NULLили мягкое удаление через полеdeleted_at.ON DELETE SET NULL: оставить строку, но убрать ссылку
Иногда дочерняя запись должна остаться, даже если родитель удалён.
Например, у нас есть заказы. Пользователь удалил аккаунт, но сам заказ всё ещё важен: по нему была оплата, доставка, возврат, отчётность.
В такой ситуации можно сделать так, чтобы при удалении пользователя заказ остался, но ссылка на пользователя стала
NULL.Для этого используется:
ON DELETE SET NULLПример:
CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint, amount numeric(12,2), status text, created_at timestamptz DEFAULT now(), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL );Обратите внимание: здесь
user_idуже неNOT NULL.Это важно.
Если мы хотим, чтобы база могла записать в
user_idзначениеNULL, колонка должна разрешатьNULL.Теперь при удалении пользователя:
DELETE FROM users WHERE id = 7;заказы не удалятся. Они останутся в таблице, но поле
user_idстанетNULL.Условно было так:
Станет так:
Смысл такой: «заказы существуют, но пользователь, к которому они были привязаны, удалён».
Как изменить CASCADE на SET NULL
Допустим, сначала мы создали внешний ключ с
CASCADE, а потом поняли, что заказы удалять нельзя. Их нужно оставлять, просто очищая ссылку на пользователя.Тогда можно изменить ограничение.
Сначала нужно разрешить
NULLв колонкеuser_id:ALTER TABLE orders ALTER COLUMN user_id DROP NOT NULL;Потом удалить старый внешний ключ и создать новый:
ALTER TABLE orders DROP CONSTRAINT fk_orders_user; ALTER TABLE orders ADD CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL;Теперь удаление пользователя не будет удалять заказы.
ON DELETE RESTRICT: запретить удаление, если есть зависимые строки
RESTRICTработает строже.Он говорит базе: «нельзя удалить родительскую запись, если на неё кто-то ссылается».
Пример:
CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint NOT NULL, amount numeric(12,2), status text, created_at timestamptz DEFAULT now(), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT );Теперь, если у пользователя есть хотя бы один заказ, такой запрос упадёт:
DELETE FROM users WHERE id = 7;База не даст удалить пользователя, потому что в
ordersесть строки, которые на него ссылаются.Это хороший вариант для данных, которые нельзя случайно потерять.
Например:
RESTRICTзаставляет сначала разобраться с зависимыми данными. Например, перенести заказы, архивировать пользователя, закрыть документы или явно удалить связанные строки.Это менее удобно, чем
CASCADE, зато намного безопаснее.А что будет, если ON DELETE вообще не указать?
Если не написать
ON DELETE, поведение по умолчанию обычно будет таким: база не позволит удалить родительскую запись, пока на неё есть ссылки.Например:
CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint NOT NULL, CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) );Если у пользователя есть заказы, удаление пользователя завершится ошибкой:
DELETE FROM users WHERE id = 7;То есть база защитит нас от появления «висящих» ссылок.
В PostgreSQL действие по умолчанию —
NO ACTION.На практике для обычного разработчика
NO ACTIONчасто выглядит почти какRESTRICT: удалить родителя нельзя, если есть дочерние строки.Разница проявляется в более продвинутых сценариях с отложенными проверками внутри транзакций. Для новичка главное запомнить так:
если
ON DELETEне указан, база не будет автоматически удалять или обнулять дочерние строки.Она скорее запретит удаление, если есть зависимые данные.
RESTRICT и NO ACTION: в чём разница
RESTRICTиNO ACTIONдействительно похожи. Оба варианта защищают данные и не дают удалить родителя, если на него есть ссылки.Но есть тонкое отличие.
RESTRICTпроверяется сразу.NO ACTIONв PostgreSQL может быть отложен до конца транзакции, если внешний ключ объявлен какDEFERRABLE.Проще говоря:
RESTRICTговорит: «проверяем прямо сейчас»;NO ACTIONможет сказать: «проверим позже, передCOMMIT».В обычных таблицах без
DEFERRABLEвы почти не заметите разницы.DEFERRABLE: когда проверку можно отложить
Иногда нужно внутри одной транзакции временно нарушить порядок ссылок, а к концу транзакции привести всё в нормальное состояние.
Например, мы загружаем данные пачкой. В середине загрузки часть строк уже вставлена, часть ещё нет. На короткий момент ссылка может выглядеть неправильной, но к концу транзакции всё станет корректно.
Для таких случаев в PostgreSQL есть отложенные ограничения:
CREATE TABLE employees ( id bigint PRIMARY KEY, name text, manager_id bigint, dept text, salary numeric(12,2), CONSTRAINT fk_emp_manager FOREIGN KEY (manager_id) REFERENCES employees(id) ON DELETE SET NULL DEFERRABLE INITIALLY DEFERRED );Здесь таблица
employeesссылается сама на себя: у сотрудника есть руководитель, который тоже является сотрудником.Если удалить руководителя, у его подчинённых
manager_idстанетNULL.DELETE FROM employees WHERE id = 10;После этого сотрудники, у которых был
manager_id = 10, останутся в таблице, но будут уже без руководителя.DEFERRABLE INITIALLY DEFERREDозначает, что проверка ограничения будет выполнена не сразу после каждого отдельного запроса, а ближе к концу транзакции — передCOMMIT.Это нужно не каждый день, но полезно знать, что такая возможность существует.
Самоссылки: когда таблица ссылается сама на себя
Самоссылка — это внешний ключ, который ведёт не в другую таблицу, а в эту же самую таблицу.
Классический пример — сотрудники и руководители:
CREATE TABLE employees ( id bigint PRIMARY KEY, name text NOT NULL, manager_id bigint, CONSTRAINT fk_emp_manager FOREIGN KEY (manager_id) REFERENCES employees(id) ON DELETE SET NULL );Здесь
manager_idхранитidруководителя.Например:
Анна — руководитель Ивана и Марии. Иван — руководитель Олега.
Если удалить Анну:
DELETE FROM employees WHERE id = 1;у Ивана и Марии
manager_idстанетNULL.Они не исчезнут из компании, просто останутся без указанного руководителя.
Это как раз хороший пример для
ON DELETE SET NULL.Индекс на колонке внешнего ключа
В PostgreSQL есть важный момент, о котором часто забывают начинающие.
Когда вы создаёте внешний ключ, PostgreSQL автоматически проверяет, что родительская колонка уникальна или является первичным ключом. Например,
users.idуже индексируется, потому что этоPRIMARY KEY.Но колонку
orders.user_idPostgreSQL автоматически не индексирует.То есть вот здесь:
FOREIGN KEY (user_id) REFERENCES users(id)индекс на
users.idуже есть, а наorders.user_id— нет.Почему это важно?
Когда вы удаляете пользователя, базе нужно быстро понять, есть ли в таблице
ordersстроки с этимuser_id.Например:
DELETE FROM users WHERE id = 7;База должна проверить:
SELECT * FROM orders WHERE user_id = 7;Если таблица
ordersмаленькая, проблемы не будет. Но если там миллионы строк, а индекса поuser_idнет, база может просматривать большую часть таблицы.Поэтому для внешних ключей почти всегда стоит создавать индекс на дочерней колонке:
CREATE INDEX idx_orders_user_id ON orders (user_id);Для самоссылки правило такое же:
CREATE INDEX idx_employees_manager_id ON employees (manager_id);Индекс особенно важен при
CASCADE,SET NULL,RESTRICTи любых частых удалениях или обновлениях родительских строк.А что насчёт ON UPDATE?
Кроме
ON DELETE, у внешнего ключа есть похожее правилоON UPDATE.Оно отвечает за ситуацию, когда меняется значение родительского ключа.
Например, если бы мы изменили
users.id, база должна была бы решить, что делать сorders.user_id.ON UPDATE CASCADEозначает: если изменился
users.id, автоматически обновитьorders.user_id.Но на практике первичные ключи почти никогда не меняют. Особенно если это технический
id.Поэтому
ON UPDATEвстречается гораздо реже, чемON DELETE.Главное для начала — хорошо разобраться именно с удалением.
Как выбрать правильный ON DELETE
Можно думать так.
Используйте CASCADE, если дочерняя строка не имеет смысла без родителя
Например:
Пример:
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADEИспользуйте SET NULL, если дочерняя строка должна остаться, но связь можно потерять
Например:
Пример:
FOREIGN KEY (manager_id) REFERENCES employees(id) ON DELETE SET NULLВажно: колонка должна разрешать
NULL.Используйте RESTRICT или NO ACTION, если удаление должно быть запрещено
Например:
Пример:
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICTТак база не даст случайно уничтожить важные данные.
Короткая шпаргалка
CASCADESET NULLRESTRICTNO ACTIONRESTRICT, но может работать с отложенной проверкойОсобенности разных СУБД
В PostgreSQL доступны
CASCADE,SET NULL,RESTRICT,NO ACTIONи отложенные ограничения черезDEFERRABLE.В MySQL с движком InnoDB тоже есть основные действия для внешних ключей:
CASCADE,SET NULL,RESTRICT,NO ACTION. Но отложенные ограниченияDEFERRABLEв MySQL не поддерживаются: проверки выполняются сразу.В ClickHouse внешних ключей в привычном смысле нет. Он не будет сам проверять ссылочную целостность между таблицами. Такие правила обычно контролируются на уровне приложения, пайплайна загрузки данных или архитектуры хранения.
Главное, что нужно запомнить
Внешний ключ — это не просто «связь между таблицами». Это правило, которое защищает данные от неправильных состояний.
ON DELETEотвечает на простой, но очень важный вопрос:что делать с дочерними строками, когда удаляется родитель?
Для начала держите в голове три основных сценария:
CASCADE— удалить всё зависимое вместе с родителем;SET NULL— оставить дочернюю строку, но убрать ссылку;RESTRICT— запретить удаление, пока есть зависимые данные.И ещё одно практическое правило: почти всегда создавайте индекс на колонке внешнего ключа.
CREATE INDEX idx_orders_user_id ON orders (user_id);Так база будет быстрее находить зависимые строки, а удаления и проверки внешних ключей не превратятся в тяжёлые операции на больших таблицах.
Хорошо выбранный
ON DELETEделает схему не просто правильной, а предсказуемой. А предсказуемость — одна из главных вещей, за которые мы любим хорошие базы данных.