---
title: "Міграції бази даних без простою в Laravel: expand-contract і lock_timeout"
url: https://laravelukraine.com/blog/migraciyi-bazi-danix-bez-prostoiu-v-laravel-expand-contract-i-lock-timeout
date: 2026-11-11
source: https://dev.to/muhammadalimughal/zero-downtime-database-migrations-in-laravel-nodejs-saas-expand-contract-lock-timeouts-6lp
---

# Міграції бази даних без простою в Laravel: expand-contract і lock_timeout

*Переклад статті [Zero-Downtime Database Migrations in Laravel & Node.js SaaS: Expand-Contract & Lock Timeouts](https://dev.to/muhammadalimughal/zero-downtime-database-migrations-in-laravel-nodejs-saas-expand-contract-lock-timeouts-6lp) автора 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 перетворює це на п'ять розгортань:

1. **Розширення:** додати `phone_e164` як nullable-стовпець. Код його ще не читає.
2. **Подвійний запис:** випустити код, який пише і в `phone`, і в `phone_e164`, але читає досі `phone`.
3. **Заповнення:** скопіювати й нормалізувати наявні рядки пакетами із завдання в черзі, а не з міграції.
4. **Перемикання читання:** випустити код, який читає `phone_e164` і досі пише в обидва стовпці, щоб відкат лишався безпечним.
5. **Скорочення:** перестати писати в `phone`, дочекатися, поки завершаться всі старі процеси, і видалити стовпець.

На папері це виглядає повільно. На практиці кроки 1 і 2 виходять разом, а між кроками 4 і 5 минає день або тиждень. Натомість кожне окреме розгортання можна відкотити, не чіпаючи даних.

### Тайм-аут блокування на кожну DDL-операцію

Найдієвіша зміна, яку ми запровадили в клієнтських проєктах, - відмова запускати DDL без тайм-ауту блокування. Якщо блокування недоступне за кілька секунд, міграція швидко падає, розгортання повторює спробу, і ніхто нічого не помічає.

У Laravel-міграції на PostgreSQL:

```php
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;` у класі міграції:

```php
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](https://dev.to/muhammadalimughal/composite-indexes-in-postgresql-mysql-column-order-guide-22p2).

### Заповнення даних - у завданнях, а не в міграціях

Міграція, яка виконує `UPDATE customers SET phone_e164 = ...` на 12 мільйонах рядків, тримає блокування рядків на весь час виконання, роздуває WAL і може загальмувати репліки. Натомість поставте в чергу завдання, яке проходить первинним ключем пакетами:

```php
// 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](https://dev.to/muhammadalimughal/running-laravel-queues-in-production-redis-supervisor-retries-and-failed-jobs-278m). Поки завдання працює, стежте за відставанням реплік і зменшуйте розмір пакета, якщо відставання перевищує кілька секунд.

### NOT NULL без довгого блокування

Коли заповнення завершено, хочеться, щоб база сама гарантувала заповненість стовпця. `ALTER COLUMN ... SET NOT NULL` сканує всю таблицю під ексклюзивним блокуванням. У PostgreSQL 12+ спершу додайте обмеження `CHECK` як `NOT VALID`, окремо перевірте його (перевірка бере слабше блокування, яке дозволяє і читання, і запис), і тоді `SET NOT NULL` стає майже миттєвим, бо PostgreSQL використовує перевірене обмеження як доказ:

```sql
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, правила ті самі - просто перекриття коротше.

### Чекліст міграції перед злиттям

1. **Чи кожна DDL-операція встановлює `lock_timeout` (PostgreSQL) або `lock_wait_timeout` (MySQL)?**
2. **Чи може розгорнутий зараз код працювати з новою схемою?** Якщо ні - розбийте зміну.
3. **Чи будуються індекси через `CONCURRENTLY` або з `ALGORITHM=INPLACE, LOCK=NONE`, поза транзакцією?**
4. **Чи заповнення даних винесене в пакетне ідемпотентне завдання, а не в міграцію?**
5. **Чи додаються `NOT NULL` і зовнішні ключі як `NOT VALID` з окремою перевіркою?**
6. **Чи безпечно запускати метод `down()`, чи план відкату - «розгорнути попередній код»?**
7. **Чи перевірили ви `pg_stat_activity` на довгі сесії та сесії idle in transaction перед розгортанням?**
8. **Чи є задача з датою на скорочення, щоб старий стовпець справді видалили?**

### Поширені запитання

#### Чи варто використовувати 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](https://laravelukraine.com/blog/difflock-linter-migracii-laravel-ta-instrument-dlia-perevirki-zmin-sxemi-bazi-danix), про який ми вже писали.
