Рядок, на який можна послатися
однозначно називає рядок: він не NULL, не повторюється і в таблиці лише один. Найчастіше це id, який нічого не означає поза базою. Буває й , узятий з самих даних: email, код валюти, назва пакета на Packagist.
у vacancies.company_id каже базі: тут може стояти лише id, який є в companies. Вакансію неіснуючої компанії база не вставить, а при видаленні компанії зробить з її вакансіями те, що сказано в ON DELETE. Без обмеження ця перевірка живе тільки в коді, і перший скрипт імпорту чи ручний DELETE у консолі її обійде.
Один до багатьох виражається колонкою на боці «багатьох»: у компанії багато вакансій, тому company_id лежить у vacancies. Багато до багатьох так не записати: вакансія вимагає кількох навичок, а навичка трапляється в сотнях вакансій. Для цього потрібна третя таблиця, , де кожен рядок це одна пара. Один до одного це звичайний зовнішній ключ з унікальним індексом.
Куди лягає новий рядок
В InnoDB рядки фізично впорядковані за первинним ключем. Ключ, що зростає, дописує новий рядок у праву сторінку дерева, яка й так у памʼяті. Випадковий ключ кидає кожну вставку в іншу сторінку: її треба прочитати з диска, вона ділиться навпіл і лишається напівпорожньою. У PostgreSQL таблиця не впорядкована за ключем, але індекс первинного ключа страждає так само.
Замір на 2 000 000 рядків з буфером 128 MB: в InnoDB вставка з BIGINT AUTO_INCREMENT зайняла 19 секунд і 264 MB, з випадковими 16 байтами 330 секунд і 452 MB, з 16 байтами, що зростають, як у UUIDv7, знову 19 секунд і 280 MB. У PostgreSQL 16 вставка з gen_random_uuid() тривала 18,7 секунди проти 7,5 з IDENTITY, а індекс ключа вийшов 75 MB проти 43 MB.
BIGINT AUTO_INCREMENT · GENERATED AS IDENTITY
послідовний
Кожен новий id більший за попередній, рядок дописується в кінець дерева. 8 байтів у кожному індексі. Мінус: id видно в URL, і за ним легко перебрати сусідні записи або порахувати, скільки їх.
ULID · UUIDv7
зростає з часом
Перші 48 біт це час у мілісекундах, тож нові ключі лягають поруч. Ключ можна згенерувати в застосунку ще до INSERT, і він нічого не каже про кількість записів. Текстом це char(26) чи char(36), у 3-4 рази довше за bigint, і так у кожному індексі.
UUIDv4
випадковий
Кожна вставка в іншу сторінку. У замірі InnoDB вставка в 17 разів повільніша, таблиця на 70% більша. Вторинні індекси InnoDB зберігають первинний ключ, тож вони теж ростуть.
email як PRIMARY KEY
змінюється
Унікальний, але не вічний: людина змінює пошту, і разом з нею треба оновити всі таблиці, що на неї посилаються. Надійніше сурогатний id і UNIQUE на email.
PRIMARY KEY (vacancy_id, skill_id)
для pivot
Пара сама унікальна, окремий id не потрібен. Порядок колонок важить так само, як у складеному індексі: цей ключ знаходить навички вакансії, а для вакансій за навичкою потрібен ще індекс на skill_id.
Заміри на MySQL 8.0.46 і PostgreSQL 16 в одному контейнері, 2 000 000 рядків, по 100 байтів даних у рядку. Числа це приклад: на іншому залізі вони будуть інші, співвідношення схожі.
База не дасть послатися в нікуди
ON DELETE каже, що робити з вакансіями, коли видаляють компанію. RESTRICT і NO ACTION (за замовчуванням) відмовляють, поки на компанію хтось посилається. CASCADE видаляє вакансії разом з нею. SET NULL лишає вакансії, але обнуляє company_id, тож колонка має бути nullable.
У MySQL RESTRICT і NO ACTION однакові. У PostgreSQL NO ACTION можна відкласти до кінця транзакції (DEFERRABLE), а RESTRICT перевіряється одразу. SQLite має пастку: зовнішні ключі вимкнені, поки зʼєднання не виконає PRAGMA foreign_keys = ON. Laravel вмикає їх сам: у config/database.php для sqlite стоїть foreign_key_constraints.
ERROR 1452 (23000): Cannot add or update a child row: a foreign key constraint fails (`lab`.`vacancies`, CONSTRAINT …) ERROR 1451 (23000): Cannot delete or update a parent row: a foreign key constraint fails (`lab`.`vacancies`, CONSTRAINT …)
Ключ оголошено окремим рядком CONSTRAINT … FOREIGN KEY. Скорочений запис company_id … REFERENCES companies (id) прямо в описі колонки MySQL 8.0 мовчки ігнорує: таблиця створиться без ключа.
ERROR: insert or update on table "vacancies" violates foreign key constraint "vacancies_company_id_foreign" DETAIL: Key (company_id)=(99) is not present in table "companies". ERROR: update or delete on table "companies" violates foreign key constraint "vacancies_company_id_foreign" on table "vacancies" DETAIL: Key (id)=(1) is still referenced from table "vacancies".
DETAIL одразу називає ключ, що завадив. IDENTITY це стандартний SQL на заміну serial; Laravel для id() досі пише bigserial, і працюють вони однаково.
0 -- перша вставка пройшла: ключ є, але не перевіряється Runtime error: FOREIGN KEY constraint failed (19)
PRAGMA діє на одне зʼєднання і не зберігається у файлі бази. Звідси клас багів «у тестах падає, у консолі sqlite3 ні» і навпаки.
InnoDB створює його сам, PostgreSQL ні
Щоб видалити компанію, база мусить знайти її вакансії: WHERE company_id = ?. Без індексу на company_id це перебір усієї таблиці вакансій на кожну видалену компанію, і так само для UPDATE ключа батьківського рядка.
InnoDB вимагає індекс під зовнішнім ключем. Якщо підходящого немає (колонка ключа першою в якомусь індексі), він створює його сам з назвою обмеження і не дасть його видалити. PostgreSQL і SQLite індекс на колонці, яка посилається, не створюють: про нього треба подбати в міграції.
Key_name Column_name PRIMARY id vacancies_company_id_foreign company_id ERROR 1553 (HY000): Cannot drop index 'vacancies_company_id_foreign': needed in a foreign key constraint
Ми не просили індекс, InnoDB додав його разом з ключем. Якщо потім додати інший індекс з company_id першою колонкою, InnoDB мовчки прибере свій, і ключ перейде на новий.
Indexes:
"vacancies_pkey" PRIMARY KEY, btree (id)
Foreign-key constraints:
"vacancies_company_id_foreign" FOREIGN KEY (company_id)
REFERENCES companies(id) ON DELETE RESTRICT Обмеження є, індексу на company_id немає. Індекс створюється лише під PRIMARY KEY, UNIQUE і EXCLUDE.
`--SCAN vacancies
SCAN це перебір таблиці. Саме такий пошук SQLite робить при кожному видаленні компанії, поки немає CREATE INDEX на company_id.
Таблиця, де рядок це пара
Вакансія вимагає Laravel і Docker, Laravel потрібен у багатьох вакансіях. Таблиця skill_vacancy тримає по рядку на кожну пару. Первинний ключ (vacancy_id, skill_id) не пустить ту саму пару двічі, і окремий id не потрібен.
Індекс первинного ключа впорядкований спершу за vacancy_id. Він швидко відповідає «які навички в цієї вакансії», а для «які вакансії вимагають Laravel» потрібен другий індекс, на skill_id. В InnoDB він ще й стане індексом під зовнішній ключ skill_id.
+-------------+---------+ | title | name | +-------------+---------+ | Senior PHP | Laravel | | Laravel Dev | Laravel | +-------------+---------+ ERROR 1062 (23000): Duplicate entry '10-1' for key 'skill_vacancy.PRIMARY'
Звʼязок багато до багатьох у запиті це два JOIN через pivot. Повторна пара впирається в первинний ключ.
ERROR: duplicate key value violates unique constraint "skill_vacancy_pkey" DETAIL: Key (vacancy_id, skill_id)=(10, 1) already exists.
У PostgreSQL REFERENCES прямо в описі колонки працює. Той самий SELECT з двома JOIN повертає ті самі два рядки.
Runtime error: UNIQUE constraint failed: skill_vacancy.vacancy_id, skill_vacancy.skill_id (19)
Таблиця з складеним первинним ключем у SQLite має прихований rowid, а пара (vacancy_id, skill_id) лежить в окремому унікальному індексі.
Скільки коштує видалити одну компанію
У PostgreSQL перевірка зовнішнього ключа працює як системний тригер, і EXPLAIN ANALYZE показує його окремим рядком. Таблиця vacancies на мільйон рядків, видаляємо компанію без жодної вакансії: сам DELETE займає соті мілісекунди, а перевірка, що на компанію ніхто не посилається, без індексу читає всю таблицю. EXPLAIN ANALYZE видаляє насправді, тому він загорнутий у BEGIN і ROLLBACK.
DELETE FROM companies WHERE id = 10000;Delete on companies (actual time=0.042..0.043 rows=0 loops=1)
-> Index Scan using companies_pkey on companies (actual time=0.022..0.023 rows=1 loops=1)
Index Cond: (id = 10000)
Trigger for constraint vacancies_company_id_foreign: time=63.512 calls=1
Execution Time: 63.621 ms DELETE FROM companies WHERE id = 10000;Delete on companies (actual time=0.035..0.035 rows=0 loops=1)
-> Index Scan using companies_pkey on companies (actual time=0.019..0.020 rows=1 loops=1)
Index Cond: (id = 10000)
Trigger for constraint vacancies_company_id_foreign: time=0.270 calls=1
Execution Time: 0.323 ms PostgreSQL 16, 10 000 компаній і 1 000 000 вакансій, ON DELETE SET NULL. EXPLAIN (ANALYZE, COSTS OFF), рядки Planning Time прибрано.
SELECT * FROM vacancies WHERE company_id = 7;| type | key | rows | Extra |
|---|---|---|---|
| ref | vacancies_company_id_foreign | 101 | NULL |
MySQL 8.0.46, InnoDB, та сама таблиця на 1 000 000 рядків. Вивід знято зі справжньої бази, частину колонок прибрано.
- Trigger for constraint
- Час, витрачений на перевірку зовнішнього ключа: пошук у vacancies рядків з цим company_id. Видно лише в EXPLAIN ANALYZE, бо план самого DELETE його не містить.
- calls
- Скільки разів тригер спрацював, по одному на видалений рядок. Видалення тисячі компаній без індексу означає тисячу переборів таблиці.
- Execution Time
- Повний час разом з тригерами: 63 мс без індексу проти 0,3 мс з індексом vacancies_company_id_index.
- key
- Індекс, який InnoDB створив разом із зовнішнім ключем. Ним же користується перевірка при видаленні компанії, тож тут вона завжди швидка.
- rows
- Оцінка рядків, які доведеться прочитати: вакансії однієї компанії, а не мільйон.
Як оголосити звʼязок у міграції
foreignId() створює колонку unsigned bigint під id(), constrained() додає обмеження. Назву обмеження Laravel складає сам: таблиця, колонка і foreign. Саме її ви побачите в повідомленні про помилку.
Пошук роботи як pivot з даними
Таблиця job_search_items звʼязує користувача з вакансією, яку він зберіг або на яку відгукнувся, і несе власні поля: етап, нотатку, наступний крок. Це звʼязок багато до багатьох з даними, тож у нього свій id, а пару стереже unique(user_id, vacancy_id). Складений первинний ключ тут не підійшов би: vacancy_id буває NULL, коли людина вручну додає вакансію з іншого сайту, а колонки первинного ключа NULL не допускають. Унікальний індекс допускає скільки завгодно NULL, бо NULL не дорівнює NULL, тож зовнішніх вакансій у людини може бути багато. Ліва колонка цього індексу, user_id, служить і індексом під зовнішній ключ. А під vacancy_id і application_id окремого індексу немає: на PostgreSQL видалення вакансії чи відгуку перебиратиме цю таблицю.
database/migrations/2026_09_02_000002_create_vacancies_table.phpvacancies.company_id з nullOnDelete: якщо компанію видалити, її вакансії лишаються в каталозі без компанії. Тому колонка nullable.database/migrations/2026_09_04_000001_add_employer_cabinet.phpapplications посилається і на вакансію, і на компанію, обидва з cascadeOnDelete: відгук без вакансії не має сенсу. users.company_id з nullOnDelete: людина лишається, коли зникає компанія.database/migrations/2026_09_04_000002_create_developer_profiles.phpОдин до одного: developer_profiles.user_id з unique() і cascadeOnDelete.database/migrations/2026_09_03_000004_create_technologies_and_packages_tables.phpПриродний ключ: у packages первинний ключ це назва пакета vendor/package, бо під нею його знає Packagist і під нею сайт оновлює його числа.Де ключі підводять
Звʼязок без обмеження
company_id є, FOREIGN KEY немає, бо «ORM і так стежить». Потім хтось видаляє компанію в консолі, і в каталозі зʼявляються вакансії з company_id, якого немає. JOIN тихо їх відкидає, а LEFT JOIN показує NULL.
Натомість: constrained() у кожній міграції. Знайти сиріт до додавання ключа: SELECT … FROM vacancies v LEFT JOIN companies c ON c.id = v.company_id WHERE v.company_id IS NOT NULL AND c.id IS NULL.
Немає індексу під ключем у PostgreSQL
Обмеження є, індексу немає. Кожне видалення батьківського рядка перебирає дочірню таблицю, а JOIN за цією колонкою теж іде без індексу.
Натомість: $table->index('company_id') поруч з constrained() або складений індекс, де ця колонка перша.
CASCADE там, де дані треба зберегти
ON DELETE CASCADE на історичних даних: видалили вакансію, разом з нею зникли всі відгуки на неї, а з ними й історія кандидатів. Каскад іде ланцюжком і може зачепити кілька таблиць за один DELETE.
Натомість: Для того, що має пережити батька, беріть SET NULL або RESTRICT і закривайте записи статусом замість видалення.
REFERENCES в описі колонки в MySQL
company_id INT REFERENCES companies (id) у MySQL 8.0 проходить без помилки, але ключа не створює. Вставка неіснуючого id теж пройде.
Натомість: Окремий рядок CONSTRAINT … FOREIGN KEY (…) REFERENCES … або міграція Laravel, яка пише саме так. Перевірка: SHOW CREATE TABLE.
UUIDv4 як первинний ключ InnoDB
Кожна вставка в випадкову сторінку дерева, таблиця розпухає, а всі вторинні індекси тягнуть по 36 байтів ключа замість 8.
Натомість: id() для внутрішніх таблиць, ULID чи UUIDv7 (HasUlids, HasUuids) там, де потрібен непередбачуваний чи заздалегідь відомий id.
- Первинний ключ
- PRIMARY KEY: NOT NULL плюс унікальність, один на таблицю. База одразу створює під нього унікальний індекс. В InnoDB це ще й кластерний індекс: рядки таблиці лежать у його листках.
- Сурогатний ключ
- Ключ, який база або застосунок генерує сам: лічильник, ULID, UUID. Він ніколи не змінюється, бо нічого не означає. Його протилежність це природний ключ.
- Природний ключ
- Унікальна властивість самих даних: email, ISO-код валюти, vendor/package. Зручний, поки не змінюється. Коли змінюється, разом з ним треба переписати всі посилання.
- Зовнішній ключ
- FOREIGN KEY (company_id) REFERENCES companies (id): значення має існувати в батьківській таблиці або бути NULL. ON DELETE і ON UPDATE кажуть, що робити з дочірніми рядками, коли батьківський зникає чи змінює ключ.
- Pivot
- Проміжна таблиця звʼязку багато до багатьох, junction table. Два зовнішні ключі, а пара з них зазвичай і є первинним ключем. Laravel називає її з імен двох моделей в однині за абеткою: skill_vacancy.
Питання зі співбесід
Третьою таблицею skill_vacancy з двома зовнішніми ключами, vacancy_id і skill_id. Кожен її рядок це одна пара. Первинний ключ (vacancy_id, skill_id) не дасть записати пару двічі, а індекс на skill_id потрібен, щоб шукати вакансії за навичкою.
Залежить від того, чи живе вакансія без компанії. Якщо вакансія без компанії не має сенсу, CASCADE. Якщо її треба лишити в каталозі, SET NULL і nullable колонка. Якщо видалення компанії з вакансіями це помилка, RESTRICT, і код спершу сам закриває або переносить вакансії. Хай яким буде вибір, він має стояти в схемі, бо код обходять консоль і імпорт.
Перевірка зовнішнього ключа шукає вакансії з цим company_id. InnoDB створив індекс під ключем сам, а PostgreSQL ні, тож там кожне видалення перебирає всю таблицю vacancies. В EXPLAIN ANALYZE це видно в рядку Trigger for constraint. Лікується CREATE INDEX на company_id.
Не UUIDv4: в InnoDB випадковий ключ розкидає вставки по кластерному індексу, таблиця й усі вторинні індекси ростуть, вставка сповільнюється в рази. Беріть ULID або UUIDv7, які зростають з часом, і зберігайте компактно (uuid у PostgreSQL, BINARY(16) у MySQL замість char(36)). Інший варіант лишити bigint для звʼязків усередині бази і додати окремий публічний ULID з унікальним індексом лише для URL.