Перейти к содержанию

Как добавить миграцию

Пошаговый рецепт. Формат файлов, advisory lock, expand-contract и rolling- deploy — подробно описаны в ../conventions/db-pgx.md. Здесь — практическая последовательность действий.

Содержание

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 новых запросов/индексов прогнан.
  • Старая миграция не изменена.

Связанные разделы