<? phpukraine СПІВБЕСІДИ
⌕Пошук по платформі
SQL · JUNIOR ЧАСТО ПИТАЮТЬ

Навіщо зовнішні ключі й що обрати: CASCADE, RESTRICT чи SET NULL?

Зовнішній ключ - це перевірка, яку робить сама база на кожному записі, тому посилання на неіснуючий рядок неможливе навіть із консолі чи з чужого скрипта. CASCADE беруть для рядків, які не живуть без батька, RESTRICT - коли видалення має бути помилкою, SET NULL - для необовʼязкового звʼязку в nullable-колонці.

Що станеться з відгуками кандидатів, якщо видалити вакансію, на яку вони надіслані?
У нас один застосунок і вся логіка в коді - навіщо тоді FK у базі?
Чому DELETE однієї компанії виконується сорок секунд?
У моделі стоїть SoftDeletes, а в міграції ON DELETE CASCADE. Коли спрацює каскад?
зовнішні ключі цілісність CASCADE soft delete

Зовнішній ключ база перевіряє в тій самій транзакції, що й сам запис: INSERT у applications із неіснуючим vacancy_id не пройде (MySQL: помилка 1452, PostgreSQL: SQLSTATE 23503), а DELETE батька або впаде з 1451, або виконає описану дію. Перевірка в коді на кшталт «спочатку exists, потім insert» дає таку саму гарантію лише на папері: між двома запитами батька встигнуть видалити. Плюс до застосунку пишуть ще й воркери, консольні команди, SQL-міграції, а є ще руки адміна в psql, і жодна з цих доріг не проходить через вашу модель. Осиротілі рядки не викидають помилок і не падають у Sentry, вони просто тихо ламають звіти й JOIN-и через півроку.

Дій усього кілька, і кожна оголошується окремо для ON DELETE та ON UPDATE. Без явного клаузу діє NO ACTION, що на практиці означає заборону: у MySQL це те саме, що RESTRICT, а в PostgreSQL різниця в тому, що NO ACTION можна відкласти до кінця транзакції через DEFERRABLE, тоді як RESTRICT перевіряється одразу й відкладенню не підлягає. CASCADE підходить рядкам, які не мають життя без батька: позиції замовлення, токени сесій, відгуки на видалену вакансію. RESTRICT ставлять там, де видалення батька майже завжди означає помилку оператора: компанія з активними вакансіями, користувач з оплаченими замовленнями. SET NULL беруть для звʼязку, який справді опційний, і колонка мусить бути nullable (MySQL відмовиться створити таке обмеження на NOT NULL, PostgreSQL створить і впаде на першому видаленні). SET DEFAULT в InnoDB не працює взагалі: парсер його приймає, а рушій відхиляє таблицю. У PostgreSQL 15 зʼявилася форма ON DELETE SET NULL (column), щоб у складеному ключі занулювати лише частину колонок.

ON UPDATE турбує тільки тоді, коли значення ключа може змінитися. З сурогатним bigserial чи UUID первинний ключ не оновлюють ніколи, тому ON UPDATE CASCADE там декорація. А от для природних ключів (код країни, скорочення валюти, slug у ролі PK) це єдиний спосіб не розсипати посилання при перейменуванні, і саме це аргумент проти природних ключів у схемах, де такі перейменування трапляються.

Тепер про індекс. PostgreSQL індексує лише сторону, на яку посилаються, а колонку company_id у vacancies залишає неіндексованою, поки ви не створите індекс самі. На маленьких таблицях це непомітно; на сотнях тисяч рядків кожне видалення батька починає сканувати дочірню таблицю, тримаючи при цьому блокування, і питання «чому DELETE триває сорок секунд» має саме цю відповідь. InnoDB від цієї проблеми захищений, бо створює потрібний індекс автоматично. Глибокі ланцюжки каскадів шкодять інакше: одна команда може видалити піддерево на мільйон рядків в одній транзакції, і замість швидкого DELETE ви отримуєте довгі блокування та відставання реплік. Великі чистки роблять пачками знизу вгору.

