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

Локальные тесты обычно исходят из базы данных, созданной с нуля или обновлённой мгновенно. Такой сценарий исключает переход: неполные исторические данные, миллионы строк, блокировки, постоянные соединения и асинхронных потребителей. На первый взгляд незначительное изменение может вызвать ошибки или деградацию.
- Переименование или удаление столбца ломает запросы, ORM-мапперы, отчёты и процессы, всё ещё использующие прежнее имя.
- Добавление ограничения
NOT NULLзавершается ошибкой, если существуют старые строки без значения или если компонент записи ещё не знает о новом поле. - Изменение типа может усечь значения, изменить сравнения, сделать индексы недействительными или вызвать дорогостоящие преобразования.
- Создание индекса или переписывание большой таблицы может удерживать блокировки и увеличивать задержку обычных операций.
- Массовое обновление в одной транзакции может исчерпать журнал транзакций, конкурировать за ресурсы или усложнить репликацию.
Ключевой вопрос: какие версии кода могут читать и записывать каждое представление данных на протяжении всего окна развёртывания? Ответ должен включать исполняемые компоненты, которые не перезапускаются автоматически, а не только HTTP-запросы.
Временная совместимость между кодом, данными и процессами
Во время постепенного развёртывания должны быть совместимы как минимум три состояния: старый код, новый код и данные в старом, новом или частично преобразованном формате. Совместимость не обязательно означает, что каждый потребитель должен всегда понимать все форматы; она состоит в определении ограниченного окна, в котором предсказуемые комбинации работают.
Например, при замене full_name на first_name и last_name не следует удалять исходное поле в начале. Новая версия может записывать оба формата и сначала читать новые поля, когда они заполнены, с явным переходом к старому значению в противном случае. Предыдущая версия продолжает работать с full_name. После преобразования исторических данных и вывода из эксплуатации старых потребителей чтение может зависеть только от новой структуры.
Не допускайте, чтобы временная совместимость была распределена по контроллерам. Централизуйте чтение, запись и нормализацию в доменном сервисе или репозитории. Так можно проверить, какая версия формата создаётся, какое значение имеет приоритет и когда следует удалить временную логику. Шаблон миграции не заменяет эту модель совместимости: шаблон выполняет изменения; модель определяет поведение приложения во время перехода.
Паттерн «расширить, мигрировать и удалить»
1. Расширить, не делая недействительными текущих потребителей
Первая фаза добавляет возможности, не удаляя существующие: столбец, допускающий NULL, новую таблицу, дополнительный индекс или параллельную структуру. Следует избегать деструктивных изменений и, когда этого требует движок, планировать метод создания для сокращения блокировок. Добавление столбца не означает, что безопасно немедленно задать значение по умолчанию, пересчитать все строки или объявить его обязательным.
Перед выполнением операции проверьте размер таблицы, наиболее частые запросы, внешние ключи, доступное пространство, нагрузку репликации и особенности поведения движка базы данных. Проведите репетицию на репрезентативной копии или в среде с сопоставимыми объёмом и конкурентностью. Также определите наблюдаемые пределы: длительность, допустимую задержку, частоту ошибок и условие отмены.
2. Развернуть совместимые компоненты чтения и записи
Затем развёртывается код, который понимает оба представления. Новые компоненты записи могут выполнять двойную запись, если это допускают стоимость и согласованность. Компоненты чтения должны устанавливать однозначный приоритет: читать новое значение, если оно валидировано; в противном случае использовать старое. Не используйте исключение как механизм перехода к альтернативному значению, поскольку оно скрывает дефекты данных и добавляет ненужную работу в критический путь.
Двойная запись требует явных решений. Если обновление затрагивает обе структуры, определите, должно ли оно выполняться в одной транзакции. Если это невозможно, спроектируйте идемпотентный процесс сверки и метрики для обнаружения расхождений. События, кэши, API и экспорты также являются потребителями: изменение только PHP-репозитория не гарантирует сквозную совместимость.
3. Мигрировать исторические данные с возможностью возобновления
После включения совместимого кода преобразуйте существующие записи небольшими пакетами. Каждый пакет должен допускать повторное выполнение без дублирования эффектов или повреждения данных. Используйте стабильный ключ или сохраняемый курсор, ограничения размера, журналирование прогресса и контролируемые повторы. Не используйте пагинацию со смещениями для изменяющихся наборов, так как она может пропускать или повторно обрабатывать строки.
$lastId = 0; // Для положительного и возрастающего первичного ключа.
while (true) {
$rows = $repository->findPendingAfterId($lastId, 500);
if ($rows === []) {
break;
}
foreach ($rows as $row) {
$repository->migrateIfNeeded($row);
$lastId = $row->id;
}
}Этот паттерн требует, чтобы findPendingAfterId() возвращал строки, отсортированные по возрастанию по тому же ключу, который используется как курсор. Курсор начинается со значения, предшествующего первому допустимому идентификатору, и продвигается только после обработки каждой строки; завершение зависит от того, что запрос не возвращает ни одного пакета. При возобновлённом запуске подтверждённое значение $lastId должно сохраняться. migrateIfNeeded() должен проверять текущее состояние и давать тот же результат при повторном выполнении.
Измеряйте количество ожидающих строк, преобразованных строк, ошибок валидации и различий между форматами. Не объявляйте фазу завершённой только потому, что таблица пройдена: также проверьте ссылочную целостность, уникальность, агрегированные бизнес-показатели и выборки критических записей.
4. Переключить чтения, наблюдать и удалить
Когда исторические данные полностью обработаны и старые процессы перестали выполняться, переключите чтение на использование исключительно новой структуры. Эта активация может быть постепенной через контролируемую конфигурацию, но её не следует путать с развёртыванием: развёртывание делает код доступным; активация изменяет путь чтения, используемый трафиком.
Наблюдайте за ошибками запросов, неожиданными полями со значением NULL, функциональными расхождениями, временем ответа и состоянием обработчиков. Только после определённого окна наблюдения удаляйте двойную запись, временные зависимости и, наконец, старый столбец, индекс или таблицу. Бессрочное сохранение устаревших структур повышает неоднозначность и стоимость; их преждевременное удаление устраняет простой путь восстановления.
Значения NULL, типы, ограничения и индексы без остановки работы
Новый столбец обычно начинается как допускающий NULL, поскольку исторические записи ещё не содержат его. Приложение должно рассматривать отсутствие значения как предусмотренное состояние, а не как невозможный случай. После завершения и проверки заполнения исторических данных можно наложить ограничение при условии, что все активные компоненты записи предоставляют допустимое значение.
Для изменений типа создайте новый столбец и преобразуйте значения явным образом. Это позволяет выявлять неконвертируемые значения, применять правила округления или нормализации и сравнивать оба результата перед заменой предыдущего столбца. Прямое изменение типа может быть подходящим в ограниченных случаях, но его следует обосновать поведением движка, объёмом и совместимостью запросов.
Индексы требуют аналогичного анализа. Новый индекс может улучшить чтение, но его построение потребляет ресурсы, а неподходящая стратегия создания может блокировать запись. Проверьте план выполнения запроса, которому он нужен; не добавляйте индексы интуитивно. Если движок предлагает режимы создания с меньшими блокировками, изучите их требования и ограничения до включения в план.
Откат: откат кода не всегда означает откат данных
Операционный откат следует разделять на решения. Пока существует старая структура и двойная запись, обычно возможно вернуться к предыдущему коду. Но если новый формат принял информацию, которую старая модель не может представить, отмена схемы не восстановит семантику этих данных.
- Обратимый: отключить новое чтение и вернуться к старому значению, сохраняя обе структуры.
- Компенсируемый: исправить или восстановить данные из определённого источника с аудируемым процессом.
- Необратимый: удалить структуру или принять преобразования, теряющие точность без сохранения оригинала.
Документируйте точку невозврата, ответственного за её авторизацию, необходимые копии или экспорты и процедуру приостановки обработчиков. Метод down() в инструменте миграций сам по себе не является планом отката: он может отменить DDL, но не гарантирует валидность данных, записанных во время перехода.
Тесты, доказательства и чек-лист

Проверьте матрицу совместимости: старый код с расширенной схемой, новый код с ещё не мигрированными данными, новый код с преобразованными данными и асинхронные процессы со смешанными версиями. Включите прерванные и возобновлённые миграции, недопустимые записи, конкурентную запись и восстановление предыдущей версии, когда это применимо.
- Инвентаризировать затронутые таблицы, запросы, обработчики, интеграции и отчёты.
- Определить временный контракт чтения и записи, включая значения NULL и приоритеты.
- Разделить расширение, совместимое развёртывание, заполнение исторических данных, активацию и удаление на независимые шаги.
- Оценить влияние DDL, индексов и пакетов на репрезентативных данных.
- Сделать процесс обработки данных идемпотентным, возобновляемым и измеримым.
- Установить проверки целостности и последующие пороги наблюдения.
- Документировать откат, компенсации и точку невозврата.
- Удалять старую совместимость и структуру только при наличии доказательств отсутствия потребителей.
При дисциплинированном применении этот паттерн превращает высокорисковое изменение базы данных в проверяемую последовательность. Ключ в том, чтобы проектировать сосуществование как часть продукта и эксплуатации, а не как скрытую деталь внутри миграции.



