Питання на співбесіді: Міграції
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
7 питань
Міграція - це контроль версій для структури бази даних. Замість того щоб писати SQL вручну й узгоджувати його між розробниками, ви описуєте схему PHP-кодом, який комітиться в git і застосовується однаково в усіх середовищах.
Міграції зберігаються в директорії database/migrations. Кожен файл - це клас із двома методами:
up()- описує зміни, які треба застосувати (створити таблицю, додати колонку, індекс).down()- описує зворотну операцію для відкату (видалити таблицю чи колонку).
Створити міграцію:
php artisan make:migration create_posts_table
Згенерований файл заповнюють у методі up():
public function up(): void
{
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('posts');
}
Основні команди:
php artisan migrate- застосувати нові міграції.php artisan migrate:rollback- відкотити останній «батч».php artisan migrate:fresh --seed- перестворити всі таблиці й засіяти даними.
Навіщо: будь-який розробник на новій машині отримує ідентичну схему БД однією командою, а зміни структури проходять через code review разом із кодом.
migrate:rollback відкочує останній «батч» міграцій, викликаючи їхні методи down():
php artisan migrate:rollback # останній батч
php artisan migrate:rollback --step=1 # рівно одну міграцію
migrate:reset відкочує всі міграції по черзі. migrate:refresh - відкочує все й накатує заново. migrate:fresh - видаляє всі таблиці й накатує міграції з нуля, взагалі не заглядаючи в down().
Що з цього небезпечне в проді: усі чотири. Кожна знищує дані, а migrate:fresh робить це найшвидше й без шансу на down(). У проді припустимий лише php artisan migrate.
Laravel сам питає підтвердження в продакшн-середовищі, і саме тому --force не варто вписувати в скрипти «щоб не заважало».
Практика, що рятує:
- Перевіряйте відкат локально одразу після написання:
migrate→migrate:rollback --step=1→migrate. Половина міграцій має неробочийdown(), і виявляється це в найгірший момент. - Пишіть
down()чесно, а якщо відкат неможливий - хай кидає виняток, це краще за мовчазну порожню реалізацію. - Не редагуйте вже застосовану міграцію - додавайте нову. Виправлений файл не перезастосується там, де він уже відпрацював.
- Видалення колонки й перейменування - окремими релізами від коду, що їх читає, інакше деплой ламає працюючий застосунок у проміжку.
На таблиці в кілька мільйонів рядків необережна міграція блокує запис на хвилини, і застосунок лежить.
Що блокує довго:
- Додавання колонки
NOT NULLзі значенням за замовчуванням у старих версіях MySQL переписує таблицю цілком. У MySQL 8 і PostgreSQL 11+ це вже метадані, але перевіряти варто саме свою версію. - Створення індексу звичайним
CREATE INDEXтримає таблицю на час побудови. - Зміна типу колонки майже завжди означає перезапис.
Безпечний порядок для нової колонки:
// 1. Спершу nullable - миттєво, без перезапису.
Schema::table('vacancies', function (Blueprint $table) {
$table->string('short_name')->nullable()->after('title');
});
Далі заповнити дані пачками - окремою командою, не в міграції:
Vacancy::whereNull('short_name')->chunkById(500, function ($vacancies) {
foreach ($vacancies as $vacancy) {
$vacancy->update(['short_name' => Str::limit($vacancy->title, 40, '')]);
}
});
І лише потім, якщо потрібно, робити колонку обовʼязковою - окремою міграцією, коли даних без значення вже немає.
Індекс без блокування:
// PostgreSQL
DB::statement('CREATE INDEX CONCURRENTLY vacancies_level_index ON vacancies (level)');
CONCURRENTLY не можна виконувати всередині транзакції, тож у міграції потрібно вимкнути обгортання:
public $withinTransaction = false;
Загальне правило: розділяйте зміну схеми й заповнення даних. Міграція має бути швидкою операцією над структурою, а перенесення даних - командою, яку можна запустити, зупинити й продовжити.
Кожен запуск php artisan migrate записує виконані міграції в таблицю migrations з одним номером пакета (batch).
php artisan migrate:rollback # відкотити останній пакет цілком
php artisan migrate:rollback --step=1 # рівно одну останню міграцію
php artisan migrate:rollback --batch=3 # конкретний пакет
php artisan migrate:status # що виконано і в якому пакеті
Відкат викликає down(), тож вона має справді повертати попередній стан: видалити створену колонку, відновити змінений тип.
Інші команди - і чим вони небезпечні:
migrate:reset- відкотити все;migrate:refresh- відкотити все й виконати знову (проходить усіdown());migrate:fresh- видалити всі таблиці й виконати міграції з нуля,down()не викликається.
Останні три знищують дані - на продакшені їх не запускають, а локально з копією прод-бази - теж обережно.
На продакшені відкат міграції, яка видалила колонку з даними, не поверне дані. Тому руйнівні зміни роблять окремим релізом пізніше, а відкат релізу - новою міграцією вперед, а не rollback.
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('post_id')->constrained()->cascadeOnDelete();
$table->foreignIdFor(User::class)->nullable()->constrained()->nullOnDelete();
$table->timestamps();
});
foreignId('post_id')->constrained()- колонкаunsignedBigIntegerплюс обмеження наposts.id(таблицю Laravel виводить з назви колонки);foreignIdFor(User::class)- назву колонки бере з моделі.
Що буде з дітьми при видаленні батька - вирішують явно:
cascadeOnDelete()- видалити разом (коментарі поста);nullOnDelete()- обнулити посилання (автор видалив акаунт, коментар лишається анонімним; колонка має бутиnullable);restrictOnDelete()- заборонити видалення, поки є діти (клієнт із рахунками).
Що варто знати:
- обмеження працює на рівні бази й обходить Eloquent - події моделей для каскадно видалених рядків не спрацюють;
- м'яке видалення батька каскад не запускає - це звичайний
UPDATE; - MySQL сам створює індекс для зовнішнього ключа, PostgreSQL - ні, тож там індекс на
post_idдодають окремо; - видалити ключ:
$table->dropForeign(['post_id']).
Міграції виконуються один раз на базу, а не на кожному сервері.
Як цього досягти:
- окремий крок деплою (job у CI, init-контейнер, release-фаза) замість
migrateу старті кожного контейнера; - якщо запускають з кожного сервера -
php artisan migrate --isolated --force: перший бере атомарне блокування в кеші, інші тихо завершуються. Кеш має бути спільним; --forceпотрібен, щоб команда не питала підтвердження на продакшені.
Сумісність схеми й коду. Під час rolling-деплою одночасно працюють стара й нова версії коду, тож міграція має підходити обом:
- Розширення: додати нову колонку (nullable чи з дефолтом), код пише в обидві.
- Перенесення даних фоново порціями.
- Перемикання коду на нову колонку.
- Звуження: видалити стару колонку окремим наступним релізом.
Що ще враховують:
- довгі блокування на великих таблицях:
instant()на MySQL,online()для індексів на PostgreSQL, нічні вікна для важкого; - PostgreSQL обгортає міграцію в транзакцію, а
CREATE INDEX CONCURRENTLYу транзакції не працює; - сідери довідників - окремо від міграцій і ідемпотентні.
Сотні файлів сповільнюють створення бази для тестів і CI, а історія давно не потрібна покроково.
php artisan schema:dump --prune
Команда записує поточну схему в database/schema/{connection}-schema.sql і з --prune видаляє наявні міграції. Нова база спершу виконує SQL зі схеми, а потім - міграції, створені після дампу.
На що зважати:
- Різні СУБД. Дамп робиться для конкретного підключення. Якщо тести йдуть на іншій базі (SQLite замість PostgreSQL), потрібен дамп і для неї - інакше тести не зберуть схему.
- Інструменти. Для дампу потрібні
mysqldumpчиpg_dumpна машині, де виконується команда. - Наявні середовища не зачіпаються: продакшен уже має всі ці міграції в таблиці
migrations. - Дані в міграціях. Якщо старі міграції наповнювали довідники, після стискання цих даних у новій базі не буде - їх переносять у сідери.
Альтернатива без стискання - тримати міграції, але прискорити тести: RefreshDatabase мігрує базу один раз на прогін і далі працює транзакціями, тож кількість міграцій впливає лише на старт.