Новое в журнале. Миграции базы данных без даунтайма: что ломает прод, а что безопасно
Практика и проекты

Миграции базы данных без даунтайма: что ломает прод, а что безопасно

Добавление nullable-колонки и индекс через CREATE INDEX CONCURRENTLY почти всегда безопасны. Удаление, переименование колонки или смена типа без expand-contract — почти всегда блокируют прод. Главное правило: в момент деплоя старая и новая версии кода работают одновременно, и схема обязана обслуживать обе.

27 октября 20269 мин чтенияПрактикам
Миграции базы данных без даунтайма: что ломает прод, а что безопасно
4 фазыпаттерн expand-contract
0 простояцель любой миграции схемы

Команда выкатывает, казалось бы, безобидный ALTER TABLE — и сайт отваливается на сорок секунд. Знакомая история: миграция тестировалась на локальной базе с пустой таблицей, а на проде та же команда встаёт в очередь на блокировку позади долгой транзакции и тащит за собой весь трафик к этой таблице. Разница между «миграция прошла незаметно» и «миграция положила прод» — не в самой SQL-команде, а в том, что происходит вокруг неё: какая блокировка берётся, сколько она держится и что уже стоит перед ней в очереди.

Если миграции без даунтайма у вас в проекте разовая головная боль, а не рутина — возможно, дело не в технике, а в процессе: когда команда, которая закладывает безопасные миграции базы данных в процесс разработки веб-приложений, ведёт проект от архитектуры до релиза, такие миграции планируются заранее, а не тушатся в два часа ночи.

Почему ALTER TABLE иногда кладёт прод

Большинство операций изменения схемы требуют блокировки — вопрос в том, какой именно и насколько долгой. PostgreSQL для многих вариантов ALTER TABLE берёт блокировку ACCESS EXCLUSIVE: на время операции таблица недоступна ни для чтения, ни для записи. В MySQL до версии 8 значительная часть DDL-операций выполнялась через полное пересоздание таблицы — все строки копировались во временную таблицу, и время операции было пропорционально объёму данных.

Сама по себе короткая блокировка не страшна: на таблице в тысячу строк ACCESS EXCLUSIVE держится миллисекунды. Проблема — очередь. Если на таблице уже выполняется долгая транзакция (аналитический запрос, незакоммиченная операция), миграция встаёт в очередь на блокировку позади неё. А пока миграция ждёт — за ней в очередь встают все новые запросы к этой таблице, включая обычные SELECT. На нагруженном проекте это превращает миллисекундную операцию в минуты простоя не потому, что миграция сложная, а потому что перед ней случайно оказалась одна медленная транзакция.

Главное правило: обратная совместимость между фазами деплоя

Rolling deployment — стандарт для любого сервиса с несколькими инстансами: новые экземпляры приложения выкатываются постепенно, и несколько секунд или минут старая и новая версия кода работают параллельно с одной и той же базой. Миграция схемы, которая ломает старый код, ломает часть трафика на всё это окно — даже если новый код полностью готов к новой структуре.

Отсюда простое практическое следствие: миграция БД и деплой кода, который на неё опирается, — это минимум два отдельных релиза, а не один шаг. Сначала схема меняется так, чтобы старый код продолжал работать: что-то добавляется, но ничего не ломается. Потом выкатывается код, который использует новую структуру. И только когда старый код гарантированно нигде не остался — убирается то, что он использовал. Это и есть логика expand-contract, которая ниже разобрана по шагам для каждой конкретной операции.

Четыре операции и как сделать их безопасными

Не все операции над схемой одинаково рискованны. Вот четыре самые частые — с риском и безопасным приёмом для каждой.

ОперацияРискБезопасный приём
Добавление колонкиЛок и возможный полный rewrite таблицы при non-null значении по умолчанию на старых версиях СУБДДобавить как nullable без default, заполнить значения отдельным батч-шагом, затем включить NOT NULL отдельной миграцией
Удаление колонкиСтарый код, который ещё читает или пишет колонку, падает с ошибкойСначала убрать все обращения из кода и выкатить, и только потом удалить колонку отдельной миграцией
Переименование / смена типаФактически то же самое, что удаление плюс добавление — ломает совместимость в обе стороныПаттерн expand-contract через промежуточную колонку, а не прямое переименование
Новый индексОбычный CREATE INDEX держит блокировку записи на всё время построенияCREATE INDEX CONCURRENTLY (PostgreSQL) или online DDL (MySQL 8+, PostgreSQL 11+)

