Классический сценарий аварии при выкатке: релизный пайплайн прошел, тесты зеленые, миграция применилась за 40 миллисекунд. Через 10 секунд мониторинг взрывается пятисотыми ошибками, а воркеры очередей падают пачками. Причина — временной зазор между накатыванием схемы в базе и моментом, когда приложение реально переключилось на новый код.
С этой проблемой сталкиваются не только владельцы Kubernetes-кластеров. Она стреляет на любом обычном сервере с Deployer, Capistrano или Docker Compose, где миграция накатывается до переключения релиза, а в фоне молотят долгоживущие очереди. Разберем, как именно база и ORM реагируют на несовместимые правки схемы, как устроен трехфазный паттерн Parallel Change и как отсекать опасные миграции еще на этапе CI.
Анатомия сбоя: почему падают сервисы и очереди
Возьмем типовую задачу: переименовать поле phone в mobile_number в таблице users.
ALTER TABLE users RENAME COLUMN phone TO mobile_number;
В локальном окружении запрос отрабатывает мгновенно. Но на проде код не обновляется в ту же миллисекунду, что и база данных. Зазор возникает в трех типовых ситуациях:
- Атомарный деплой через симлинки (Deployer, Envoy, Capistrano): Стандартный шаг пайплайна накатывает миграции в релизной директории
releases/123до того, как симлинкcurrentпереключится на новый релиз. Пока идут сборка ассетов, прогрев кэша и перезапуск PHP-FPM, старый релиз продолжает обслуживать входящие HTTP-запросы. База для него уже изменилась, и он падает с ошибкой:SQLSTATE[42703]: Undefined column: "phone" does not exist. - Долгоживущие воркеры очередей (Queue Workers, Supervisor, Horizon): Воркеры очередей загружают код в память один раз при старте. Даже если веб-трафик переключился моментально, демоны очередей прямо сейчас докручивают пачку тяжелых джобов со старыми моделями в памяти. Пока не дойдет команда
queue:restart, любой инсерт или селект в измененную таблицу валит воркер в аварийный перезапуск. - Плавный перезапуск инстансов (Rolling Update): Если за балансировщиком крутится несколько серверов или контейнеров, они обновляются по очереди. В переходный период (от 30 секунд до пары минут) старые и новые версии приложения работают с одной базой данных одновременно.
Вторая проблема — поведение ORM. Большинство фреймворков (Eloquent в Laravel, Doctrine, Django ORM) при записи или выборке генерируют запросы со всеми полями таблицы или кэшируют карту схемы. Если старый инстанс пытается сохранить пользователя, он пытается вставить значение в несуществующую колонку phone. Если же колонка уже удалена, ломаются любые операции записи.
Третья скрытая проблема — эксклюзивные блокировки. Любой ALTER TABLE в PostgreSQL требует блокировку AccessExclusiveLock. Она конфликтует даже с обычными SELECT. Если в момент миграции в базе крутится долгий фоновый отчет, миграция встает в очередь за блокировкой, а все последующие пользовательские запросы к таблице встают в очередь за миграцией. Пул соединений мгновенно исчерпывается.
Паттерн Parallel Change (Expand and Contract)
Единственный надежный способ менять схему живой базы без простоя — никогда не делать несовместимых изменений за один релиз. Изменение всегда разбивается на три независимых этапа, разнесенных во времени.
Фаза 1: Расширение (Expand)
Цель — подготовить базу и код к приему новых данных, не ломая обратную совместимость с работающим кодом.
- Миграция базы: Добавляем новую колонку
mobile_numberкак nullable (или с валидным дефолтным значением). Старую колонкуphoneне трогаем. - Код (Dual-Write): Выкатываем версию кода, которая пишет сразу в обе колонки. Читает приложение пока еще из старой колонки
phone, но любые обновления и вставки дублируются вmobile_number.
Пример в модели Laravel (аксессор / мутатор или хук события):
protected static function booted(): void
{
static::saving(function (User $user) {
if ($user->isDirty('phone') && !$user->isDirty('mobile_number')) {
$user->mobile_number = $user->phone;
}
});
}
На этом этапе старые воркеры и процессы спокойно пишут в phone, а новый код поддерживает консистентность обеих колонок.
Фаза 2: Миграция данных и переключение чтения (Migrate)
Когда релиз с Dual-Write стабильно работает на 100% трафика, переносим исторические данные.
- Фоновый backfill: Нельзя обновлять миллионы строк одним
UPDATE users SET mobile_number = phone— это заблокирует строки и раздует WAL/репликацию. Бэкфилл гоняется чанками через фоновые очереди или консольную команду:
User::whereNull('mobile_number')
->whereNotNull('phone')
->chunkById(1000, function ($users) {
foreach ($users as $user) {
$user->updateQuietly(['mobile_number' => $user->phone]);
}
usleep(50000); // 50ms пауза для снижения нагрузки на реплики
});
- Переключение чтения: Следующим релизом код переводится на чтение из
mobile_number. Запись по-прежнему идет в оба поля на случай экстренного отката релиза.
Фаза 3: Сжатие и очистка (Contract)
Заключительный этап выполняется только тогда, когда код с чтением из новой колонки успешно отработал в проде хотя бы один релизный цикл, а очереди и логи чисты от обращений к старому полю.
- Удаление Dual-Write из кода: Выкатываем версию, которая полностью забывает про существование поля
phone. - Удаление колонки из БД: Отдельной миграцией удаляем старое поле:
ALTER TABLE users DROP COLUMN phone;
Если объединить Фазу 1 и Фазу 3 в один MR/PR — весь смысл изоляции теряется.
Защита на уровне CI/CD: отсекаем опасные миграции
Надеяться на то, что разработчик в спешке вспомнит про Parallel Change, нельзя. Деструктивные операции должны блокироваться на этапе сборки в пайплайне (GitLab CI / GitHub Actions).
Для статического анализа SQL-миграций используются специализированные линтеры — например, Atlas (atlas migrate lint) или пакеты уровня strong_migrations.
Что обязан перехватывать CI-гейт:
- Любые вызовы
DROP TABLEиDROP COLUMN; - Переименование колонок и таблиц (
RENAME COLUMN/RENAME TABLE); - Добавление колонки с ограничением
NOT NULLбез указанияDEFAULT; - Изменение типа колонки с сужением размера (например,
VARCHAR(255)вVARCHAR(100)); - Создание индексов без флага
CONCURRENTLY(для Postgres).
Пример шага в GitHub Actions с проверкой схемы через Atlas:
jobs:
lint-migrations:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Atlas
run: curl -sSf https://atlasgo.sh | sh
- name: Run Migration Lint
run: |
atlas migrate lint --dir "file://database/migrations" --dev-url "docker://postgres/16/test" --latest 1
Если разработчик добавил деструктивную миграцию в обычный продуктовый тикет, CI падает, блокируя слияние в main.
Регламент исключений: ручной апрув (Human-in-the-Loop)
Очевидно, что Фазу 3 (удаление старой колонки) выполнить все равно придется. Чтобы пайплайн не блокировал плановую очистку, внедряется механизм авторизованных исключений:
- Миграция очистки выносится в отдельный MR/PR, помеченный специальным лейблом (например,
database:breaking-change). - В кодовой базе миграция сопровождается явным маркером подавления линтера или конфигурацией игнорирования.
- В пайплайне шаг применения таких миграций блокируется правилом
manual(ручной триггер в интерфейсе CI), требующим подтверждения от тимлида или DBA.
Правила работы с блокировками в продакшене
Кроме самого разделения полей, при накатывании DDL-запросов на живую базу действуют два обязательных инженерных правила:
1. Жесткий lock_timeout
Перед любым изменением схемы сессия мигратора должна выставлять короткий таймаут ожидания блокировки:
SET lock_timeout = '2s';
ALTER TABLE users ADD COLUMN mobile_number VARCHAR(32);
Если таблица занята тяжелой транзакцией, миграция упадет через 2 секунды с ошибкой таймаута, но не соберет за собой очередь из сотен заблокированных пользовательских запросов. Пайплайн может повторить попытку через пару минут.
2. Индексы только через CONCURRENTLY
Обычный CREATE INDEX блокирует запись (INSERT/UPDATE/DELETE) в таблицу на все время построения дерева индекса. На миллионных объемах это означает гарантированный даунтайм сервиса на десятки минут. В Postgres создание индекса всегда выполняется конкурентно:
CREATE INDEX CONCURRENTLY idx_users_mobile_number ON users (mobile_number);
Учитывайте, что CREATE INDEX CONCURRENTLY нельзя выполнять внутри транзакционного блока (BEGIN ... COMMIT), поэтому в фреймворках для таких миграций отключают автоматические транзакции.