Инструменты миграции Go для безопасных изменений схемы в реальных командах
Инструменты миграции Go сильно различаются по откатам, блокировкам, использованию в CI и структуре репозитория. В этом обзоре показано, на что смотреть, прежде чем команда выберет свой вариант.

Почему изменения схемы быстро превращаются в хаос
Изменение схемы может выглядеть крошечным на бумаге и всё равно сорвать спокойный деплой. Команда добавляет одну новую колонку со значением по умолчанию ещё до обеда. Код приложения готов, тесты проходят, и изменение кажется безобидным. Потом база держит блокировку дольше, чем ожидалось, записи замедляются, и релиз встаёт, пока все смотрят на дашборды.
Команды обычно боятся миграций больше, чем изменений кода, по простой причине: код откатывается проще, чем данные. Если релиз пошёл не так, старый код приложения можно развернуть заново за минуты. А вот удалённую колонку, неудачный бэкфилл или изменение типа не всегда можно вернуть без потери данных или остановки трафика.
Напряжение растёт, когда два сервиса используют одну базу. Сервис A сегодня начинает записывать новое поле. Сервис B всё ещё читает старую форму и ломается на null, пропущенных значениях или изменившемся смысле данных. В каждом сервисе вроде бы никто особо ничего не менял, но порядок деплоя внезапно становится важным, и одно тихое изменение базы превращается в проблему для нескольких команд.
Вот почему работа с базой кажется менее прощающей, чем обычная разработка. Важны сроки. Важно поведение блокировок. Старые и новые версии часто работают одновременно. Миграция может пройти в staging и всё равно навредить в production, потому что там больше строк, больше трафика и меньше права на ошибку.
Команды, которые выпускают изменения без лишнего шума, стремятся к предсказуемым релизам. Они делают маленькие шаги, какое-то время держат старый и новый код совместимыми и запускают одни и те же миграции локально, в CI и в production. Инструменты миграции Go могут помочь, но цель проще: сделать изменения схемы настолько обычным делом, чтобы никому не приходилось затаивать дыхание перед деплоем.
Инструменты, которые действительно стоит сравнивать
Большинство Go-команд в итоге смотрят на одни и те же четыре названия: goose, golang-migrate, Atlas и dbmate. Все они решают одну базовую задачу, но подталкивают команды к разным привычкам. А это важнее длинной таблицы с функциями.
Если вашей команде нравится писать SQL вручную и держать слой миграций тонким, goose и dbmate покажутся знакомыми. Если вам нужен более строгий формат файлов, который каждый раз ведёт себя одинаково, golang-migrate часто проще стандартизировать. Atlas идёт дальше, чем обычный runner миграций. Он добавляет правила схемы, планирование и сгенерированные diff'ы. Одни команды любят такую структуру. Другим она кажется тяжелее, чем нужно.
- goose остаётся близко к SQL, но при этом позволяет писать миграции на Go, если SQL уже не хватает. Обычно он хранит шаги up и down в одном файле, разделяя их комментариями goose.
- golang-migrate прост и прямолинеен. Обычно он ожидает пары файлов вроде
.up.sqlи.down.sql, поэтому намерение отката хорошо видно в ревью кода. - Atlas добавляет больше структуры. Он не строит процесс вокруг вручную написанных пар up и down. Команды часто используют его для генерации упорядоченных SQL-миграций из заданного состояния схемы.
- dbmate простой и SQL-first. Как и goose, он хранит секции up и down в одном файле, и многим небольшим командам это удобно читать и поддерживать.
Небольшая команда с одним Postgres-приложением может долго и спокойно работать с goose или dbmate. Файлы легко просматривать, и никому не нужно долго разбираться в инструменте. В репозитории с несколькими сервисами golang-migrate часто ощущается аккуратнее, потому что имена файлов строгие, а поведение легко автоматизировать.
Atlas больше подходит, когда дрейф схемы уже мешает, или когда один человек постоянно вручную исправляет ошибки миграций. Он требует от команды больше, но может предотвратить много грязной работы с базой в будущем.
Чем отличается поддержка откатов
Поддержка откатов важнее всего в плохой день релиза. Миграция уже отправлена в production, приложение начало выдавать ошибки, и команде нужно быстро решить: откатить изменение схемы или срочно выкатить фикс вперёд.
В повседневной работе поддержка отката обычно означает одно из двух. Либо инструмент умеет запускать явную down-миграцию для каждого шага, либо команда считает миграции только движением вперёд и исправляет ошибки следующей миграцией.
Down-миграции или только вперёд
Явные down-миграции кажутся безопаснее, потому что дают прямой выход. Если миграция только добавляет индекс, создаёт таблицу или добавляет nullable-колонку, down-файл может отлично сработать и заметно снизить стресс.
Но эта безопасность может быть обманчивой. Down-миграция может отменить SQL, но не всегда отменяет состояние приложения вокруг неё. Если один сервис уже пишет в новую колонку или старый путь кода уже исчез, откат базы может сломать систему по-другому.
Подходы только вперёд строже. Вы не делаете вид, что старое состояние легко восстановить. Если миграция вызывает проблемы, вы пишете новую миграцию, которая чинит схему или оставляет старый и новый код совместимыми ещё на один релиз.
Многие команды, которые используют инструменты миграции Go, в production в итоге предпочитают именно движение вперёд, даже если инструмент поддерживает down-файлы. В моменте это медленнее, но обычно лучше соответствует тому, как работают реальные системы.
Где откаты помогают, а где нет
Откаты лучше всего работают для маленьких изолированных изменений:
- добавить или убрать индекс
- создать новую таблицу, от которой пока ничего не зависит
- добавить колонку, которую пока не использует код
- исправить опечатку в constraint или default
Они становятся рискованными, когда миграция меняет живые данные. Бэкфиллы, разделение колонок, переименования и преобразование типов редко откатываются чисто. Как только скрипт перезаписал значения, объединил поля или удалил старые данные, прежняя форма может исчезнуть навсегда.
Вот почему откат миграции базы звучит проще, чем есть на самом деле. Для изменений только в схеме это может быть удобно. Для миграций данных безопаснее обычно поэтапные релизы, бэкапы и проверенный путь исправления вперёд.
Что говорит о поведении блокировок
Инструмент миграций, который берёт блокировку базы до начала изменений, обычно безопаснее того, который просто сразу начинает работу. Блокировка не даёт двум процессам одновременно менять схему — именно так команды получают наполовину применённые изменения и беспорядочные деплои.
Представьте два задания деплоя, которые стартуют с разницей в несколько секунд. Одно приходит из merge в main. Другое — из ручного повторного запуска после нестабильного теста. Если у инструмента есть понятный шаг блокировки, одно задание работает, а другое ждёт или выходит. Если нет, оба могут попытаться добавить одну и ту же колонку, создать один и тот же индекс или записать конфликтующие строки в таблицу миграций.
Поэтому поведение блокировок важнее в CI, чем на ноутбуке. Локальный запуск обычно означает одного человека, одну команду, одну базу. В CI задания пересекаются постоянно. Production ещё менее терпим, потому что повторные попытки, перезапуски и лишние воркеры могут запустить одну и ту же миграцию дважды.
Когда вы сравниваете инструменты миграции Go, проверьте несколько простых вещей:
- Блокирует ли инструмент базу до чтения и применения ожидающих миграций?
- Ждёт ли второй job, падает быстро или продолжает работу без блокировки?
- Если job аварийно завершается, как убрать зависшую блокировку?
- Видны ли события блокировки в логах?
Зависшие блокировки заслуживают отдельного внимания. Инструмент, который оставляет блокировку после падения, может заблокировать все следующие деплои, пока кто-то не снимет её вручную. В разработке это ещё терпимо. В production в 2 часа ночи это уже серьёзная проблема. Хорошие инструменты делают очистку понятной: через timeout, явную команду unlock или и то и другое.
Если ваша команда использует компактный CI/CD и часто деплоит, поведение блокировок — не мелочь. Оно показывает, рассчитан ли инструмент на реальные конфликты или только на спокойные локальные сценарии.
Как они вписываются в CI
Инструмент миграций доказывает свою ценность в CI, когда может каждый раз собрать базу с нуля. Это важнее красивых функций. Если чистый запуск падает, команда будет снова и снова ловить один и тот же сюрприз в новых окружениях, тестовых базах и учениях по восстановлению.
Начните с простого теста: создайте пустую базу и примените все миграции по порядку. Такой прогон вскрывает пропущенные файлы, ошибки нумерации и скрытые допущения о старых данных. Многие команды пропускают этот шаг и проверяют только последнюю миграцию, из-за чего позже появляется слишком большая дыра.
Не менее важна понятная ошибка. CI нужен ясный ненулевой код выхода, а в логах должно быть видно точную миграцию, на которой всё сломалось. Если вывод говорит только «migration failed» без имени файла или SQL-ошибки, люди теряют время на догадки вместо исправления.
У большинства команд в итоге появляются два отдельных job'а:
- проверка pull request, которая запускает миграции на свежей базе
- release job, которая применяет одобренные миграции в целевой среде
Такое разделение ускоряет обратную связь в ревью и делает деплой более предсказуемым. Оно также помогает, когда несколько сервисов живут в одном репозитории: можно проверять только миграции того сервиса, который сейчас рассматривается, и при этом сохранить полный путь релиза.
Локальная работа должна как можно ближе совпадать с CI. Разработчики должны запускать ту же команду, с той же формой конфигурации, на своих машинах. Если CI требует отдельных обёрток, специальных флагов или скрытых шагов подготовки, отладка быстро становится тяжёлой.
Здесь как раз выигрывает скучный и предсказуемый вариант. Часто лучший выбор — это инструмент, который делает меньше, но сообщает об ошибках яснее и укладывается в одну повторяемую команду, которую команда может запускать на ноутбуке, в pull request и в релизной автоматизации.
Что меняется в репозитории с несколькими сервисами
Один сервис с одной базой обычно прост. Загруженный monorepo — нет. Когда несколько сервисов выкатываются по разным графикам, файлы миграций перестают быть просто SQL и начинают вести себя как общая инфраструктура.
Одна общая папка миграций хорошо работает, когда одну базу контролирует одна команда и изменения идут по одному пути. Всем видна полная история, а порядок откатов понятен. Минус — трение. Два pull request'а могут одновременно добавить следующую версию, и один из них придётся перенумеровывать или перебазировать.
Папка на сервис кажется чище, когда каждый сервис владеет своей базой или схемой. Команды двигаются быстрее, потому что не ждут друг друга на каждое изменение. Но эта модель ломается, когда сервисы делят таблицы. Тогда структура репозитория говорит одно, а реальность базы — другое.
Общие таблицы требуют общих правил. Если два сервиса зависят от одной схемы, один владелец должен утверждать порядок миграций, окно совместимости и время очистки. Без этого выбор инструмента уже не так важен, как координация. Даже хорошая migration library не спасёт команду, которая меняет общие таблицы без плана выката.
Именно здесь строгие имена файлов особенно помогают. Предсказуемое версионирование, понятное владение и одно место, где миграции реально запускаются, заметно упрощают жизнь в репозитории с несколькими сервисами.
Простой способ проверить инструменты в своём репозитории
Не тратьте время на игрушечные примеры. Возьмите одну миграцию, которая уже нужна из бэклога, и проверьте на ней два инструмента миграции Go. Хороший кандидат — небольшой, чтобы уложиться в несколько минут, но достаточно реальный, чтобы выявить проблемы: добавление колонки, бэкфилл данных или изменение индекса на таблице, с которой приложение часто работает.
Используйте свежую локальную базу для каждого прогона. Старые таблицы, оставшиеся блокировки и полусломанные тестовые данные скрывают то поведение, которое вам действительно нужно увидеть. Если в репозитории больше одного сервиса, запускайте миграцию так, как команда делала бы это в реальности, с теми же файлами конфигурации, порядком старта и пользователем базы.
Быстрое сравнение обычно показывает больше, чем длинный список функций:
- Запустите tool A и tool B на одной и той же свежей базе и измерьте очевидное: время настройки, понятность команды и то, насколько легко увидеть, какая версия была применена.
- Запустите два runner'а миграций одновременно. Один может идти из контейнера приложения, другой — из локальной оболочки или второго сервиса. Посмотрите, что происходит с блокировками.
- Спровоцируйте падение миграции. Намеренно добавьте плохой SQL или остановите процесс на середине. Потом посмотрите, что инструмент оставляет после себя: частичные таблицы, флаги dirty state или понятные шаги для повторного запуска.
- Исправьте сломанную миграцию и запустите её снова. Этот шаг важнее, чем happy path. Команды запоминают инструмент, который делает восстановление предсказуемым.
- Поставьте лучший инструмент в временный CI job ещё до того, как сделаете выбор окончательно. Такая проверка показывает, хорошо ли он подходит именно для CI database migrations, а не только для ноутбука.
Если вы пройдёте этот тест за один день, вы узнаете больше, чем из десяти обзорных страниц. Обычно выигрывает тот инструмент, который команда может запускать снова и снова без догадок, а не тот, у которого длиннее список функций.
Реалистичный сценарий для команды
У команды есть API-сервис, worker и одна база Postgres. Они добавляют новую функцию для хранения предпочтительного языка пользователя, поэтому им нужна одна новая колонка и бэкфилл для старых строк.
Первая миграция должна быть маленькой: добавить preferred_locale как nullable. Не объединяйте это с большим обновлением в том же шаге. Если API выйдет первым и начнёт читать колонку, которой ещё нет, запросы сломаются. Если первой выйдет миграция, ничего не сломается, потому что старый код может просто игнорировать лишнюю nullable-колонку.
Следующий деплой меняет код API. Он записывает новое поле для свежих обновлений, но всё ещё работает, когда preferred_locale пустой. Это важно, потому что старым строкам всё ещё нужны данные.
Бэкфилл лучше запускать после этого, обычно в worker или отдельной задаче, а не внутри самой schema migration. Пакетные обновления держат блокировки короткими и помогают избежать длинного деплоя, который держит Postgres занятым минутами. Файл миграции, который и добавляет колонку, и переписывает миллионы строк, трудно проверять и ещё труднее откатывать.
Когда бэкфилл закончится, API может начать читать preferred_locale в первую очередь. И только в более позднем релизе команде стоит делать колонку обязательной или удалять старое поле, которое помогало в бэкфилле.
Для такого изменения SQL-first инструмент с явными файлами up и down удобен в работе. golang-migrate хорошо подойдёт, если команде нужны строгие пары миграций и очень простой CI-процесс. goose тоже отлично работает, если в репозитории уже есть смесь SQL и небольшого количества Go.
Такой выбор упрощает ревью. Один файл добавляет колонку, другой отвечает за следующий шаг схемы, а бэкфилл живёт в коде worker'а, где разработчики могут остановить его, повторить или замедлить. Если с фичей что-то пойдёт не так, команда сможет откатить API, остановить worker и решить, оставить колонку для безопасности или убрать её отдельной чистой миграцией.
Типичные ошибки команд
Большинство проблем с миграциями начинается с привычек, а не с инструмента. Команды часто винят Go migration tools, хотя настоящая проблема в том, как они пишут и запускают изменения.
Первая ошибка — запихнуть слишком много в одну миграцию. Один файл, который добавляет колонки, переносит данные, перестраивает индексы и удаляет старые таблицы, трудно проверять и ещё труднее отменять. Маленькие шаги пишутся дольше, но ломаются менее болезненно, а в production это очень важно.
Другая распространённая путаница — смешивать исправление данных со схемой без плана повторного запуска. Обновление данных может провалиться на середине из-за плохих строк, таймаутов или блокировки, которая держится дольше, чем ожидалось. Если скрипт нельзя безопасно запустить повторно, у команды теперь есть наполовину изменённая база и неясный путь вперёд.
Запускать миграции при старте каждого сервиса тоже опасно. В репозитории с несколькими приложениями два экземпляра могут попытаться применить изменения одновременно. Даже если библиотека умеет блокировать, старт становится шумным и хрупким. Один сервис или один CI job должен отвечать за шаг миграции.
Команды ещё и пропускают down-файлы, даже когда библиотека их ожидает. Это кажется нормальным, пока кому-то не понадобится быстрый database migration rollback во время неудачного деплоя. Если путь отката пустой, сломан или ни разу не проверялся, инструмент уже не спасёт.
Я видел именно такую картину в небольшой команде: один большой SQL-файл, миграции при старте в двух сервисах и тесты только на ноутбуке разработчика, где уже лежали старые таблицы. Всё выглядело нормально, пока чистая staging-база не показала пропущенные шаги.
Лучший режим работы простой:
- держите каждую миграцию узкой
- отделяйте изменения схемы от рискованных исправлений данных
- запускайте миграции один раз, в одном контролируемом месте
- тестируйте на чистой базе и на копии со старыми данными
Это требует немного больше дисциплины, но сильно снижает количество лишней драмы.
Короткий чек-лист перед выбором
Лучший инструмент — тот, который команда может использовать в обычный загруженный вторник, не ломая production. Длинный список функций важен меньше, чем несколько простых проверок, которые заранее ловят проблемы.
Прежде чем выбирать между инструментами миграции Go, проверьте их на своём реальном репозитории, а не на демо-приложении:
- Начните с пустой базы. Новый разработчик должен клонировать репозиторий, запустить одну команду и получить схему, которую ждёт приложение.
- Сломайте одну миграцию специально в CI. Job должен быстро остановиться, показать неудачный файл и дать понятную ошибку.
- Запустите два job'а миграции одновременно. Хорошее поведение блокировок должно не дать раннерам менять одну и ту же таблицу вместе.
- Откройте дерево репозитория и представьте изменения на шесть месяцев вперёд. Папки сервисов, общие миграции и имена файлов всё ещё должны быть понятны.
- Назначьте владельца для каждой миграции. Это может быть команда, сервис или один инженер.
Небольшой тест говорит о многом больше, чем сравнительная таблица. Если инструмент позволяет новичку собрать всё с нуля, помогает CI громко падать, нормально работает при конкурирующих запусках, остаётся понятным и делает владение очевидным, скорее всего, он выдержит реальную командную работу.
Если хотя бы одна из этих проверок не проходит сейчас, она провалится и в 17:00 в день релиза.
Что делать дальше вашей команде
Выберите один инструмент и на время перестаньте сравнивать остальные. Большинству команд больше безопасности даёт понятный процесс, чем погоня за «лучшей» библиотекой. Если вы уже сузили список, берите тот вариант, который лучше подходит вашим правилам отката, терпимости к блокировкам и тому, как вы выкатываете код.
Запишите это решение в короткой заметке для команды. Определите один стиль миграций, кто их пишет, когда они запускаются и что команда делает, если релиз падает. Если две службы трогают одну базу, попросите владельцев обеих сервисов договориться об этих правилах заранее, а не во время инцидента.
Самый безопасный следующий шаг — небольшой CI-путь:
- применить миграции к свежей базе
- запустить приложение и провести smoke test
- протестировать database migration rollback, если ваш инструмент это поддерживает
- провалить pipeline при ожидании блокировки или timeout'е
Сначала держите этот путь отдельно от production-деплоев. После нескольких чистых прогонов перенесите его в основной pipeline и сделайте частью каждого релиза.
Командам с большим числом сервисов стоит вместе пересмотреть поведение блокировок и политику отката. Один сервис, который слишком долго держит блокировку, может заблокировать деплой другой команды, даже если обе сделали свою работу правильно. 30-минутный разбор со всеми владельцами сервисов может сэкономить очень длинный вечер позже.
Если вашей команде нужен внешний взгляд, Oleg Sotnikov на oleg.is работает как Fractional CTO и startup advisor и помогает командам улучшать процесс миграций, CI и инфраструктуру без лишней бюрократии.
Обычно лучший план — самый скучный. Выберите инструмент, проверьте его в CI, договоритесь о правилах и сделайте изменения схемы рутиной.
Часто задаваемые вопросы
С какого Go migration tool большинству команд стоит начать?
Для большинства команд лучше начать с простого SQL-first инструмента. golang-migrate хорошо подходит, если вам нужны строгие файлы .up.sql и .down.sql. goose или dbmate удобны, если важны более лёгкий процесс и файлы, которые легко читать.
Выбирайте то, что команда сможет запускать одинаково на ноутбуке, в CI и в production. Привычный и стабильный процесс важнее лишних функций.
Стоит ли полагаться на down-миграции в production?
Используйте down-миграции для небольших изменений схемы, например для добавления индекса или nullable-колонки. Для изменений данных в production лучше исходить из подхода fix-forward, если только вы не проверили откат на данных, похожих на реальные.
Down-файл может отменить SQL, но он не всегда способен отменить то, что уже записало приложение.
Почему изменения базы ломаются в production, хотя в staging всё выглядело нормально?
В production больше данных, больше трафика и жёстче требования ко времени. Миграция, которая кажется безобидной в staging, может дольше держать блокировки или затронуть намного больше данных в production.
Именно поэтому маленькие шаги и поэтапные релизы работают лучше, чем большие изменения «всё сразу».
Куда лучше помещать backfills?
Держите саму schema migration маленькой, а бэкфилл переносите в код приложения, worker или отдельную одноразовую задачу. Запускайте его батчами, чтобы Postgres не сидел под долгими блокировками.
Если бэкфилл упадёт на середине, вам нужна задача, которую можно остановить, повторить или замедлить без повторного запуска схемы.
Должен ли каждый сервис запускать миграции при старте?
Нет. Пусть миграциями управляет одна задача или один сервис. Если каждый сервис запускает миграции при старте, два экземпляра могут столкнуться и превратить деплой в лотерею.
Один шаг миграции делает логи чище и упрощает поиск ошибок.
Что CI должен проверять для миграций базы данных?
Начните с простой проверки: соберите пустую базу с нуля на каждом pull request. Затем убедитесь, что при ошибке job быстро завершается и в логах есть имя файла и SQL-ошибка.
Также полезно иметь release job, который применяет одобренные миграции в целевой среде отдельно от проверок в ревью.
Как проверить поведение блокировок до production?
Запустите две команды миграции почти одновременно на одной и той же свежей базе. Один runner должен подождать или завершиться без ошибок, а не менять схему вместе с другим.
Ещё проверьте, что будет после сбоя. Вам нужен понятный способ убрать зависшую блокировку и повторить попытку.
Какая структура папок лучше всего подходит для репозитория с несколькими сервисами?
Если каждый сервис владеет своей базой или схемой, папка на сервис обычно подходит. Если сервисы делят таблицы, держите общие миграции у одного понятного владельца и один путь релиза.
Структура папок важна меньше, чем ответственность и порядок деплоя. Общие таблицы требуют общих правил.
Когда Atlas стоит своей дополнительной сложности?
Atlas имеет смысл, когда дрейф схемы уже мешает или когда команде нужны сгенерированные diff'ы и более строгие правила. Он требует от команды больше, чем тонкий migration runner.
Если работа со схемой у вас пока простая, более лёгкий инструмент обычно даёт меньше сюрпризов.
Как безопаснее всего выкатывать новую колонку?
Сначала добавьте новую колонку как nullable. Затем разверните код приложения, который умеет работать и со старым, и с новым состоянием. После этого сделайте бэкфилл батчами и только потом делайте колонку обязательной.
Такой порядок сохраняет спокойный релиз, потому что старый код может игнорировать дополнительную колонку, а новый уже начинает в неё писать.