Добавление колонки

Безопасная последовательность — три отдельных шага. Сначала колонка добавляется как nullable без значения по умолчанию: это почти мгновенная операция в современных версиях PostgreSQL и MySQL, потому что меняются только метаданные, а не сами данные. Затем значения заполняются отдельным батч-скриптом в фоне. И только после этого отдельной миграцией включается ограничение NOT NULL. Сделать всё одной командой — значит рискнуть тем же полным rewrite таблицы, которого шаги и призваны избежать.

Удаление колонки

Здесь порядок важнее самой операции. Правило простое: сначала код, потом колонка. Нужно убрать все обращения к колонке из кодовой базы, выкатить изменение и убедиться, что на проде не осталось ни одного инстанса со старым кодом — и только тогда удалять колонку отдельной миграцией. Обратный порядок гарантированно ломает любой инстанс, который ещё работает со старой версией кода.

Переименование или смена типа колонки

Прямого безопасного способа переименовать колонку или сменить её тип не существует — это всегда эмулируется паттерном expand-contract в четыре шага:

  1. Добавить новую колонку рядом со старой, с нужным именем или типом.
  2. Настроить запись в обе колонки одновременно (в коде приложения или триггером на уровне БД) и перенести существующие данные.
  3. Перевести чтения на новую колонку отдельным деплоем кода и убедиться, что старая колонка больше нигде не читается.
  4. Убрать запись в старую колонку, затем удалить саму колонку отдельной миграцией.

Четыре шага выглядят избыточно для одной, казалось бы, простой операции — но каждый из них независимо откатываем, и в любой момент между ними старый и новый код продолжают работать корректно.

Абстрактная схема слияния двух слоёв схемы базы данных

Новый индекс без блокировки

Обычный CREATE INDEX берёт блокировку, которая не пускает запись в таблицу на всё время построения индекса — на большой таблице это минуты или часы простоя записи. В PostgreSQL для этого есть CREATE INDEX CONCURRENTLY: индекс строится без блокировки записи ценой того, что операция занимает больше времени и не может выполняться внутри транзакции. В MySQL 8+ и PostgreSQL 11+ аналогичную роль для большинства типов индексов играет online DDL — но конкретную операцию стоит явно сверять с документацией версии: не все изменения схемы поддерживают online-режим одинаково.

Constraint и валидация без простоя

Добавление CHECK- или foreign key-constraint обычно требует полного сканирования таблицы на валидность — та же проблема, что с non-null колонкой: блокировка держится, пока проверяется каждая существующая строка.

Решение — разделить добавление ограничения и его валидацию на два шага. Constraint добавляется с флагом NOT VALID: он сразу начинает действовать для новых и изменяемых строк, но существующие данные не проверяются, и блокировка не берётся. Затем отдельной командой VALIDATE CONSTRAINT запускается сканирование существующих строк — эта операция берёт только блокировку уровня SHARE UPDATE EXCLUSIVE, не блокируя запись.

Разница на практике ощутима: добавление constraint сразу с полной валидацией может держать эксклюзивную блокировку минуты на таблице с миллионами строк. NOT VALID с отложенной VALIDATE CONSTRAINT делает то же самое, не блокируя запись ни на одном из двух шагов.

Большие таблицы: миграция данных в фоне

Backfill или трансформация данных на таблице в десятки миллионов строк не должны выполняться одним UPDATE — такой запрос держит блокировку на всё время выполнения и создаёт резкую нагрузку на репликацию.

Правильный подход — батчами: скрипт берёт небольшую порцию строк, обычно 500–5000 за итерацию, обновляет их, коммитит, ждёт короткую паузу и переходит к следующей порции. Это снижает пиковую нагрузку на БД и держит каждую отдельную блокировку короткой.

