4. Миграции базы данных
Введение: Контроль версий для схемы БД
Представьте, что вам поручили провести капитальный ремонт водопровода и проводки в многоквартирном жилом доме. Но есть строгое условие: жильцы не должны покидать свои квартиры, свет и вода не могут отключаться ни на секунду, а краны на кухнях должны работать исправно в любой момент времени. Если сантехник без предупреждения перережет главную трубу — возникнет авария и потоп. Если электрик перепутает фазу с землей — сгорят все бытовые приборы.
Изменение структуры живой производственной базы данных — это ровно такой же капитальный ремонт на ходу. Пока миллионы пользователей совершают онлайн-покупки, переводят деньги и авторизуются в системе, инженеру необходимо добавить новые столбцы, перестроить индексы и разделить таблицы. Одно неверное движение — и эксклюзивная блокировка заморозит запросы, пул соединений переполнится за секунды, а сервис упадет в глухой даунтайм.
Миграции базы данных — это механизм версионирования изменений схемы (таблиц, индексов, представлений, ограничений) и структуры данных. В современной архитектуре бэкенда, где код деплоится автоматически через CI/CD, база данных часто становится главным узким местом. Неуправляемые изменения схемы ведут к рассинхрону кода и БД, потере данных и даунтайму.
Для инженера уровня Senior/Lead понимание миграций — это не просто умение запускать команду migrate up. Это глубокое знание того, как СУБД обрабатывает DDL-команды, как транзакции взаимодействуют с блокировками таблиц, как минимизировать влияние на продакшен и как спроектировать стратегию безопасного изменения схемы без остановки сервиса.
В этой статье мы разберем:
- Архитектуру системы миграций и внутреннюю логику трекинга версий.
- Различия в обработке DDL в PostgreSQL и MySQL (транзакционность против имплисит-коммитов).
- Идиоматическую интеграцию миграций в Go-приложение через
embed.FSиgolang-migrate. - Механику блокировок, генерации WAL и влияние на диск при изменении больших таблиц.
- Паттерны Zero-Downtime развертывания: Expand-Contract,
CONCURRENTLYи пакетная обработка. - Типичные ловушки, антипаттерны и вопросы с хардовых собеседований.
1. Архитектура механизма миграций
Любой современный инструмент миграций строится вокруг единой концептуальной модели:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
FS["Файловая система миграций<br>up_down версии"]:::process --> Parse["Парсер и сортировка"]
Parse --> Check["Проверка таблицы версий"]
Check --> DBState{"Сравнение текущей и целевой версии"}
DBState -- Равны --> Done["Готово, миграции не требуются"]
DBState -- Ниже --> Apply["Открыть транзакцию"]
Apply --> Exec["Выполнить up-скрипты по порядку"]
Exec --> UpdateVer["Обновить номер версии в БД"]
UpdateVer --> Commit{"Успех?"}
Commit -- Да --> End["Commit транзакции"]
Commit -- Нет --> Rollback["Rollback и установка dirty=1"]
Таблица трекинга: schema_migrations
Обычно инструмент создает служебную таблицу:
CREATE TABLE schema_migrations (
version BIGINT PRIMARY KEY,
dirty BOOLEAN DEFAULT FALSE,
applied_at TIMESTAMP DEFAULT NOW()
);
- version: Уникальный номер или хеш миграции.
- dirty: Флаг, указывающий на частичное выполнение. Если
dirty = TRUE, дальнейшие миграции блокируются до ручного вмешательства. - applied_at: Аудит времени применения.
2. Под капотом: Транзакции, DDL-блокировки и поведение СУБД
Поведение DDL (Data Definition Language) команд фундаментально различается в реляционных СУБД, что напрямую влияет на стратегии миграций.
PostgreSQL: Транзакционный DDL
PostgreSQL поддерживает транзакционный DDL. Это означает, что CREATE TABLE, ALTER COLUMN, CREATE INDEX можно обернуть в BEGIN / COMMIT или ROLLBACK.
BEGIN;
ALTER TABLE users ADD COLUMN last_login TIMESTAMP;
-- Если следующая команда упадет, вся миграция откатится!
CREATE INDEX idx_users_last_login ON users(last_login);
COMMIT;
MySQL: Имлисит-коммиты
В MySQL (движок InnoDB) большинство DDL-команд не поддерживают транзакции. Выполнение ALTER TABLE автоматически завершает текущую транзакцию (COMMIT) и применяет изменения немедленно. Если скрипт упадет на второй команде, первая уже применится и не откатится.
3. Идиоматическая интеграция в Go
В Go 1.16+ появился пакет embed, который позволяет встраивать файлы миграций прямо в бинарник. Это устраняет зависимость от внешних путей к файлам при деплое.
3.1. Использование golang-migrate с embed
package db
import (
"database/sql"
"embed"
"fmt"
"log"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
_ "github.com/golang-migrate/migrate/v4/source/file" // Требуется для парсинга embed
"github.com/golang-migrate/migrate/v4/source/iofs"
)
//go:embed migrations/*.sql
var migrationFS embed.FS
func RunMigrations(dsn string) error {
db, err := sql.Open("postgres", dsn)
if err != nil {
return fmt.Errorf("open db for migration: %w", err)
}
defer db.Close()
driver, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return fmt.Errorf("create migration driver: %w", err)
}
src, err := iofs.New(migrationFS, "migrations")
if err != nil {
return fmt.Errorf("create source: %w", err)
}
m, err := migrate.NewWithInstance("iofs", src, "postgres", driver)
if err != nil {
return fmt.Errorf("init migrate: %w", err)
}
// Запуск всех ожидающих миграций
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
return fmt.Errorf("run migrations: %w", err)
}
log.Println("Migrations applied successfully")
return nil
}
3.2. Go-миграции для сложной логики
Когда чистого SQL недостаточно (нужно прочитать данные, преобразовать, вставить обратно), goose и golang-migrate поддерживают миграции на чистом Go.
// migration_002_backfill_user_status.go
package migrations
import (
"database/sql"
"github.com/pressly/goose/v3"
)
func init() {
goose.AddMigration(Up002, Down002)
}
func Up002(tx *sql.Tx) error {
// Пакетная обработка для больших таблиц
// Читаем старые записи, вычисляем новый статус, обновляем
_, err := tx.Exec(`
UPDATE users SET status = 'active'
WHERE last_login > NOW() - INTERVAL '30 days'
`)
return err
}
func Down002(tx *sql.Tx) error {
_, err := tx.Exec(`
UPDATE users SET status = 'unknown'
WHERE last_login > NOW() - INTERVAL '30 days'
`)
return err
}
4. Стратегии Zero-Downtime: Expand-Contract
Прямое удаление колонки или переименование поля в высоконагруженной системе приведет к ошибкам в старых версиях приложения, которые еще не обновились. Решением является паттерн Expand-Contract.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Phase1["Expand<br>Добавляем новую колонку/таблицу"]:::process --> Phase2["Dual Write<br>Пишем в старое и новое"]
Phase2 --> Phase3["Backfill<br>Переносим исторические данные"]
Phase3 --> Phase4["Contract<br>Переключаем чтение на новое"]
Phase4 --> Phase5["Drop<br>Удаляем старое после всех релизов"]
Пример: Переименование колонки name -> full_name
- Deploy v1: Добавляем
full_name. Приложение продолжает читать/писать вname. - Деплой мигратора: Копируем данные из
nameвfull_name(пакетно, чтобы не блокировать). - Deploy v2: Приложение пишет в обе колонки, читает из
full_name. - Deploy v3: Приложение перестает писать в
name. - Миграция:
ALTER TABLE users DROP COLUMN name.
5. Mechanical Sympathy: Диск, WAL и нагрузка при миграциях
Понимание того, что происходит на уровне железа при миграциях, отделяет инженера от новичка.
5.1. Table Rewrite и I/O
В старых версиях PostgreSQL и в MySQL (InnoDB) команда ALTER TABLE ADD COLUMN с DEFAULT или изменением типа данных (например, INT -> BIGINT) часто вызывает полную перезапись таблицы.
- СУБД читает каждую страницу диска.
- Создает временную таблицу с новой структурой.
- Копирует данные.
- Меняет системные указатели.
- Результат: Колоссальная нагрузка на диск, генерация гигабайтов WAL, блокировка чтения/записи.
5.2. Генерация WAL и MVCC-оверхед
Любое UPDATE в PostgreSQL не меняет данные на месте. Оно создает новую версию строки (tuple), а старую помечает как мертвую. При миграции-бэкилле на 10 млн строк вы:
- Создаете 10 млн новых версий в куче.
- Генерируете огромный объем WAL.
- Заставляете
VACUUMв будущем чистить 10 млн мертвых кортежей. - Влияние на кэш: При массовой записи вы вымываете полезные данные из
shared_buffersи Page Cache ОС, что замедляет обычные пользовательские запросы.
6. Типичные ошибки и антипаттерны
- Одна гигантская миграция: Файл на 500 строк, меняющий 10 таблиц. Если что-то упадет, откат сложнейший. Разбивайте на атомарные шаги.
- Игнорирование таймаутов: Длительные
ALTERилиUPDATEбезstatement_timeoutблокируют пул соединений и ждут вечно. Всегда устанавливайте таймаут на сессию миграции. - Неидемпотентные скрипты:
CREATE TABLE usersбезIF NOT EXISTSупадет при повторном запуске. Используйте защитные конструкции или доверяйте инструменту трекинга версий. - Запуск миграций из нескольких подов: В кластере K8s несколько реплик сервиса могут одновременно попытаться применить миграции. Используйте
advisory locks(PostgreSQL:SELECT pg_try_advisory_lock(...)) илиleader election, чтобы миграции запускались только на одной ноде.
7. Итог
Миграции базы данных — это критический компонент жизненного цикла приложения. Их правильное проектирование требует баланса между скоростью разработки, безопасностью данных и влиянием на продакшен.
Ключевые принципы для уровня Senior/Lead:
- Атомарность и идемпотентность — основа надежности.
- Понимание разницы между транзакционным DDL (PostgreSQL) и имплисит-коммитами (MySQL).
- Избегание блокировок через
CONCURRENTLYи пакетную обработку. - Стратегия Expand-Contract для Zero-Downtime развертывания.
- Встраивание миграций в бинарник через
embed.FSи четкое разделение ответственности (миграции как отдельный процесс/Job).
Освоив механику миграций, вы сможете безопасно изменять схему БД, не боясь рассинхрона кода и потери данных. Но миграции — это лишь одна часть жизненного цикла. Как правильно версионировать саму схему, управлять историей изменений и интегрировать это с CI/CD пайплайном? В следующей статье мы разберем принципы и инструменты для этого: 5. Версионирование схемы.