Laravel migration, которая на локальной машине отрабатывает за секунду, на проде может повесить таблицу на несколько минут под блокировкой, а то и уронить сайт в даунтайм. Я сталкивался с этим на боевых базах, где ALTER TABLE без параметров держал таблицу заказов заблокированной на запись, пока клиенты жали кнопку «оплатить» и получали ошибку. Дальше - конкретные приёмы, которые снижают этот риск: как добавлять колонки без блокировки, как переименовывать и менять тип полей без потери данных, как переносить большие объёмы данных и что делать, если миграция на проде упала на середине.
Как Laravel выполняет миграции
Каждая миграция - это класс с методами up() и down(), которые Laravel компилирует в SQL через Schema Builder. Команда php artisan migrate применяет все миграции, которых ещё нет в таблице migrations, и группирует их в один batch. Это важно: rollback откатывает не одну миграцию, а весь последний batch, поэтому если в один деплой попало пять миграций, откат снесёт все пять разом.
Перед тем как гнать миграцию на прод, я всегда смотрю, какой SQL она реально сгенерирует:
php artisan migrate --pretend
php artisan migrate:status
- pretend не выполняет миграцию, а печатает запросы, которые она бы отправила в базу. Это дёшево и почти всегда показывает то, чего не видно в коде миграции: например, что ALTER TABLE зацепит больше колонок, чем ожидалось, или что индекс создаётся не с тем именем. На проде миграции запускаю с флагом - force - без него Laravel в production-окружении переспрашивает подтверждение в интерактивном режиме, а в CI просто зависнет без ответа.
Добавление колонок и индексов без блокировки таблицы
Самая безопасная операция - добавить nullable-колонку или колонку со значением по умолчанию:
Schema::table('orders', function (Blueprint $table) {
$table->string('status', 20)->default('new')->after('total');
});
В MySQL 8 добавление колонки в конец таблицы со значением по умолчанию чаще всего выполняется через INSTANT DDL - это операция с изменением метаданных, без копирования строк, поэтому таблица не блокируется даже под нагрузкой. Но это не универсальное правило: добавление колонки в середину таблицы, добавление колонки с уникальным индексом или колонки, требующей пересчёта существующих строк, может потребовать полного копирования таблицы. На таблице в пару миллионов строк это ощутимая пауза, поэтому перед деплоем такой миграции стоит хотя бы прикинуть размер таблицы и проверить SQL через - pretend.
С индексами то же самое: добавление индекса на колонку с высокой кардинальностью на большой таблице держит блокировку дольше, чем кажется по коду миграции. Если таблица активно пишется, добавляю индекс отдельной миграцией, не вперемешку с изменением данных, чтобы легче было понять, что именно вызвало задержку при деплое.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
Переименование и изменение типа колонки: экспанд-контракт
Переименование колонки напрямую - самый частый источник потерянных данных не в базе, а в приложении. Пока прошёл деплой новой версии кода на все воркеры и очереди, часть запросов ещё обращается к старому имени колонки, которого уже нет. Метод есть:
Schema::table('users', function (Blueprint $table) {
$table->renameColumn('name', 'full_name');
});
Начиная с Laravel 11 renameColumn() и метод change() для изменения типа колонки работают через нативную обработку схемы для MySQL, PostgreSQL и SQLite и не требуют пакета doctrine/dbal, как это было в более старых версиях фреймворка. Но сама физическая проблема одновременного деплоя от версии не зависит.
Как делаю на практике
Вместо одной миграции с renameColumn() разбиваю изменение на три шага:
- Миграция 1: добавляю новую колонку рядом со старой (nullable или с default).
- Отдельная команда или job переносит данные из старой колонки в новую чанками, а код приложения на время переходного периода пишет в обе колонки.
- Миграция 2, которая едет отдельным деплоем после того, как весь трафик перешёл на новый код: старая колонка дропается.
Это и есть экспанд-контракт: сначала расширяем схему, потом переключаем код, потом сокращаем схему. Дольше по срокам, зато ни одна версия приложения не падает на несуществующей колонке и данные не теряются в промежутке между релизами.
То же самое касается изменения типа колонки методом change(), и здесь есть отдельная ловушка: при изменении колонки надо заново перечислить все модификаторы, которые должны остаться, любой неуказанный атрибут будет сброшен. Поэтому unsigned, default, nullable и комментарий выписываю в вызов явно, иначе они молча исчезнут вместе с ALTER:
Schema::table('products', function (Blueprint $table) {
$table->decimal('price', 10, 2)->unsigned()->default(0)->change();
});
Если сужаем тип (например, из decimal(10,2) в decimal(8,2) или из text в varchar с ограничением длины), есть риск, что часть значений не влезет и MySQL их обрежет или откажется выполнять ALTER. Перед такой миграцией прогоняю SELECT, который находит строки, не проходящие по новым границам, и разбираюсь с ними отдельно, а не полагаюсь на то, что база сама аккуратно всё подрежет.
Удаление колонок и таблиц: как подстраховаться
DROP COLUMN и DROP TABLE необратимы на уровне данных - down() в миграции восстановит структуру колонки, но не значения, которые в ней были. Поэтому для миграций, которые реально удаляют данные на проде, у меня правило: сначала бэкап, потом миграция.
mysqldump -u user -p --single-transaction dbname orders > orders_backup_$(date +%Y%m%d).sql
- single-transaction нужен, чтобы дамп таблиц InnoDB снялся консистентно, без блокировки на запись во время бэкапа. Дамп делаю не только целиком базы (это может занять долго на больших проектах), но и точечно по таблице, которую трогает миграция - так быстрее проверить, что откат вообще возможен.
Если колонку удаляю не потому что она больше не нужна, а потому что переезжаю на новую структуру - сначала гоняю код без обращений к старой колонке минимум один релизный цикл, смотрю логи на ошибки, и только потом дропаю её отдельной миграцией.
Большие таблицы: чанкование данных и порядок операций
Одна из ошибок, которая приводит к зависшим деплоям - тяжёлая операция с данными прямо внутри миграции. Например, UPDATE, который проставляет значение по умолчанию во все строки таблицы на пару миллионов записей. MySQL держит блокировку на всё время выполнения, и пока она не снята, деплой стоит.
Вместо этого такие операции переношу в отдельную artisan-команду и обрабатываю чанками:
DB::table('orders')
->whereNull('status')
->orderBy('id')
->chunkById(500, function ($orders) {
foreach ($orders as $order) {
DB::table('orders')
->where('id', $order->id)
->update(['status' => 'new']);
}
});
chunkById() выбирает строки пачками по 500 и не держит одну гигантскую транзакцию открытой. Схему меняю в одной миграции (добавляю nullable-колонку), а перенос данных запускаю отдельно, желательно в тихий час по трафику, а не в момент деплоя. Так миграция схемы остаётся быстрой и предсказуемой, а тяжёлая часть работы не блокирует выкатку кода.
Если объём данных такой, что даже чанкованный перенос занимает часы, такие задачи по переносу и очистке данных на бэкенде Laravel обычно делаю в рамках отдельной услуги - бэкенд на Laravel, где можно заложить время на постепенный перенос без давления в виде окна деплоя.
Откат миграций и проверка перед деплоем на прод
php artisan migrate:rollback - step=1 откатывает миграции последнего batch по одной. Но откат работает только если down() написан честно и симметрично up() - на практике часто вижу пустые или неполные down(), которые в момент реального отката просто ничего не делают или падают с ошибкой на несуществующей колонке.
Перед тем как отправить миграцию в прод, у меня обычно такой чек-лист:
| Операция | Риск на проде | Как снижаю риск |
|---|---|---|
| Добавление nullable-колонки или с default | Низкий, обычно быстрая операция | Проверяю SQL через migrate - pretend |
| Добавление NOT NULL-колонки без default | Упадёт на существующих строках | Сначала добавляю с default, потом ужесточаю |
| Переименование колонки | Старый код сломается на несуществующем имени | Экспанд-контракт в два деплоя |
| Изменение типа колонки | Часть значений может не пройти по новым границам | Проверяю данные SELECT-запросом заранее |
| Удаление колонки или таблицы | Необратимая потеря данных | Бэкап конкретной таблицы перед миграцией |
| Массовый UPDATE в миграции | Долгая блокировка, зависший деплой | Выношу в отдельную команду с chunkById |
Ещё одна вещь, которая экономит нервы - прогон миграции на копии прод-базы перед релизом, а не только на локальной SQLite или на пустой тестовой базе. Пустая база не покажет блокировку на полутора миллионах строк и не покажет строки, которые не проходят по новым ограничениям типа.
Laravel регулярно обновляется, поэтому перед внедрением я сверяюсь с актуальной документацией.
Частые вопросы
Как посмотреть, какой SQL выполнит миграция, не запуская её на проде?
Команда php artisan migrate - pretend печатает запросы, которые сгенерирует миграция, без фактического выполнения. Это первое, что проверяю перед любым деплоем со схемными изменениями - иногда Schema Builder генерирует не тот SQL, который ожидаешь по коду миграции.
Что делать, если миграция на проде упала посередине?
DDL-операции в MySQL не транзакционны, поэтому если ALTER TABLE упал на середине, часть изменений уже может быть применена, а запись в таблице migrations не появится. Сначала смотрю php artisan migrate:status и реальную структуру таблицы через DESCRIBE, сравниваю с тем, что должна была сделать миграция, и либо доделываю недостающую часть вручную, либо пишу новую миграцию, которая доводит схему до нужного состояния с проверкой на существование колонки или индекса.
Нужно ли включать режим обслуживания на время миграции?
php artisan down переводит сайт в режим обслуживания и имеет смысл для миграций, которые физически держат блокировку на таблице несколько секунд и дольше - лучше показать пользователю страницу с сообщением, чем отдавать ошибки 500 на реальных запросах. Для быстрых миграций (nullable-колонка, индекс на некрупной таблице) обычно обхожусь без даунтайма, полагаясь на экспанд-контракт вместо прямых разрушающих изменений.
Как тестировать миграции перед тем, как катить их на боевую базу?
Прогоняю миграцию на копии продовой базы, а не на пустой тестовой - дамп с mysqldump разворачиваю в staging-окружении, накатываю миграцию, проверяю время выполнения и результат через migrate:status, и только после этого включаю её в релиз. На пустой базе многие проблемы с блокировками и некорректными данными просто не воспроизводятся.