Два требования к такому скрипту обязательны. Во-первых, идемпотентность: повторный запуск после сбоя — сети, деплоя, ручной остановки — не должен портить уже обработанные строки; проверка «уже обновлено» в начале каждой итерации это решает. Во-вторых, скрипт должен сам останавливаться при росте replication lag или нагрузки выше заданного порога, а не слепо гнать батчи до конца — иначе фоновая миграция сама становится причиной того даунтайма, которого должна была избежать.

Чем управлять миграциями, если руками уже не вариант

На проекте с несколькими разработчиками и регулярными релизами миграции, применяемые вручную через консоль, рано или поздно расходятся между окружениями — кто-то забыл применить, кто-то применил не в том порядке. Инструменты версионирования схемы вроде Flyway и Liquibase решают именно это: каждая миграция — пронумерованный файл, а инструмент хранит в самой БД таблицу с историей применённых версий и не даст применить миграцию дважды или не по порядку.

Для небольшого проекта с одним-двумя разработчиками отдельный инструмент необязателен — миграций, встроенных во фреймворк (Laravel, Django, Rails и аналоги), обычно достаточно: они решают ту же задачу версионирования, просто ближе к коду приложения. Переходить на отдельный Flyway или Liquibase имеет смысл, когда миграции нужно применять независимо от деплоя кода — например, в архитектуре с несколькими сервисами на одной общей БД.

Чек-лист перед запуском миграции на проде

Перед тем как запускать миграцию на боевой базе, стоит пройтись по короткому списку.

  1. Сделан свежий бэкап или снапшот, восстановление из которого проверено заранее, а не только на бумаге.
  2. Миграция прогнана на копии прода — дампе или staging с сопоставимым объёмом данных, а не на локальной пустой базе.
  3. Для операций над большой таблицей используется CONCURRENTLY / online DDL или батч-скрипт, а не прямая блокирующая команда.
  4. Есть явный план отката — отдельная миграция или команда, которая возвращает схему назад, а не «просто откатим предыдущий коммит».
  5. Миграция и код, который на неё опирается, разведены по совместимости через expand-contract, если меняется или удаляется существующая колонка.
  6. Запуск назначен не на пик нагрузки, а мониторинг блокировок и репликации открыт на всё время выката.

Частые вопросы

Что такое zero-downtime миграция базы данных?

Миграция схемы или данных, которая не прерывает обслуживание запросов и не ломает ни старую, ни новую версию кода, работающие одновременно в момент rolling-деплоя. Достигается разбиением изменения на обратно-совместимые шаги, а не одной командой.

Почему ALTER TABLE иногда кладёт прод, а иногда проходит незаметно?

Разница не в самой команде, а в блокировке вокруг неё: короткий лок на маленькой таблице незаметен, но если перед миграцией в очереди уже стоит долгая транзакция, за миграцией выстраиваются все новые запросы к таблице, включая обычные чтения.

Что такое паттерн expand-contract и когда он обязателен?

Четырёхшаговая схема изменения колонки: добавить новую, писать в обе и перенести данные, перевести чтения на новую, убрать старую. Обязателен для переименования и смены типа колонки — прямого безопасного способа сделать это одной командой не существует.

Нужен ли Flyway или Liquibase для небольшого проекта, или хватит ORM-миграций?

Для одного-двух разработчиков обычно достаточно миграций, встроенных во фреймворк (Laravel, Django, Rails) — они решают ту же задачу версионирования. Отдельный инструмент оправдан, когда миграции нужно применять независимо от деплоя кода, например на нескольких сервисах с общей БД.

Зачем откладывать валидацию constraint, если можно сделать всё сразу?

Добавление constraint с немедленной проверкой всех строк держит блокировку на время полного сканирования таблицы. NOT VALID включает ограничение для новых данных сразу без блокировки, а отдельная команда VALIDATE CONSTRAINT проверяет существующие строки, не блокируя запись.

Что делать, если миграция данных на большой таблице не успевает за окном деплоя?

Разделить её на самостоятельный фоновый процесс вне окна деплоя: идемпотентный батч-скрипт, который можно безопасно останавливать и перезапускать, с отдельным контролем нагрузки на БД и replication lag.

Код, который не стыдно передать

Начните с принципов, а дальше двигайтесь по рубрикам. Каждый разбор заканчивается чек-листом.

Случайный разбор