Проект достался с работающей базой. Сорок таблиц, часть создана скриптами, часть — руками в клиенте, история миграций отсутствует как понятие. Приложение работает, данные ценные, останавливать нельзя.
Первый же запуск миграций попытается создать таблицу, которая уже есть, и упадёт. Разбираемся, как войти в эту воду правильно.
Что вообще происходит
Doctrine хранит список выполненных миграций в служебной таблице. Команда применения сравнивает файлы миграций с этим списком и выполняет то, чего в нём нет.
На legacy-базе таблицы нет вовсе, поэтому «нет ничего» означает «выполнить всё с начала». Задача — сообщить системе, что стартовое состояние уже достигнуто.
Базовая линия
Базовая линия — это миграция, которая описывает уже существующую схему, плюс запись о том, что на legacy-базе она выполнена. Порядок здесь важен: сначала создать и проверить файл миграции, и только потом регистрировать его версию. Если сделать наоборот, новый файл останется невыполненным и при следующем деплое попробует создать существующие таблицы.
Когда baseline-файл готов и в каталоге миграций нет более новых версий, его можно отметить выполненным без запуска:
php bin/console doctrine:migrations:version --add --all --no-interactionПеред этим я синхронизирую структуру служебной таблицы:
php bin/console doctrine:migrations:sync-metadata-storage --no-interactionversion --add --all проходит по всем найденным файлам и записывает их в служебную таблицу, не выполняя запросы из миграций. Поэтому команду нельзя запускать, пока в каталоге лежит хоть одна версия, которой в этой базе на самом деле нет.
Обёрнуто в цель Makefile с явным предупреждением:
db-baseline-mark: ## [!] Отметить все миграции выполненными БЕЗ запуска (для legacy-базы)
@printf "Только для базы с уже существующей схемой. Продолжить? [y/N] " && read a && [ "$$a" = "y" ]
$(PHP) php bin/console doctrine:migrations:version --add --all --no-interactionОпасность очевидна: запустить это на пустой базе означает получить приложение, которое считает схему созданной при её отсутствии. Отсюда подтверждение и восклицательный знак в справке.
Честная первая миграция
Одной записи в служебной таблице недостаточно — нужен файл миграции, соответствующий текущей схеме. Иначе новый разработчик развернёт проект с нуля и получит пустую базу.
Doctrine умеет построить миграцию из маппинга так, будто исходная схема пуста:
php bin/console doctrine:migrations:diff --from-empty-schemaЕсли установленная версия команды не поддерживает этот флаг, тот же результат можно получить через временное соединение с пустой базой. В обоих случаях порядок один: сгенерировать baseline, прочитать SQL, дополнить отсутствующие объекты, вернуть соединение на legacy-базу и только после этого отметить версию выполненной.
Полученный файл нужно прочитать глазами. Doctrine видит только то, что описано в маппинге. Всё, чего в сущностях нет, — представления, функции, триггеры, частичные индексы, права — в сгенерированную миграцию не попадёт.
У меня в тот раз потерялись три вещи: частичный индекс на таблице значений, представление для отчётов и расширение для полнотекстового поиска. Дописал руками в тот же файл.
Проверка расхождений
После базовой линии обязателен контрольный вопрос: а совпадает ли маппинг с тем, что реально в базе?
php bin/console doctrine:schema:update --dump-sqlКоманда показывает запросы, которые привели бы схему в соответствие с сущностями. Пустой вывод — всё сходится. Непустой — расхождение, и его надо разобрать до первого релиза.
На той базе вывод был на семьдесят строк. Половина оказалась косметикой: длина строковых полей, значения по умолчанию, порядок колонок в индексах. Вторая половина — настоящими расхождениями, включая колонку, которой в сущности не было вовсе, а в базе она использовалась приложением через сырой SQL.
На этой живой базе я использую schema:update только для просмотра запросов. Автоматическое выполнение способно удалить колонку, о которой не знает маппинг.
Правила безопасных миграций
Схема меняется, пока приложение работает. Отсюда три правила.
Добавление колонки обычно безопасно, если она допускает пустое значение. В PostgreSQL начиная с 11-й версии колонка с неизменчивым значением по умолчанию добавляется без переписывания таблицы. Для volatile-выражений вроде random() оптимизация не работает, а краткая блокировка ACCESS EXCLUSIVE нужна в любом случае.
Переименование делается в три шага и три релиза:
-- релиз 1: добавить новую колонку, писать в обе
ALTER TABLE candidate ADD COLUMN full_name VARCHAR(255);
-- релиз 2: перенести данные, читать из новой
UPDATE candidate SET full_name = name WHERE full_name IS NULL;
-- релиз 3: удалить старую
ALTER TABLE candidate DROP COLUMN name;Между релизами приложение работает с обеими колонками. Соблазн сделать это одной миграцией велик, и он ломает выкладку, если старая и новая версии кода хотя бы несколько секунд работают одновременно. Для моей схемы деплоя это обычная ситуация.
Удаление — только после того, как убедились, что колонку никто не читает. Поиск по коду недостаточен: остаются сырые запросы, представления и отчёты.
Индекс на большой таблице создаётся конкурентно:
CREATE INDEX CONCURRENTLY idx_issue_project ON wm_issue (project_id);Обычное создание блокирует запись на всё время построения. На таблице в полмиллиона строк это полминуты недоступности.
Тут есть подвох: конкурентное создание не работает внутри транзакции. Если для миграций включён транзакционный режим, его надо отключить на уровне этого класса:
public function isTransactional(): bool
{
return false;
}Миграции данных отдельно
Изменение схемы и перенос данных — разные вещи с разными свойствами.
Миграцию схемы я стараюсь делать короткой, но скорость и обратимость зависят от операции: перестроение индекса или изменение типа большой колонки тоже может занять минуты. Перенос данных отдельно сложнее контролировать, он требует памяти и обратного хода обычно не имеет.
Я держу их в разных файлах и в разных релизах. Перенос данных вообще чаще делаю консольной командой, а не миграцией:
php bin/console app:backfill-full-name --batch=1000Причины две. Команду можно запустить повторно после сбоя, а миграция после падения оставит систему в неопределённом состоянии. И команду можно гонять партиями с паузами, не держа одну длинную транзакцию.
Про откат
Метод обратной миграции в большинстве случаев — фикция.
Он честно работает для добавления колонки: удалить обратно легко. Для удаления колонки — нет: данные уже потеряны, и восстановить структуру без содержимого бессмысленно.
Я перестал делать вид, что откат существует, и пишу в необратимых миграциях явный отказ:
public function down(Schema $schema): void
{
$this->throwIrreversibleMigrationException('Удаление колонки необратимо, восстанавливайте из бэкапа');
}Это честнее пустого метода, который создаёт иллюзию возможности откатиться. Реальный способ вернуться назад один — восстановление из резервной копии, и именно он должен быть проверен заранее.
Статус как первая команда при проблеме
php bin/console doctrine:migrations:statusПоказывает, сколько миграций всего, сколько применено, есть ли выполненные версии без файлов и файлы без выполнения.
«Выполнена, но файла нет» означает откат кода без отката базы — обычно после переключения веток. «Файл есть, не выполнена» — забытый шаг деплоя.
Я запускаю эту команду первой всякий раз, когда приложение падает на запросе к чему-то отсутствующему. В восьми случаях из десяти ответ находится там.
Итог
Вход в legacy-базу занял день. Отметка базовой линии — минуту, генерация первой миграции — час, разбор семидесяти строк расхождений — весь остаток.
Самым полезным оказался последний этап. Расхождения между маппингом и реальной схемой копились годами, и половина странностей в поведении приложения объяснялась именно ими.