Как откатить миграцию в prod¶
Runbook для ситуации: миграция применилась в prod, после деплоя сервис падает / корраптит данные / ведёт себя неверно — нужно вернуть схему к предыдущему состоянию. Это последнее средство, применяется только после того, как другие варианты (hotfix кода, быстрый forward-fix миграции) не сработали.
Reference по написанию миграций, expand-contract и advisory lock — в
../conventions/db-pgx.md и
add-migration.md. Про типовые сбои migrate CLI —
../troubleshooting/migration-fails.md.
Содержание¶
- Главное правило: в prod нет down
- Когда откатывать
- Когда НЕ откатывать
- 1. Остановить дальнейший ущерб
- 2. Зафиксировать состояние
- 3. Выбрать стратегию отката
- Стратегия A: forward-fix миграция
- Стратегия B: revert кода + compatibility-миграция
- Стратегия C: ручной DDL под advisory lock
- 4. Проверить schema_migrations
- 5. Восстановить данные, если backfill испортил
- 6. Incident log
- Частые ошибки при откате
- Чеклист
- Связанные разделы
Главное правило: в prod нет down¶
Down-миграции в prod не применяются. Причины:
- Down удаляет колонку/таблицу с данными — это необратимая потеря.
- Down не знает про зависимые объекты, добавленные другими миграциями
(FK, view'ы, функции) —
DROP ... CASCADEпорвёт связи. schema_migrationsпослеmigrate down 1разжимается на один номер назад — если потом применить ту же up-миграцию, advisory lock и уникальные constraint'ы на исходные данные могут выдать ошибку, и сервис не стартует.
Откат в prod — это forward-migration, которая возвращает схему в
нужное состояние. Не migrate down.
Единственное исключение — dev/staging, где данные можно потерять: там
migrate -path ./migrations -database "$DATABASE_URL" down 1 — штатный
инструмент при отладке самой миграции перед merge.
Когда откатывать¶
- Миграция добавила
NOT NULLconstraint, который ломается на существующих NULL-строках → сервис не стартует после deploy. - Миграция удалила колонку, которую всё ещё читает старая версия сервиса (rolling deploy не завершён).
- Миграция ввела новый FK, и массовые inserts теперь нарушают его на production-данных.
- Index создан
CONCURRENTLYбезIF NOT EXISTS, и второй запуск CI-стадии попытался создать снова — advisory lock предотвратил, но миграция зависла. - Backfill перезаписал данные, которые нельзя было трогать (fired
trigger, неверное условие
WHERE).
Когда НЕ откатывать¶
- Сервис падает не из-за схемы, а из-за кода — откатывай код, не миграцию.
- Миграция добавила nullable-колонку → чуть больше размер таблицы, но ничего не ломается. Оставь, удалишь отдельной cleanup-миграцией позже.
- Hotfix-миграция уже готова и быстрее применить её, чем откат. См. §Стратегия A.
- Backfill ещё не закончился — не прерывай его посередине, это оставит схему в смешанном состоянии. Дождись, потом решай.
1. Остановить дальнейший ущерб¶
Перед любым SQL-вмешательством остановить поток записи, который делает ситуацию хуже:
- Кэш код-деплой — если новая версия сервиса пишет «битые» данные
через свежую схему, откати деплой до предыдущего образа
(
kubectl rollout undo deployment/<service>). Это делается до отката миграции. - Останови проблемный worker — если backfill-скрипт / CronJob
продолжает портить данные, заскейль его в 0
(
kubectl scale cronjob/<name> --replicas=0или удали schedule). - Включи read-only mode, если сервис поддерживает
(
MAINTENANCE_MODE=trueenv-переменная) — запретить записи, пока разбираешься.
Запомни: rollback кода и rollback миграции — разные операции. Код откатывается быстрее. Миграция — только если её эффект действительно нужно обратить.
2. Зафиксировать состояние¶
-- какие миграции применены
SELECT version, dirty FROM schema_migrations ORDER BY version DESC LIMIT 10;
-- структура проблемной таблицы
\d <schema>.<table>
-- сколько строк, сколько с NULL в новой колонке, etc.
SELECT count(*) FROM <schema>.<table>;
SELECT count(*) FROM <schema>.<table> WHERE <new_column> IS NULL;
-- активные длинные транзакции, которые могут держать lock
SELECT pid, state, xact_start, query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND xact_start < NOW() - INTERVAL '30 seconds';
Запиши результаты в incident log (§6). Без этого через день ты не вспомнишь, что было «до».
Если есть pg_dump / логический бэкап свежее проблемной миграции —
проверь, что он реально свежий:
# посмотри список бэкапов в backup-бакете
aws s3 ls s3://<backup-bucket>/<service>/ --recursive | tail -5
Восстановление целиком из бэкапа — последний вариант, убивает всю активность с момента бэкапа. Используется только при необратимой corruption'е.
3. Выбрать стратегию отката¶
| Ситуация | Стратегия |
|---|---|
| Миграция добавила constraint/index, который мешает — данные целы | A: forward-fix (новая миграция, отменяющая constraint) |
| Миграция удалила колонку, которую читает старый код | B: вернуть код + compatibility-миграция, восстанавливающая колонку |
| Миграция зависла (advisory lock, dirty state) | C: ручной DDL + migrate force N, см. ../troubleshooting/migration-fails.md |
| Backfill перезаписал данные неверно | A + восстановление из audit-trail / бэкапа |
Стратегия A: forward-fix миграция¶
Самый частый и безопасный путь. Пишем новую миграцию с номером N+1, которая отменяет то, что сделала проблемная миграция N.
Пример: миграция 042_add_review_status_not_null.up.sql добавила
NOT NULL, но на существующих строках status IS NULL → сервис падает
при update.
-- 043_review_status_drop_not_null.up.sql
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('review_migrations'));
ALTER TABLE review.reviews ALTER COLUMN status DROP NOT NULL;
COMMIT;
-- 043_review_status_drop_not_null.down.sql
-- restore NOT NULL; requires prior backfill of NULLs
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('review_migrations'));
ALTER TABLE review.reviews ALTER COLUMN status SET NOT NULL;
COMMIT;
Порядок действий:
- Создай пару файлов
043_*.up.sql/043_*.down.sqlвmigrations/. - Локально прогони
make migrate-up— убедись, что миграция применяется и работает то, что было сломано. - Открой PR с описанием инцидента в description (короткое «что случилось, почему откатываем, как проверили»).
- Merge и деплой по стандартному CI/CD.
- После того, как схема восстановлена — разбирайся, почему исходная миграция сломала prod (тесты на pre-prod данные, missing backfill, и т.п.) и фиксируй это в задаче на пост-мортем.
Этот путь не трогает schema_migrations руками, не использует
migrate force, не требует down.sql прошлой миграции. Именно его
применяй в 90% случаев.
Стратегия B: revert кода + compatibility-миграция¶
Когда миграция DROP COLUMN удалила поле, а старая версия сервиса
(или rolling deploy в процессе) всё ещё читает его → 500 на
column "X" does not exist.
Правильный expand-contract бы это исключил (см.
../conventions/db-pgx.md),
но если ошибка случилась:
- Откати код до версии, которая не читает поле (
kubectl rollout undo). Это немедленно останавливает 500. - Убедись, что новая схема совместима с откаченным кодом (старый код без поля — ну и без, всё ок).
- Напиши compatibility-миграцию, которая возвращает колонку nullable:
-- 044_restore_review_old_field.up.sql
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('review_migrations'));
ALTER TABLE review.reviews ADD COLUMN old_field TEXT;
-- данные восстановить из бэкапа отдельным скриптом
COMMIT;
- Восстанови данные в колонке, если они нужны — см. §5.
Не пытайся «просто накатить код обратно с удалённой колонкой» — после
DROP COLUMN данных больше нет, сервис будет работать с пустым
полем, что тоже может сломать бизнес-логику.
Стратегия C: ручной DDL под advisory lock¶
Используется, когда миграция частично применилась и schema_migrations
показывает dirty=true. Full flow — в
../troubleshooting/migration-fails.md.
Кратко, адаптированно для отката:
- Открой проблемную up.sql, посмотри, какие DDL успели применится.
- Откройсь в psql под advisory lock:
- Выполни обратные DDL-операции, явно. Пример (миграция добавила
колонку и index, но упала на
SET NOT NULL):
DROP INDEX IF EXISTS review.idx_reviews_status;
ALTER TABLE review.reviews DROP COLUMN IF EXISTS status;
COMMIT;
- Сбрось dirty-флаг и пометь миграцию как не применённую:
DELETE FROM schema_migrations WHERE version = 42;
-- или сбрось к предыдущей
INSERT INTO schema_migrations (version, dirty) VALUES (41, false)
ON CONFLICT (version) DO UPDATE SET dirty = false;
Важно: в разных версиях golang-migrate структура таблицы
чуть разная. Сначала посмотри:
- Напиши исправленную версию миграции с тем же номером (если она ещё не замерджена в main), либо новую миграцию N+1 — не редактируй уже применённую, если она уже в main.
Ручное вмешательство в schema_migrations — только как аварийный
механизм. В штатных случаях — Стратегия A.
4. Проверить schema_migrations¶
После любого отката:
Ожидания:
dirty = falseдля всех строк. Еслиdirty=true— миграция «висит», следующие стартапы сервиса будут падать.- Максимальный
versionсоответствует последней миграции вmigrations/репо. - Между номерами нет пропусков (
migrate upна пустой БД должен накатить все последовательно).
Проверь, что сервис стартует:
kubectl rollout restart deployment/<service>
kubectl logs -f deployment/<service>
# ожидаем: "migrations applied", "server starting on :808X", без ERROR
5. Восстановить данные, если backfill испортил¶
Если миграция включала UPDATE, который перезаписал данные неверно — восстановление зависит от того, что у тебя есть:
a) Audit-trail таблица¶
Если в сервисе есть history-таблица (review.reviews_history с
триггером на INSERT/UPDATE/DELETE) — источник истинных данных до
миграции:
UPDATE review.reviews r
SET status = h.status
FROM review.reviews_history h
WHERE h.review_id = r.id
AND h.changed_at = (
SELECT MAX(changed_at) FROM review.reviews_history
WHERE review_id = r.id
AND changed_at < '2026-04-19 12:00:00'::timestamptz -- до миграции
);
b) Логический бэкап¶
pg_restore на отдельную БД, INSERT INTO ... SELECT FROM с
fetch'ем нужных строк. Никогда не накатывай бэкап поверх живой prod-БД
— только через staging-копию.
c) Outbox/events¶
Если изменения публиковались через outbox, прошлые события
(kazmaps.review.review.updated) всё ещё в Kafka (retention 7 дней).
Можно re-play'нуть последнее событие до миграции и получить payload
старого состояния.
Стратегия выбора:
| Что доступно | Используй |
|---|---|
| Audit-trail | a — самый точный и быстрый |
| Только pg_dump | b — медленнее, но всегда доступно |
| Audit нет, но есть Kafka-события | c — точечное восстановление по ID |
| Ничего | Данные утрачены. Инцидент = DLP. Фиксируй в incident log. |
6. Incident log¶
После отката заведи запись (в сервис-репо, docs/incidents/ или в
системе инцидентов команды):
# 2026-04-19 review-service: откат миграции 042
## Что случилось
Миграция 042 добавила `NOT NULL` на `review.reviews.status` без
backfill NULL-строк. После деплоя сервис падал в 100% PUT /v1/reviews
с ошибкой.
## Как починили
Стратегия A: миграция 043 сняла `NOT NULL`. Деплой 043 + rollout
сервиса.
## Почему не поймали раньше
Тест миграции на staging проходил, потому что на staging нет
исторических reviews с `status IS NULL` (данные обнулены 2 недели назад).
## Что сделаем
- Тест миграций прогоняется на snapshot'е prod-схемы + sample данных
(задача: ...).
- Чеклист PR для миграций: проверить backfill для `SET NOT NULL` на
существующих данных.
Без этой записи следующая аналогичная миграция повторит ту же ошибку через полгода.
Частые ошибки при откате¶
migrate downв prod. Удаляет колонки с данными, отключает advisory lock механизм, оставляетschema_migrationsв состоянии, которое потом ломает up.- Редактирование уже применённой миграции.
042_add_review_status.up.sqlтеперь делает что-то другое, но на prod применилась старая версия. Любой сервис, который прогоняетmigrate upс нуля (новый environment, после восстановления БД), получит другое состояние. Пиши новую миграцию N+1. - Ручной
DELETE FROM schema_migrationsбез сверки схемы. БД иschema_migrationsрассинхронизированы, следующие миграции пойдут не в ту ветку DDL. - Забыл advisory lock в ручных DDL. Параллельный pod того же сервиса пытается накатить ту же миграцию — оба падают друг на друга.
- Не откатил код до миграции. Миграция применена в обратную сторону, а код всё ещё использует новые поля → немедленно 500.
- Восстановление из бэкапа без остановки writes. Бэкап накатывается поверх live-данных → потеряны все записи с момента бэкапа.
Чеклист¶
- Определил, нужен ли реально откат схемы, или достаточно rollback кода.
- Остановил поток «битых» записей (rollback кода / read-only mode / CronJob scale=0).
- Зафиксировал
schema_migrations, структуру таблицы и счётчики строк. - Выбрал стратегию (A / B / C) и обосновал выбор.
- Миграция отката имеет advisory lock в начале up.sql.
- Прогнал миграцию локально на snapshot-копии prod-данных.
- PR создан с описанием инцидента, approve от lead.
- После деплоя:
schema_migrationsчистый (dirty=false), сервис стартует, readiness 200. - Данные восстановлены (если нужно) из audit-trail / бэкапа / events.
- Incident log написан.
- Post-mortem запланирован: почему не поймали на pre-prod.
Связанные разделы¶
../conventions/db-pgx.md— advisory lock, expand-contract, rolling-deploy совместимость.add-migration.md— формат миграций, чего не делать в up.sql.../troubleshooting/migration-fails.md— dirty state, advisory lock ожидание, deadlock.../troubleshooting/db-slow-query.md— если после миграции запросы начали тормозить, но схема сама цела.