Dirty database version: почему миграция блокирует все следующие и как снять флаг, не потеряв данные

Сервис после деплоя не поднимается, а в логе одна строка:

Dirty database version 17. Fix and force version.

Её печатает golang-migrate — самый распространённый инструмент миграций в Go-проектах. Первый ответ, который находится по этой строке, почти всегда один: выполните migrate force 17. Иногда это правильно. Но в PostgreSQL чаще правильно force 16, а force 17 оставляет схему без миграции 17 — и никакого сообщения об этом не будет. Ниже — как отличить один случай от другого.

Что означает флаг dirty

golang-migrate хранит состояние в таблице schema_migrations: номер версии и признак dirty. Каждую миграцию он применяет в три шага:

  1. записывает версию N с dirty = true — «начал миграцию N»;
  2. выполняет файл N_*.up.sql;
  3. записывает версию N с dirty = false — «закончил».

Если между первым и третьим шагом что-то случилось — SQL-ошибка в миграции, OOM, docker kill посреди деплоя, обрыв соединения с базой, — в таблице остаётся N, dirty = true. С этого момента golang-migrate отказывается делать что-либо, кроме force: ни up, ни down, ни следующей миграции. Это не каприз: по одной строке в таблице инструмент не может узнать, применилась миграция N целиком, наполовину или не применилась вовсе. Решать должен человек, посмотрев на схему.

На чём застряли, видно так:

SELECT version, dirty FROM schema_migrations;

Что на самом деле делает force

migrate force N не выполняет ни строки SQL. Он только переписывает запись в schema_migrations: версия N, dirty = false. Смысл команды — «я проверил, схема соответствует версии N». Поэтому вариантов ровно два:

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

PostgreSQL: миграция откатывается целиком

Здесь главное отличие PostgreSQL от остальных баз, и именно оно делает универсальный совет «force N» опасным.

Драйвер golang-migrate для PostgreSQL по умолчанию отправляет файл миграции целиком, одним вызовом Exec без параметров. Для такого вызова pgx выбирает простой протокол запросов, а PostgreSQL выполняет несколько операторов, пришедших одним простым запросом, как одну неявную транзакцию. Поэтому если в миграции упал любой оператор — или процесс убили посреди файла, — PostgreSQL откатывает всю миграцию: ни одна таблица, колонка или индекс из этого файла в базе не остаются.

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

Проверить всё равно стоит — по одному объекту из файла N:

-- миграция добавляла колонку?
SELECT column_name FROM information_schema.columns
WHERE table_name = 'orders' AND column_name = 'archived_at';

-- создавала таблицу?
SELECT to_regclass('public.order_archive');

Объект есть — миграция успела зафиксироваться (так бывает, если процесс умер уже после её коммита, но до третьего шага), нужен force N. Объекта нет — force N-1. Половинчатого состояния у обычной миграции в PostgreSQL не бывает.

Когда «всё или ничего» не работает

ClickHouse и MySQL: сверять со схемой

В ClickHouse транзакций для DDL нет, в MySQL каждый DDL-оператор неявно фиксирует транзакцию. В обоих случаях миграция из нескольких операторов может остановиться на середине, и узнать где можно только одним способом — сравнить файл миграции со схемой, SHOW CREATE TABLE по каждой затронутой таблице. Недостающее доделать руками или откатить сделанное, затем force N или force N-1 соответственно.

Разбор сильно упрощает правило «один оператор на файл миграции»: тогда состояние «наполовину» сводится к «применился или нет». В ClickHouse есть ещё одна особенность — мутации. ALTER TABLE … UPDATE и ALTER TABLE … DELETE выполняются асинхронно и продолжаются на сервере, даже если клиент уже отключился. Незавершённые видны так:

SELECT database, table, mutation_id, command FROM system.mutations WHERE NOT is_done;

Порядок действий

  1. Узнать номер: SELECT version, dirty FROM schema_migrations; — пусть это N.
  2. Открыть N_*.up.sql и понять, что он должен был сделать.
  3. Сверить со схемой. В PostgreSQL без исключений из списка выше достаточно одного объекта, в остальных случаях — всех.
  4. Довести схему до одного из двух чистых состояний: миграция N есть целиком или её нет вовсе.
  5. Если миграция упала из-за ошибки в самом SQL — исправить файл, иначе следующий up оставит тот же флаг.
  6. Снять флаг: migrate -path ./migrations -database "$DATABASE_URL" force N (или N-1) и запустить up.
  7. Убедиться, что SELECT version, dirty FROM schema_migrations; показывает последнюю версию и dirty = false.

Почему миграции рвутся в проде

Ошибку в самом SQL обычно ловят на стенде. В проде миграция чаще обрывается из-за окружения:

Как это устроено в Gotcha

Gotcha — self-hosted observability-платформа — применяет миграции PostgreSQL и ClickHouse через golang-migrate при старте, поэтому встречает эту ошибку так же, как любой Go-сервис. Отличия в подаче:

Подробности — в разделе «Если миграция оборвалась» документации.