Как добавить миграцию
Мы используем Atlas (versioned mode). Миграции —
обычный SQL в migrations/; целостность отслеживается atlas.sum; применяются
они отдельным шагом до раската (Argo CD PreSync hook), а не на старте
приложения. Принципы (expand-contract, online DDL, least-privilege) — в
../conventions/db-pgx.
Раньше был golang-migrate +
*.up.sql/*.down.sql+ advisory-lock в каждой миграции +MIGRATE-on-startup. Это устарело — см. миграцию на Atlas внизу.
Содержание
- Модель в двух словах
- 1. Определить тип изменения
- 2. Создать миграцию
- 3. CONCURRENTLY и не-транзакционные миграции
- 4. Expand-contract
- 5. Триггеры, функции, materialized view
- 6. Lint и локальный прогон
- 7. Как это применяется в кластере
- 8. Чего не делать
- 9. Никогда не правь применённую миграцию
- Backfill safety
- Checkpointing для больших миграций
- Rollback testing на staging
- Чеклист
- Миграция на Atlas: что изменилось
Модель в двух словах
- Миграция = один forward-SQL-файл
migrations/NNNN_name.sql. Down-файлов нет (в prod катимся только вперёд — fix-forward). migrations/atlas.sum— файл целостности (хэши). Генерится Atlas, руками не правится, коммитится вместе с миграцией.- Версии применённых миграций Atlas хранит в таблице
atlas_schema_revisions(своя, в схеме сервиса). Никакогоschema_migrationsруками и advisory-lock в каждом файле — Atlas сам берёт session-lock на весь прогон. - Применяет миграции PreSync-hook Job (Atlas-образ сервиса) до того, как
Argo катит Deployment. Упала миграция → деплой не прошёл, работающий сервис
не падает. DDL выполняет выделенная роль
<svc>_migrator; рантайм-роль сервиса — только DML.
1. Определить тип изменения
- Non-breaking: новая таблица, nullable-колонка, индекс. Старый код не ломается; миграция совместима с текущим prod-кодом.
- Breaking: rename/drop колонки, change type,
ADD NOT NULLна большой таблице. Требует expand-contract (несколько миграций + промежуточные деплои кода).
Не уверен — считай breaking.
2. Создать миграцию
Из корня репозитория сервиса:
# создаёт пустой версионированный файл migrations/<ts>_add_review_status.sql
# и обновляет atlas.sum
atlas migrate new add_review_status --env localВпиши forward-SQL в созданный файл:
-- migrations/20240612090000_add_review_status.sql
ALTER TABLE reviews ADD COLUMN status TEXT;Пересчитай целостность (после любой ручной правки файлов миграций):
atlas migrate hash --env localИмя описывает что делает: хорошо — add_review_status,
index_reviews_place_id; плохо — fix_bug_PROJ-1234, tmp_revert.
Транзакция: Postgres DDL транзакционен, и Atlas по умолчанию оборачивает каждую миграцию в транзакцию.
BEGIN/COMMITвнутри файла не пиши — исключение только для CONCURRENTLY (см. ниже).
3. CONCURRENTLY и не-транзакционные миграции
CREATE INDEX CONCURRENTLY не работает внутри транзакции. Помечай такой файл
директивой Atlas — он выполнит его вне транзакции:
-- atlas:txmode none
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_reviews_place_id
ON reviews (place_id)
WHERE deleted_at IS NULL;Одна не-транзакционная операция = один файл (не смешивай с обычным DDL).
4. Expand-contract
Rolling deploy = старый и новый код работают одновременно, поэтому breaking-
изменения режутся на обратно-совместимые шаги. Пример nickname → username:
- expand:
ALTER TABLE users ADD COLUMN username TEXT;(nullable) → deploy. - код dual-write: пишет оба поля, читает
nickname→ deploy. - backfill: заполнить
usernameбатчами (см. Backfill safety) — отдельным job, не миграцией. - код: читать
username(fallbacknickname), писать оба → deploy. - contract-1:
ALTER TABLE users ALTER COLUMN username SET NOT NULL;(после проверкиCOUNT(*) WHERE username IS NULL = 0). - код: только
username→ deploy. - contract-2:
ALTER TABLE users DROP COLUMN nickname;.
4 миграции + 3 деплоя, но zero-downtime. Одна миграция с RENAME COLUMN
гарантированно ломает rolling-deploy. atlas migrate lint валит PR на таких
несовместимостях — это и есть страховка дисциплины.
5. Триггеры, функции, materialized view
Пишутся как обычный SQL в миграции — Atlas применяет любой SQL, ограничений нет:
CREATE FUNCTION reviews.touch() RETURNS trigger AS $$
BEGIN NEW.updated_at = now(); RETURN NEW; END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER reviews_touch BEFORE UPDATE ON reviews
FOR EACH ROW EXECUTE FUNCTION reviews.touch();
CREATE MATERIALIZED VIEW reviews.place_rating AS
SELECT place_id, avg(rating) AS rating FROM reviews GROUP BY place_id;⚠️ Важно про ревью: atlas migrate lint (community) не моделирует
триггеры/функции/materialized view в дифф-анализе — он проверит statement-level
(деструктив/локи по таблицам), но семантику этих объектов глубоко не разберёт.
Поэтому миграции, трогающие их, требуют усиленного ручного ревью:
REFRESH MATERIALIZED VIEWберёт AccessExclusiveLock → используйREFRESH MATERIALIZED VIEW CONCURRENTLY(нужен UNIQUE-индекс на mat-view) и выноси тяжёлый refresh из миграции в job/cron.- Триггер на hot-таблице добавляет латентность на каждый write — оцени.
- Изменение/удаление триггера/функции, от которой зависит работающий код, — тоже expand-contract (сначала перестать зависеть, потом дропнуть).
В PR явно отметь, что миграция трогает триггер/функцию/mat-view, чтобы ревьюер посмотрел эти пункты.
6. Lint и локальный прогон
# safety-анализ (деструктив, локи, обратная несовместимость) — то же гоняет CI
atlas migrate lint --env ci --latest 1
# применить локально (поднимет твою dev-БД до текущей схемы)
atlas migrate apply --env local --url "postgres://localhost:5432/<svc>?sslmode=disable"
# что применено / что pending
atlas migrate status --env local --url "..."atlas migrate lint запускается на каждом PR, трогающем migrations/
(workflow atlas-migrate-lint), и роняет PR на опасных изменениях +
комментирует результат. Целостность atlas.sum проверяет atlas migrate validate.
7. Как это применяется в кластере
Ничего вручную в проде не запускаем. Поток:
- Мержишь PR → CI собирает на один SHA оба образа: app
…/<svc>:<sha>и миграций…/<svc>-migrations:<sha>(Atlas CLI + каталогmigrations/). - Проставляешь оба tag’а в gitops-overlay сервиса. Бампни и app-образ: PreSync-Job — это Argo-hook, не отслеживаемый ресурс, и смена только migrations-образа не даёт diff → синк не запустится и миграция не применится. Бамп app-тега даёт diff Deployment, который триггерит синк (в обычном потоке миграция и так едет вместе с кодом, так что app-образ меняется сам).
- Argo на синке запускает PreSync-hook Job
atlas migrate apply --env k8s(под ролью<svc>_migrator, через session-pool алиас<db>_migrate) до Deployment. Успех → катится новый код; провал → деплой блокируется, сервис жив.
На свежей БД (после restore) тот же Job накатывает всё с нуля — схема
самовосстанавливается. На уже заполненной БД делается разовый --baseline
(см. runbook сервиса).
8. Чего не делать
- Long lock на hot-таблице:
ADD COLUMN ... NOT NULL DEFAULTна большой таблице — разбей (nullable → backfill → SET DEFAULT → SET NOT NULL). CREATE INDEXбез CONCURRENTLY на большой таблице — блокирует writes. Используй-- atlas:txmode none+CONCURRENTLY(см. §3).- Большой
UPDATEодной транзакцией — backfill только батчами и не в миграции (см. Backfill safety). - Cross-service/ cross-database в миграции — миграция трогает только свою БД; изменения в чужом сервисе — через событие (outbox + Kafka).
9. Никогда не правь применённую миграцию
Файл миграции, попавший в main, зафиксирован — Atlas хранит его хэш в
atlas.sum и сверяет с применённым. Нашёл ошибку → новая миграция поверх
(atlas migrate new fix_…), а не правка старого файла. Изменение применённого
файла сломает atlas migrate validate (integrity) на всех окружениях.
Backfill safety
Backfill большой таблицы атомарным UPDATE без LIMIT возьмёт row-lock на все
строки, выжрет WAL и зависнет. Атомарный backfill запрещён для таблиц > 100k
строк. DDL и backfill — в разных шагах: миграция добавляет nullable-колонку
/ индекс CONCURRENTLY; сам backfill — отдельный Go-job/SQL-скрипт, батчами.
WITH batch AS (
SELECT id FROM reviews
WHERE author_slug IS NULL
ORDER BY id LIMIT 5000
FOR UPDATE SKIP LOCKED
)
UPDATE reviews r SET author_slug = lower(r.author_name)
FROM batch WHERE r.id = batch.id;- Batch 1000–10000; пауза между батчами (replica catch-up).
FOR UPDATE SKIP LOCKED— параллельный запуск не пересечётся.- Идемпотентность:
WHERE new_col IS NULLотсекает обработанные. - Метрика
backfill_rows_done_total{migration}+ alertrate(...[5m]) == 0 FOR 30mприrows_total > 0.
Checkpointing для больших миграций
Большая = > 10M строк ИЛИ > 1 часа. Однопроходно нельзя (kill pod → с нуля; растущий WAL; replication lag; блок autovacuum). Паттерн:
- Checkpoint-таблица
migration_checkpoints(migration_id PK, last_processed_id, …)в схеме сервиса. - Go-
Job(не Deployment),restartPolicy: OnFailure,activeDeadlineSeconds. processBatchделает UPDATE строк и UPDATE checkpoint в одной транзакции → после restart возобновляется с последнего батча.- Первая ошибка в батче →
return err(неcontinue— молча потеряешь строки). - Метрики
migration_rows_done_total/_total+ alert на «не растёт 15 мин».
Rollback testing на staging
Любая миграция проверяется на staging перед prod. Без этого PR не мержится.
- Снимок схемы/данных до (
pg_dump --schema-only,--data-onlyключевых таблиц). - Применить ровно одну:
atlas migrate apply --env <staging> --latest 1. - Smoke сервиса (
/healthz+ read-heavy + write-heavy endpoint). - Проверить совместимость код↔схема: для expand — сервис v(N-1) работает на схеме v(N); для contract — v(N+1) на v(N).
- Откат: в проде только fix-forward;
atlas migrate down— лишь на staging/dev (см.rollback-migration).
В описании PR обязательна секция:
## Rollback plan
- Strategy: fix-forward / atlas migrate down (dev only) / snapshot restore
- Tested on staging: yes / date
- Data loss risk: none / bounded (X rows) / catastrophic
- Touches trigger/function/materialized view: no / yes (что именно)Без неё ревьюер не аппрувит.
Чеклист
- Один forward-файл
migrations/<ts>_name.sql;atlas migrate hashпрогнан,atlas.sumобновлён. -
CREATE INDEX CONCURRENTLY→-- atlas:txmode none, безBEGIN/COMMIT. - Breaking разбит на expand-contract.
- Backfill — отдельным job, батчами (не в миграции).
- Триггер/функция/mat-view — отмечено в PR, усиленное ревью (локи/refresh).
-
atlas migrate lint --env ciзелёный локально; CI-lint пройдёт. - Применённую миграцию не правил — фикс новой миграцией.
- Rollback plan в описании PR.
Миграция на Atlas: что изменилось
| Было (golang-migrate) | Стало (Atlas) |
|---|---|
NNN_name.up.sql + .down.sql | один forward NNNN_name.sql, down нет (fix-forward) |
| advisory-lock в каждом файле | Atlas сам берёт session-lock на прогон |
schema_migrations (своя) | atlas_schema_revisions (Atlas) |
make migrate-up/down | atlas migrate apply/status, lint в CI |
MIGRATE=true на старте приложения | PreSync-hook Job до раската (decoupled) |
| рантайм-роль с DDL | <svc>_migrator (DDL) + рантайм DML-only |
| нет авто-проверки безопасности | atlas migrate lint роняет опасный PR |