MAATRIX / Блог / Миграции накатились в разном порядке на двух серверах: разбор

Миграции накатились в разном порядке на двух серверах: разбор

MAATRIX

Разработчик мержит фичу, локально всё работает, тесты зелёные — а на проде запрос падает с ошибкой «column does not exist», хотя миграция, которая эту колонку добавляет, по всем признакам уже применена. Копают дальше и выясняют неприятное: на проде и на staging одни и те же миграции применились в разном порядке, и в результате это не одна и та же схема, хотя обе базы «думают», что они в актуальном состоянии. Разберём, почему так случается, как это диагностировать без веры на слово таблице применённых миграций и что сделать, чтобы не наступать на эти грабли повторно.

Как выглядит проблема на практике

Классическая картина: два сервера — например, боевой и разработческая копия для тестов — исторически получали миграции не синхронно. На одном что-то накатили руками во время инцидента, на другом — через обычный CI/CD пайплайн, но с других веток, слитых в разном порядке. Формально в обеих базах в таблице учёта миграций стоят одни и те же номера или имена файлов как «применено». По факту структура таблиц отличается: где-то есть колонка, а индекса под неё нет, где-то внешний ключ ссылается на таблицу, которая на другом сервере была переименована другой миграцией.

Симптомы обычно всплывают не сразу и не явно:

  • запрос падает с column "x" does not exist там, где по коду колонка обязана быть;
  • приложение на одном сервере работает, на другом — падает на том же коммите;
  • миграция, запущенная повторно, ругается «уже применена», хотя эффект в схеме не виден;
  • внешний ключ или уникальный индекс есть на одном сервере и отсутствует на другом, хотя миграция, которая его создаёт, отмечена как выполненная на обоих;
  • бэкап, восстановленный на другую машину, после наката «недостающих» миграций даёт ошибки о конфликте типов или дублирующих объектах.

Ключевая ловушка в том, что таблица истории миграций (schema_migrations, flyway_schema_history, django_migrations, alembic_version — название зависит от инструмента) фиксирует факт «эта миграция была запущена», но не гарантирует, что порядок запуска совпадал с порядком, который предполагал автор кода. Если миграция B создана после миграции A, но по случайности накатилась раньше — на выходе может получиться структура, которую никто не проектировал и не тестировал.

Откуда берётся рассинхронизация порядка

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

Параллельная разработка без координации. Два разработчика одновременно создают миграции в отдельных ветках — скажем, 2026_08_20_add_column_x и 2026_08_21_add_index_y. Локально у каждого своя последовательность, тесты проходят у обоих. Ветки мержатся в master не в том порядке, в каком создавались файлы, и если инструмент упорядочивает их не строго по временной метке (а, например, по алфавиту или по порядку мержа), CI накатит их в одном порядке, а разработчик, тянувший ветки в другой последовательности локально, — в другом.

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

Несколько сред с независимым деплоем. Прод обновляется по одному расписанию, staging — по другому или по требованию. Если между обновлениями накопилось несколько новых миграций и применяются они не атомарно одним прогоном, а частями, есть шанс, что на одной среде часть миграций из середины очереди применится раньше тех, что формально шли перед ними — потому что кто-то вручную догонял «недостающие» файлы не по порядку.

Расхождение таблицы истории и фактической схемы. Восстановление базы из бэкапа, сделанного в промежуточный момент, ручной INSERT в таблицу учёта миграций «чтобы CI не ругался», откат через DROP TABLE без отметки в истории — любое из этого создаёт ситуацию, где записи о применённых миграциях больше не отражают реальную структуру.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Практический подход к предотвращению

Здесь нет универсального рецепта под конкретный инструмент — Flyway, Liquibase, Alembic, Django migrations, Rails ActiveRecord или самописный раннер решают задачу похожими средствами, детали отличаются. Смысл общий: должен существовать единый, автоматизированный механизм, который явно отслеживает, какие миграции применены и в каком порядке, и который не позволяет применить их иначе, чем задумано.