Найчастіша пастка в Laravel-проєктах - трейт SoftDeletes поруч із cascadeOnDelete() у міграції. Soft delete виконує UPDATE ... SET deleted_at = now(), рядок лишається в таблиці, отже база не бачить події, на яку реагує FK: каскад не спрацює, nullOnDelete нічого не занулить, а restrictOnDelete нікому не завадить. Дочірні записи й далі посилаються на «видаленого» батька, і фільтрувати їх доводиться в кожному запиті. Так само FK не заборонить вставити дитину до soft-deleted батька: для бази той рядок живий. Вибирайте одне: або справжнє видалення з каскадом у схемі, або soft delete на всьому піддереві з ручним каскадом в обробниках deleting/restoring у межах транзакції. Ще одна деталь, яку легко проґавити при аудиті: у MySQL каскадні дії FK не запускають тригери, а в Laravel каскадне видалення на рівні бази не викликає події моделей, тому прибирання файлів, кешу чи індексу пошуку треба робити явно, до видалення батька.

CREATE TABLE companies (
    id   bigserial PRIMARY KEY,
    name text NOT NULL
);

CREATE TABLE vacancies (
    id           bigserial PRIMARY KEY,
    -- компанію з вакансіями видаляти не можна: спершу розберись із вакансіями
    company_id   bigint NOT NULL REFERENCES companies (id) ON DELETE RESTRICT,
    -- рекрутер може піти, вакансія лишається без відповідального
    recruiter_id bigint REFERENCES users (id) ON DELETE SET NULL,
    title        text NOT NULL
);

CREATE TABLE applications (
    id           bigserial PRIMARY KEY,
    -- відгук не існує без вакансії: чистимо разом із нею
    vacancy_id   bigint NOT NULL REFERENCES vacancies (id) ON DELETE CASCADE,
    candidate_id bigint NOT NULL REFERENCES candidates (id) ON DELETE CASCADE,
    created_at   timestamptz NOT NULL DEFAULT now()
);

-- PostgreSQL не індексує дочірню колонку сам: без цих індексів
-- DELETE у батьку робить Seq Scan по дитині. InnoDB створює їх автоматично.
CREATE INDEX vacancies_company_id_idx ON vacancies (company_id);
CREATE INDEX applications_vacancy_id_idx ON applications (vacancy_id);
CREATE INDEX applications_candidate_id_idx ON applications (candidate_id);

-- Впаде з 23503 foreign_key_violation, поки є хоч одна вакансія
DELETE FROM companies WHERE id = 42;

-- Тому спершу дивимось, що саме заблокує видалення
SELECT count(*) FROM vacancies WHERE company_id = 42;
Що перевірка в коді має вікно гонки й обходиться будь-яким записом поза застосунком (консоль, дамп, інший сервіс, воркер), а FK перевіряється в тій самій транзакції, що й сам запис.
Різницю між ON DELETE і ON UPDATE та що без явного клаузу діє NO ACTION, тобто фактично заборона.
Критерій вибору дії: CASCADE для частин агрегату (позиції замовлення, токени), RESTRICT для самостійних сутностей, SET NULL для необовʼязкового звʼязку в nullable-колонці.
Що PostgreSQL не створює індекс під колонку FK сам, і без нього кожне видалення батька сканує дочірню таблицю; InnoDB такий індекс створює автоматично.
Що soft delete не видаляє рядок, тому жодна дія FK не спрацьовує, і каскад у схемі при `SoftDeletes` - мертвий код.
Казати «цілісність забезпечує ORM», а перевірку робити запитом `exists` перед вставкою: між перевіркою і вставкою батька можуть видалити.
Ставити ON DELETE CASCADE всюди за замовчуванням і втратити оплачені замовлення разом із випадково видаленим користувачем.
Оголошувати ON DELETE SET NULL на колонці NOT NULL: MySQL відмовить ще на створенні FK, PostgreSQL створить обмеження й впаде на першому ж DELETE.
Забути індекс на `company_id` у PostgreSQL і потім винити каскад у тому, що видалення тримає таблицю хвилинами.
Розраховувати, що каскадне видалення запустить тригери аудиту в MySQL або події моделі в Laravel: ні перше, ні друге не відбувається.
ПОРАДА

Назвіть критерій одним реченням: чи має цей рядок сенс без батька? Немає - CASCADE, має і важливий - RESTRICT, має і звʼязок опційний - SET NULL. І окремо додайте, що під FK у PostgreSQL індекс створюєте руками.

оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Soft delete - це UPDATE колонки `deleted_at`, а не DELETE, тому база не бачить події, на яку реагує зовнішній ключ.