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. Каждую миграцию он применяет в три шага:
- записывает версию N с
dirty = true— «начал миграцию N»; - выполняет файл
N_*.up.sql; - записывает версию 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— миграция N применилась полностью (или вы доделали её руками). Следующийupначнёт с N+1.force N-1— миграция N не применилась (или вы откатили её остатки руками). Следующийupвыполнит 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 не бывает.
Когда «всё или ничего» не работает
Включён
x-multi-statement. С этим параметром в строке подключения драйвер режет файл по;и выполняет операторы по одному. Каждый — отдельная транзакция, и миграция может примениться наполовину.В файле есть явные
BEGINиCOMMIT. Всё, что доCOMMIT, зафиксировано независимо от того, что упало после.CREATE INDEX CONCURRENTLY. Этот оператор нельзя выполнять внутри транзакции, поэтому он живёт в файле один, и откат его не защищает. Если построение прервётся, PostgreSQL оставит в каталоге недостроенный индекс со статусом «недействителен». Хуже всего то, что повторный накат сCREATE INDEX CONCURRENTLY IF NOT EXISTSувидит объект с таким именем и молча пропустит создание: миграция отчитается об успехе, а рабочего индекса не будет — планировщик недействительные индексы не использует. Проверка:SELECT indexrelid::regclass, indisvalid FROM pg_index WHERE NOT indisvalid;Недостроенный индекс нужно удалить (
DROP INDEX CONCURRENTLY имя_индекса;), снять флаг на N-1 и накатить миграцию заново.
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;
Порядок действий
- Узнать номер:
SELECT version, dirty FROM schema_migrations;— пусть это N. - Открыть
N_*.up.sqlи понять, что он должен был сделать. - Сверить со схемой. В PostgreSQL без исключений из списка выше достаточно одного объекта, в остальных случаях — всех.
- Довести схему до одного из двух чистых состояний: миграция N есть целиком или её нет вовсе.
- Если миграция упала из-за ошибки в самом SQL — исправить файл, иначе следующий
upоставит тот же флаг. - Снять флаг:
migrate -path ./migrations -database "$DATABASE_URL" force N(илиN-1) и запуститьup. - Убедиться, что
SELECT version, dirty FROM schema_migrations;показывает последнюю версию иdirty = false.
Почему миграции рвутся в проде
Ошибку в самом SQL обычно ловят на стенде. В проде миграция чаще обрывается из-за окружения:
- Процесс убили посреди миграции. Оркестратор перезапустил контейнер, не
дождавшись конца долгого
ALTER TABLE, пришёл OOM-killer, кто-то сделал рестарт в неудачный момент. - Миграция ждала блокировку.
ALTER TABLEна нагруженной таблице встаёт в очередь за долгой транзакцией, а за ним встают все остальные запросы к этой таблице. Сработал таймаут — миграция упала.SET lock_timeout = '5s';в начале миграции превращает долгое ожидание с простоем в быструю и понятную ошибку.
Как это устроено в Gotcha
Gotcha — self-hosted observability-платформа — применяет миграции PostgreSQL и ClickHouse через golang-migrate при старте, поэтому встречает эту ошибку так же, как любой Go-сервис. Отличия в подаче:
- ошибка старта называет, какая из двух баз застряла и на какой версии, и ссылается на раздел документации;
- флаг снимает сам бинарь, отдельно ставить migrate не нужно:
--migrate-force=Nдля PostgreSQL и--migrate-force-ch=Nдля ClickHouse; - принимаются только номер застрявшей версии и предыдущий. Любое другое число считается опечаткой — оно молча сдвинуло бы точку отсчёта всех будущих миграций;
- индексы к таблицам с данными строятся через
CREATE INDEX CONCURRENTLY— так устроены 28 миграций PostgreSQL из 98, по одному оператору на файл. А к таблице, которую миграция сама создаёт пустой, индекс строится обычнымCREATE INDEXв том же файле: блокировать нечего, и миграция остаётся атомарной. В документации к обновлению есть та же проверкаindisvalid— её стоит выполнять после любого обновления, даже прошедшего гладко.
Подробности — в разделе «Если миграция оборвалась» документации.