Практические принципы, которые работают независимо от конкретного инструмента:

  • Один источник правды на порядок. Порядок миграций должен определяться детерминированно — обычно временной меткой в имени файла (20260825120000_add_column.sql) или явной цепочкой зависимостей (ссылка на предыдущую ревизию, как down_revision в Alembic). Алфавитный порядок без временной метки — плохая идея: 10_x.sql окажется раньше 2_y.sql.
  • Миграции — только через раннер, никогда руками. Соблазн «быстро поправить» через psql напрямую на проде должен пресекаться процессом: экстренное изменение схемы оформляется отдельной миграцией и катится тем же механизмом, что и обычные.
  • Один пайплайн деплоя для всех окружений. Staging и прод должны получать миграции из одного и того же CI/CD прогона или как минимум по одной команде в одинаковой последовательности, а не независимыми ручными запусками в разное время разными людьми.
  • Блокировка при параллельном применении. Зрелые инструменты миграций (Flyway, Liquibase, Django) берут блокировку на таблице истории на время применения, чтобы два одновременных запуска не переплели порядок. Самописный раннер должен реализовать это явно, например через pg_advisory_lock в PostgreSQL.
  • Координация при параллельной разработке. Когда две ветки одновременно правят одну область схемы, порядок стоит согласовывать явно — договориться, кто мержит первым, или свести оба изменения в одну миграцию с ревью. Часть инструментов (Alembic) требует указывать родительскую ревизию — тогда конфликт порядка виден при мерже как конфликт файлов, а не как тихое расхождение в рантайме.
  • Ревью миграций как обычного кода — с обязательным вопросом, что произойдёт, если файл применится не первым в очереди, а вторым или третьим.

У большинства инструментов есть флаг --dry-run или аналог, показывающий, что и в каком порядке будет применено до реального запуска на PostgreSQL или другой СУБД — включайте это как обязательный шаг перед деплоем на прод.

Диагностика: не верьте таблице истории на слово

Главная методическая ошибка при расследовании такой проблемы — начинать с вопроса «какие миграции отмечены как применённые». Этот вопрос отвечает не на то, что реально нужно узнать. Таблица истории говорит, что раннер думает о состоянии базы, а не то, какая структура там на самом деле — а именно расхождение между этими двумя вещами и есть причина инцидента.

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

Шаг 1. Снять фактическую схему с обоих серверов. Для PostgreSQL:

pg_dump --schema-only --no-owner --no-privileges \
  -h prod-host -U app_user -d app_db > schema_prod.sql

pg_dump --schema-only --no-owner --no-privileges \
  -h staging-host -U app_user -d app_db > schema_staging.sql

Для MySQL/MariaDB похожая идея через mysqldump --no-data, для других СУБД — соответствующий аналог экспорта DDL без данных.

Шаг 2. Сравнить дампы. Простой diff уже многое покажет, но чувствителен к порядку объектов в дампе и к незначимым различиям (комментарии, порядок GRANT):

diff <(sort schema_prod.sql) <(sort schema_staging.sql)

Для более осмысленного сравнения удобнее специализированные инструменты сравнения схем — например migra для PostgreSQL, который выдаёт готовый SQL-патч, приводящий одну схему к другой:

pip install migra[pg]
migra postgresql://user@staging-host/app_db postgresql://user@prod-host/app_db

Вывод migra — это фактически список того, что реально разошлось: недостающие колонки, индексы, constraints, отличающиеся типы данных. Это и есть настоящая картина расхождения, в отличие от списка «применённых» миграций.

Шаг 3. Сравнить таблицу истории миграций отдельно.

SELECT version, applied_at FROM schema_migrations ORDER BY applied_at;

(название таблицы и колонок зависит от инструмента — flyway_schema_history, django_migrations, alembic_version). Здесь смотрите не только на список версий, но и на порядок по времени применения — если он отличается между серверами, это прямое подтверждение гипотезы про порядок.

