Как добавить миграцию¶
Пошаговый рецепт. Формат файлов, advisory lock, expand-contract и rolling-
deploy — подробно описаны в ../conventions/db-pgx.md.
Здесь — практическая последовательность действий.
Содержание¶
- 1. Определить тип изменения
- 2. Создать файлы
- 3. Advisory lock в начале up.sql
- 4. Expand-contract
- 5. Чего не делать в миграции
- 6. Down-миграция
- 7. Локальный прогон
- 8. Тест миграций
- 9. EXPLAIN ANALYZE
- 10. Seed-скрипты
- 11. Rolling-deploy compatibility
- 12. Никогда не правь применённую миграцию
- Troubleshooting
- Чеклист
- Связанные разделы
1. Определить тип изменения¶
- Non-breaking: добавление новой таблицы, nullable-колонки, индекса. Старый код не ломается. Миграция может применяться одновременно с разворачиванием нового кода.
- Breaking: rename колонки, drop колонки, change type, ADD NOT NULL DEFAULT на большой таблице. Требует expand-contract из трёх-четырёх миграций и промежуточных деплоев кода.
Если не уверен, какой у тебя случай, — скорее всего, breaking. Безопаснее предположить больше шагов.
2. Создать файлы¶
Имя: NNN_snake_case.up.sql + NNN_snake_case.down.sql. Нумерация
сплошная, трёхзначная:
migrations/
├── 001_init.up.sql
├── 001_init.down.sql
├── 002_add_review_status.up.sql
├── 002_add_review_status.down.sql
└── 003_backfill_review_status.up.sql
003_backfill_review_status.down.sql
Имя описывает что делает, не «почему»:
- хорошо:
002_add_review_status,007_index_reviews_place_id. - плохо:
002_fix_bug_PROJ-1234,005_tmp_revert.
3. Advisory lock в начале up.sql¶
Каждая up-миграция начинается с advisory lock:
-- migrations/002_add_review_status.up.sql
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('review_migrations'));
ALTER TABLE review.reviews
ADD COLUMN status TEXT;
COMMIT;
hashtext('<service>_migrations')— одинаковый seed для всех миграций одного сервиса. Можно взять ручной 64-битный integer, главное, чтобы все миграции сервиса использовали один и тот же seed.- Lock снимается автоматически при
COMMIT/ROLLBACK. Если два pod'а стартуют параллельно — второй подождёт первого. - Один seed = один «канал» миграций. Не заводи разные seed'ы на разные миграции одного сервиса.
4. Expand-contract¶
Покажем на примере переименования колонки nickname → username:
Миграция N (expand): добавить новую колонку nullable¶
-- 010_add_username_column.up.sql
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('user_migrations'));
ALTER TABLE auth.users
ADD COLUMN username TEXT;
COMMIT;
Deploy. Код в этот момент читает старое поле (nickname) и игнорирует
username.
Код: dual-write¶
Выпускается релиз, в котором код пишет оба поля (nickname и username),
но читает всё ещё из nickname. Rolling deploy безопасен — старые
pod'ы не падают от нового поля в insert'е (оно nullable).
Миграция N+1 (backfill): заполнить существующие строки¶
Не делай в миграции большой UPDATE одной транзакцией. Вынеси в отдельную команду или background job, батчами:
-- 011_backfill_username.up.sql
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('user_migrations'));
-- Только для маленьких таблиц (< 100k строк):
UPDATE auth.users SET username = nickname WHERE username IS NULL;
COMMIT;
Для больших таблиц — отдельный Go-скрипт:
for {
rows, err := pool.Exec(ctx, `
UPDATE auth.users
SET username = nickname
WHERE id IN (
SELECT id FROM auth.users
WHERE username IS NULL
ORDER BY id
LIMIT 1000
)
`)
if err != nil || rows.RowsAffected() == 0 {
return err
}
time.Sleep(100 * time.Millisecond) // чтобы не выжигать реплику
}
Код: читать username, fallback на nickname¶
Выпускается релиз, который читает username, а если он null — nickname.
Пишет всё ещё оба.
Миграция N+2 (contract, часть 1): NOT NULL¶
-- 012_username_not_null.up.sql
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('user_migrations'));
ALTER TABLE auth.users
ALTER COLUMN username SET NOT NULL;
COMMIT;
Deploy только после того, как убедился: SELECT COUNT(*) FROM auth.users
WHERE username IS NULL; даёт 0.
Код: перестать писать/читать nickname¶
Выпускается релиз, который пишет/читает только username.
Миграция N+3 (contract, часть 2): DROP COLUMN¶
-- 013_drop_nickname.up.sql
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('user_migrations'));
ALTER TABLE auth.users
DROP COLUMN nickname;
COMMIT;
Итого: 4 миграции + 3 промежуточных деплоя. Выглядит долго, но это
zero-downtime. Попытка сделать всё одной миграцией
(ALTER TABLE ... RENAME COLUMN) гарантированно ломает rolling-deploy.
5. Чего не делать в миграции¶
Long locks на hot таблицы¶
-- ПЛОХО: полный table lock + read всей таблицы, блокирует writes
ALTER TABLE reviews ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
Postgres 11+ умеет ADD COLUMN ... DEFAULT без rewrite, но NOT NULL DEFAULT
на больших таблицах — смотри план, проверяй. Безопасный путь:
-- 1: nullable без default
ALTER TABLE reviews ADD COLUMN status TEXT;
-- 2: backfill батчами
-- 3: ALTER ... SET DEFAULT 'active'
-- 4: ALTER ... SET NOT NULL
CREATE INDEX в транзакции¶
-- ПЛОХО: блокирует writes на таблицу, пока строит индекс
BEGIN;
CREATE INDEX idx_reviews_place ON reviews(place_id);
COMMIT;
Правильно — CREATE INDEX CONCURRENTLY, но он не работает внутри
транзакции. Значит миграция — без BEGIN/COMMIT:
-- 014_index_reviews_place_id.up.sql
-- NB: no BEGIN/COMMIT — CREATE INDEX CONCURRENTLY cannot run inside a tx.
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_reviews_place_id
ON review.reviews (place_id)
WHERE deleted_at IS NULL;
golang-migrate умеет применять такие миграции — у пакета есть флаг «без transaction» (в заголовке файла комментарий — чтобы ревьюер сразу видел).
Cross-schema действия¶
Не ходи в схемы других сервисов. Миграция одного сервиса трогает только свою схему.
6. Down-миграция¶
Down-файл обязателен даже если логически необратим. Правила:
- Делай «максимально обратное»: добавил колонку → drop в down.
- Если операция необратима (drop column с потерей данных) — пиши
-- cannot be reversed: column data is irrecoverableи ничего. Это сигнал ревьюеру, что rollback придётся руками. - Down-миграция тоже нужна с advisory lock и в транзакции (если применимо).
-- 002_add_review_status.down.sql
BEGIN;
SELECT pg_advisory_xact_lock(hashtext('review_migrations'));
ALTER TABLE review.reviews DROP COLUMN status;
COMMIT;
7. Локальный прогон¶
cd <service-repo>
make migrate-up
# применились все up миграции
make migrate-down
# откатились
make migrate-up
# применились снова
make migrate-up/migrate-down — обёртки вокруг golang-migrate CLI,
смотри Makefile сервиса. Если цель отсутствует, подтяни её по образцу из
эталонного сервиса (см. репозиторий сервиса user, файл Makefile).
Проверь, что down + up дают ту же схему, что чистый up.
8. Тест миграций¶
Integration-тест в internal/db/migrations_test.go:
func TestMigrations_ApplyClean(t *testing.T) {
pool := testcontainersPostgres(t)
if err := migrations.Run(context.Background(), pool, slog.Default()); err != nil {
t.Fatalf("migrations: %v", err)
}
// Проверь, что таблицы созданы.
var exists bool
err := pool.QueryRow(context.Background(), `
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'review' AND table_name = 'reviews'
)
`).Scan(&exists)
if err != nil || !exists {
t.Fatal("reviews table missing after migrations")
}
}
Тест гоняется при каждом make test. Ловит ошибки SQL до merge.
9. EXPLAIN ANALYZE¶
Если добавил индекс или новый запрос, прогони план на testcontainers-Postgres с seed-данными:
EXPLAIN ANALYZE
SELECT r.* FROM review.reviews r
WHERE r.place_id = 42 AND r.deleted_at IS NULL
ORDER BY r.created_at DESC
LIMIT 20;
Что смотреть: нет ли Seq Scan на большой таблице, используется ли
новый индекс, оценка rows реалистична. Если план плохой — правь индекс/
запрос до merge, а не после.
10. Seed-скрипты¶
Если в сервисе есть dev-seed (заполнение БД тестовыми данными для локальной разработки), проверь, что он корректно работает после новой миграции. Иногда нужно обновить INSERT — добавить новую колонку, убрать удалённую.
11. Rolling-deploy compatibility¶
Не комбинируй в одну миграцию «что-то добавить» и «что-то удалить». Правило: миграция совместима с текущим prod-кодом:
- После
001_add_columnстарый prod-код ещё работает. - Код, использующий новую колонку, деплоится после миграции.
- Код, переставший использовать старую колонку, деплоится до
миграции
DROP COLUMN.
Если не соблюдать — получишь 5 минут 500-х ошибок от тех pod'ов, которые ещё не обновились / уже обновились слишком рано.
12. Никогда не правь применённую миграцию¶
Миграция с номером, который уже попал в main и прокатился на каком-то окружении, зафиксирована. Нашёл ошибку — не меняй файл, пиши новую миграцию поверх:
002_add_review_status.up.sql ← прошла, не трогаем
015_fix_review_status_default.up.sql ← исправление
golang-migrate отслеживает применённые миграции по номеру. Изменение содержимого уже применённой миграции приведёт к несогласованному состоянию на разных окружениях.
Troubleshooting¶
Миграция не применяется / падает — ../troubleshooting/migration-fails.md.
Чеклист¶
- Файлы
NNN_name.up.sqlиNNN_name.down.sqlсозданы. - Advisory lock в up.sql (и в down.sql, если в транзакции).
- Breaking-изменения разбиты на expand-contract миграции.
-
CREATE INDEX CONCURRENTLY— безBEGIN/COMMIT. -
make migrate-up && make migrate-down && make migrate-upработает. - Integration-тест миграций в
make testпроходит. -
EXPLAIN ANALYZEновых запросов/индексов прогнан. - Старая миграция не изменена.
Связанные разделы¶
../conventions/db-pgx.md— правила работы с Postgres через pgx, индексы, expand-contract.../troubleshooting/migration-fails.md— диагностика: миграция упала, advisory lock, dirty state.