Переклад статті Zero-Downtime Database Migrations in Laravel & Node.js SaaS: Expand-Contract & Lock Timeouts автора Muhammad Ali Mughal (31 жовтня 2026). Частини, що стосуються лише Node.js, ми прибрали.
Більшість аварій SaaS-застосунків після розгортання, які мене кличуть лагодити, спричиняє не поганий код застосунку. Їх спричиняє міграція, яка на рев'ю виглядала нешкідливою: ALTER TABLE orders ADD COLUMN ... NOT NULL DEFAULT ..., перейменування або новий індекс на таблиці у 40 мільйонів рядків. Міграція чекає на блокування, за нею вишиковуються всі запити, пул з'єднань заповнюється, і перевірка стану (health check) починає падати на всіх серверах одночасно.
Ми супроводжуємо Laravel-застосунки на PostgreSQL і MySQL для клієнтів, які не можуть собі дозволити вікно обслуговування. У цій статті - підхід, яким ми користуємося: міграції за патерном expand-contract, тайм-аут блокування на кожну DDL-операцію, пакетне заповнення даних і порядок розгортання, за якого старий і новий код працюють з однією схемою одночасно.
Як запускати міграції бази даних без простою?
Коротка відповідь: розбивайте кожну несумісну зміну схеми на фази розширення (expand) і скорочення (contract), щоб старий і новий код могли працювати з однією схемою. Ставте короткий lock_timeout на кожну DDL-операцію і повторюйте спробу замість того, щоб ставати в чергу, будуйте індекси конкурентно, заповнюйте дані невеликими пакетами поза міграцією і видаляйте старі стовпці лише тоді, коли жоден запущений процес їх уже не читає.
Чому «маленькі» міграції кладуть продакшн
Небезпечна частина міграції - рідко її тривалість. Небезпечно те, яке блокування вона запитує і що вже тримає блокування на цій таблиці.
У PostgreSQL більшість форм ALTER TABLE потребують блокування ACCESS EXCLUSIVE. Воно конфліктує з усім, навіть зі звичайним SELECT. Якщо довгий запит звіту або воркер у стані idle in transaction тримає навіть слабке блокування на orders, ваш ALTER чекає. Поки він чекає, він стоїть першим у черзі блокувань, і кожен новий SELECT по orders стає в чергу за ним. Міграція, яка мала б тривати 20 мілісекунд, перетворюється на двохвилинний повний простій кожної сторінки, що працює із замовленнями.
У MySQL 8 схожа проблема з блокуваннями метаданих (MDL). Онлайн-DDL (ALGORITHM=INPLACE або INSTANT) обходиться без копіювання таблиці, але на початку й наприкінці все одно потребує короткого ексклюзивного блокування метаданих, і цей запит стає в чергу за будь-якою відкритою транзакцією на таблиці.
Ось як поводяться типові операції в сучасних версіях:
| Операція |
PostgreSQL 14+ |
MySQL 8.0 (InnoDB) |
Безпечний підхід |
| Додати nullable-стовпець |
Миттєво, коротке ексклюзивне блокування |
INSTANT, коротке MDL |
Безпечно з lock_timeout |
| Додати стовпець зі сталим значенням за замовчуванням |
Миттєво (PG 11+) |
INSTANT (8.0.12+) |
Безпечно з lock_timeout |
Додати NOT NULL до наявного стовпця |
Повне сканування таблиці під ексклюзивним блокуванням |
Перебудова таблиці |
Додати CHECK ... NOT VALID, перевірити, потім встановити |
| Створити індекс |
Блокує записи |
INPLACE, записи дозволені |
CREATE INDEX CONCURRENTLY / ALGORITHM=INPLACE, LOCK=NONE |
| Перейменувати стовпець |
Миттєво, але ламає запущений код |
Миттєво, але ламає запущений код |
Expand-contract з новим стовпцем |
| Змінити тип стовпця |
Зазвичай повний перезапис |
Копіювання таблиці |
Новий стовпець + заповнення + заміна |
| Додати зовнішній ключ |
Сканує таблицю під блокуванням |
Може перебудувати |
NOT VALID, потім VALIDATE CONSTRAINT |
| Видалити стовпець |
Миттєво |
INSTANT (8.0.29+) |
Лише після того, як код перестав його читати |
Підхід в останньому стовпці щоразу той самий: ніколи не змушуйте базу виконувати довгу операцію, тримаючи сильне блокування, і ніколи не вносьте зміну, якої не переживе код, що зараз розгорнутий.
Патерн expand-contract крок за кроком
Скажімо, нам треба перейменувати customers.phone на customers.phone_e164 і примусити нормалізований формат. Пряме перейменування ламає кожен вебсервер і воркер черги, які під час поступового розгортання ще виконують попередній випуск. Expand-contract перетворює це на п'ять розгортань:
- Розширення: додати
phone_e164 як nullable-стовпець. Код його ще не читає.
- Подвійний запис: випустити код, який пише і в
phone, і в phone_e164, але читає досі phone.
- Заповнення: скопіювати й нормалізувати наявні рядки пакетами із завдання в черзі, а не з міграції.
- Перемикання читання: випустити код, який читає
phone_e164 і досі пише в обидва стовпці, щоб відкат лишався безпечним.
- Скорочення: перестати писати в
phone, дочекатися, поки завершаться всі старі процеси, і видалити стовпець.
На папері це виглядає повільно. На практиці кроки 1 і 2 виходять разом, а між кроками 4 і 5 минає день або тиждень. Натомість кожне окреме розгортання можна відкотити, не чіпаючи даних.
Тайм-аут блокування на кожну DDL-операцію
Найдієвіша зміна, яку ми запровадили в клієнтських проєктах, - відмова запускати DDL без тайм-ауту блокування. Якщо блокування недоступне за кілька секунд, міграція швидко падає, розгортання повторює спробу, і ніхто нічого не помічає.
У Laravel-міграції на PostgreSQL:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
DB::statement("SET lock_timeout = '3s'");
DB::statement("SET statement_timeout = '15s'");
Schema::table('customers', function (Blueprint $table) {
$table->string('phone_e164', 20)->nullable();
});
}
public function down(): void
{
DB::statement("SET lock_timeout = '3s'");
Schema::table('customers', fn (Blueprint $t) => $t->dropColumn('phone_e164'));
}
};
Для MySQL відповідник - SET SESSION lock_wait_timeout = 3; перед операцією. У скрипті розгортання обгорніть php artisan migrate --force циклом повторів на три-п'ять спроб з короткою паузою, щоб одна напружена мить не провалила весь випуск.
Індекси без блокування записів
CREATE INDEX CONCURRENTLY не можна виконати всередині транзакції, а Laravel за замовчуванням загортає міграції PostgreSQL у транзакцію. Тож встановіть public $withinTransaction = false; у класі міграції:
return new class extends Migration
{
public $withinTransaction = false;
public function up(): void
{
DB::statement("SET lock_timeout = '3s'");
DB::statement('CREATE INDEX CONCURRENTLY IF NOT EXISTS customers_phone_e164_idx ON customers (phone_e164)');
}
public function down(): void
{
DB::statement('DROP INDEX CONCURRENTLY IF EXISTS customers_phone_e164_idx');
}
};
Якщо конкурентна побудова обірветься на півдорозі, PostgreSQL залишить по собі індекс у стані INVALID. Знайдіть його запитом SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;, видаліть і запустіть побудову знову. Порядок стовпців у складених індексах при цьому так само важливий - про це автор писав у статті Composite Indexes in PostgreSQL & MySQL: Column Order Guide.
Заповнення даних - у завданнях, а не в міграціях
Міграція, яка виконує UPDATE customers SET phone_e164 = ... на 12 мільйонах рядків, тримає блокування рядків на весь час виконання, роздуває WAL і може загальмувати репліки. Натомість поставте в чергу завдання, яке проходить первинним ключем пакетами:
// app/Jobs/BackfillPhoneE164.php
public function handle(): void
{
DB::table('customers')
->whereNull('phone_e164')
->whereNotNull('phone')
->orderBy('id')
->chunkById(1000, function ($rows) {
foreach ($rows as $row) {
DB::table('customers')
->where('id', $row->id)
->whereNull('phone_e164')
->update(['phone_e164' => PhoneNormalizer::toE164($row->phone)]);
}
usleep(50_000); // даємо реплікам і autovacuum перепочити
});
}
Умова whereNull робить завдання ідемпотентним: його можна повторити або запустити двічі, і воно не перезапише значень, які вже встановив код подвійного запису. Запускайте його в окремій черзі з низькою конкурентністю - тут повністю застосовні прийоми зі статті автора Running Laravel Queues in Production. Поки завдання працює, стежте за відставанням реплік і зменшуйте розмір пакета, якщо відставання перевищує кілька секунд.
NOT NULL без довгого блокування
Коли заповнення завершено, хочеться, щоб база сама гарантувала заповненість стовпця. ALTER COLUMN ... SET NOT NULL сканує всю таблицю під ексклюзивним блокуванням. У PostgreSQL 12+ спершу додайте обмеження CHECK як NOT VALID, окремо перевірте його (перевірка бере слабше блокування, яке дозволяє і читання, і запис), і тоді SET NOT NULL стає майже миттєвим, бо PostgreSQL використовує перевірене обмеження як доказ:
SET lock_timeout = '3s';
ALTER TABLE customers ADD CONSTRAINT customers_phone_e164_nn CHECK (phone_e164 IS NOT NULL) NOT VALID;
-- окрема міграція, може тривати хвилини, записів не блокує
ALTER TABLE customers VALIDATE CONSTRAINT customers_phone_e164_nn;
SET lock_timeout = '3s';
ALTER TABLE customers ALTER COLUMN phone_e164 SET NOT NULL;
ALTER TABLE customers DROP CONSTRAINT customers_phone_e164_nn;
Той самий прийом - NOT VALID, потім VALIDATE - працює і для зовнішніх ключів.
Порядок поступового розгортання
У поступовому розгортанні на Kubernetes, у Forge з розгортанням без простою чи у схемі blue/green старий і новий код співіснують від кількох секунд до кількох хвилин. Про воркери черги зазвичай забувають: воркер Horizon може виконувати попередній випуск стільки, скільки триває його найдовше завдання. Правило, якого ми дотримуємося:
- Міграції розширення запускаються до того, як новий код почне приймати трафік. Старий код мусить витримувати нову схему.
- Міграції скорочення запускаються в одному з наступних випусків, після того як
php artisan queue:restart повністю відпрацював.
- Ніколи не змішуйте розширення і скорочення в одному випуску. Якщо потрібно і те, і те, - це два випуски.
Якщо ви розгортаєтеся на одному VPS, правила ті самі - просто перекриття коротше.
Чекліст міграції перед злиттям
- Чи кожна DDL-операція встановлює
lock_timeout (PostgreSQL) або lock_wait_timeout (MySQL)?
- Чи може розгорнутий зараз код працювати з новою схемою? Якщо ні - розбийте зміну.
- Чи будуються індекси через
CONCURRENTLY або з ALGORITHM=INPLACE, LOCK=NONE, поза транзакцією?
- Чи заповнення даних винесене в пакетне ідемпотентне завдання, а не в міграцію?
- Чи додаються
NOT NULL і зовнішні ключі як NOT VALID з окремою перевіркою?
- Чи безпечно запускати метод
down(), чи план відкату - «розгорнути попередній код»?
- Чи перевірили ви
pg_stat_activity на довгі сесії та сесії idle in transaction перед розгортанням?
- Чи є задача з датою на скорочення, щоб старий стовпець справді видалили?
Поширені запитання
Чи варто використовувати pt-online-schema-change або gh-ost у MySQL?
Для таблиць на десятки мільйонів рядків, де зміна все ще потребує копіювання таблиці, - так. gh-ost читає binlog замість тригерів, тож менше навантажує основний сервер. Для INSTANT-операцій у MySQL 8 вони не потрібні.
Чи взагалі безпечно запускати php artisan migrate під час розгортання?
Так, якщо кожна міграція дотримується правил вище і запускається один раз, до того як нові сервери почнуть приймати трафік. Використовуйте --isolated (Laravel 9.24+), щоб два хости розгортання не змагалися за запуск тієї самої міграції.
Який тайм-аут блокування ставити?
Для більшості SaaS-навантажень підходять дві-п'ять секунд. Достатньо довго, щоб отримати блокування у звичайний момент, і достатньо коротко, щоб користувачі ледь помітили запити, які стоять у черзі, поки міграція чекає.
Як перейменувати стовпець, від якого залежить модель?
Під час фази подвійного запису модель і далі читає старий атрибут - через аксесор або відображення назви стовпця, - а у випуску з перемиканням читання ви змінюєте це відображення. Аксесори Eloquent роблять це чисто.
Чи змінюється підхід для застосунків із кількома орендарями, де в кожного орендаря своя схема?
Патерн той самий, але кожна міграція запускається N разів. Обмежуйте темп між орендарями, відстежуйте прогрес для кожного з них і робіть кожну міграцію ідемпотентною, щоб частковий запуск можна було продовжити.
Як перевірити, що міграція справді безпечна?
Відновіть свіжий знімок продакшн-бази на staging, запустіть генератор навантаження, потім запустіть міграцію і стежте за pg_locks та затримкою p99. Те, що міграція безпечна на порожній базі розробника, не говорить нічого.
Підсумок
Безпечна міграція - це не швидка міграція, а та, що не тримає сильного блокування довше за кілька секунд і не ламає код, який зараз працює. Тайм-аут блокування на кожну DDL-операцію, expand-contract для несумісних змін, індекси без блокування записів і заповнення даних у завданнях черги закривають більшість сценаріїв, за яких розгортання кладе продакшн.
Частину небезпечних міграцій можна зловити ще до розгортання автоматично - для цього є лінтер Difflock, про який ми вже писали.