Шаг 4. Сопоставить два результата. Если migra (или diff) не показал различий в структуре, а таблицы истории отличаются по порядку — тревога ложная, реальной проблемы в схеме нет, хотя стоит всё равно синхронизировать историю. Если структуры разошлись — вот конкретные объекты, с которыми нужно разбираться, независимо от того, что написано в истории.

Как исправить уже возникшее расхождение

Когда список расхождений на руках (из migra или ручного diff), дальше — аккуратное сведение схем, а не повторный прогон всех миграций подряд в надежде, что «доедет само».

  1. Заморозить изменения схемы на обоих серверах — пока идёт исправление, никто не должен катить новые миграции, иначе цель будет двигаться.
  2. Определить целевое состояние. Обычно это схема продакшна: она обслуживает реальный трафик, менять её рискованнее, поэтому staging приводят к ней. Но если прод получил «на скорую руку» правки в обход миграций, правильнее сделать целью staging или третью эталонную копию и уже её накатить на прод по всем правилам.
  3. Сгенерировать патч приведения схем к единому состоянию. Тот же migra может сразу выдать применимый SQL:
migra postgresql://user@staging-host/app_db postgresql://user@prod-host/app_db \
  --unsafe > fix_staging_schema.sql

Флаг --unsafe разрешает включать потенциально деструктивные операции (например, DROP COLUMN) — обязательно прочитать сгенерированный SQL целиком до выполнения.

  1. Прогнать патч на тестовой копии, а не сразу на живом сервере. Разверните копию боевой базы на отдельном сервере и примените патч там — посмотреть, не ломает ли он данные и не требует ли долгой блокировки больших таблиц.
  2. Исправить таблицу истории миграций. Если обе схемы приведены к одному состоянию, а история всё ещё расходится по порядку записей — вручную выровняйте записи так, чтобы список применённых версий и их порядок совпадали. Это правка метаданных, а не миграция схемы — делайте её с полным пониманием, что каждая строка означает для раннера.
  3. Регресс-тестирование — прогнать интеграционные тесты и ключевые бизнес-сценарии на исправленной схеме, прежде чем считать инцидент закрытым, и задокументировать, какая миграция и в каком порядке привела к расхождению.

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

Что делать, если восстановленный бэкап не совпадает со «свежей» базой

Отдельный частый случай — не два постоянно работающих сервера, а бэкап, поднятый на новой машине для тестов или disaster recovery. Бэкап снят в момент X, к моменту восстановления накопилось ещё N миграций, их накатывают на восстановленную копию, чтобы «догнать» прод. Если хотя бы одна из них написана без учёта того, что до неё в истории могут быть записи из другого порядка применения — получите ту же рассинхронизацию, но по вине временного зазора между бэкапом и текущим состоянием.

Практическое правило: после восстановления из бэкапа и наката недостающих миграций сравнение фактических схем через pg_dump --schema-only и migra должно быть обязательным чек-пунктом, а не опциональным — совпадения списков «применённых» миграций для этого недостаточно. Общий чек-лист проверки после переноса или восстановления базы разобран отдельно в материале о том, как проверить, что миграция прошла успешно. Если восстановленная копия останется в работе как staging или реплика для чтения — сразу подключите её к общему пайплайну миграций, а не оставляйте разовым снимком, который потом обновляют вручную.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

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

Можно ли просто пересоздать таблицу истории миграций с нуля, раз она не отражает реальность?

Только после того, как фактическая схема сверена и приведена к нужному состоянию. Пересоздание истории без предварительной сверки DDL прячет проблему, а не решает её: раннер снова будет думать, что всё синхронно, хотя структура может остаться расходящейся.

Что делать, если разошлись ещё и сами имена миграций — двое разработчиков создали миграции с одинаковым номером?

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

Есть ли способ автоматически предотвратить накат миграций не по порядку?

Да — большинство инструментов по умолчанию отказываются применять миграцию с версией ниже уже применённой, если явно не разрешить «out-of-order» — это разрешение стоит держать выключенным на проде. Проблема обычно не в самом инструменте, а в обходе его вручную.

Как часто сверять схемы прод/staging, если процесс уже настроен правильно?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →