Олег Марков
Пошаговая миграция step-by-step - практическое руководство для разработчиков
Введение
Пошаговая миграция (step-by-step migration) — это подход, при котором вы переносите систему, данные или функциональность не одним большим "рывком", а серией небольших контролируемых шагов. Такой подход применяют при:
- смене версии фреймворка или языка;
- миграции монолита на микросервисы;
- изменении структуры базы данных;
- переносе инфраструктуры в облако;
- внедрении новой архитектуры (например, CQRS, event sourcing).
Суть пошаговой миграции в том, чтобы вы могли:
- запускать новые части системы параллельно со старыми;
- иметь возможность отката на каждом этапе;
- минимизировать риски и простои;
- постепенно переводить пользователей и данные на новую версию.
Давайте разберем, как вы можете выстроить этот процесс по шагам и что важно учесть, чтобы миграция прошла предсказуемо.
Общие принципы пошаговой миграции
Основная идея подхода step-by-step
Пошаговая миграция опирается на несколько ключевых принципов:
Малые инкрементальные изменения
Лучше сделать 10 маленьких безопасных шагов, чем один гигантский, который сложно протестировать и откатить.Обратная совместимость между шагами
Старый и новый код, старый и новый формат данных какое-то время живут вместе. Каждый шаг должен быть совместим как минимум в одну сторону.Возможность быстрого отката
На каждом этапе вы должны понимать, как вернуться в исходное состояние с минимальными потерями.Наблюдаемость и измеримость
На каждом шаге вы собираете метрики и логи, чтобы вовремя заметить проблемы.Изоляция рисков
Новая логика запускается сначала для малого процента пользователей или в ограниченном окружении.
Типичные сценарии, где нужен step-by-step
Чтобы вам было проще связать теорию с практикой, перечислю типичные сценарии:
- миграция схемы базы данных без остановки сервиса;
- замена одного сервиса другим (например, внутреннего авторизационного сервиса на внешний);
- перевод API с одной версии на другую;
- перенос очередей сообщений с одной шины на другую (RabbitMQ → Kafka);
- переход с монолитного приложения на набор микросервисов.
Дальше мы будем рассматривать общую логику, а затем несколько типовых примеров с техническими деталями.
Шаг 1. Анализ текущего состояния
Инвентаризация системы
Перед любыми изменениями важно понять, что именно вы собираетесь мигрировать. Здесь полезно составить "карту" системы:
- какие компоненты участвуют (сервисы, базы, внешние интеграции);
- какие данные где хранятся;
- какие протоколы и форматы используются (JSON, gRPC, XML);
- какие SLA и RPO/RTO действуют (допустимый простой, потеря данных).
Определение границ миграции
Смотрите, на этом этапе вы отвечаете на вопросы:
- Что именно вы меняете — только схему БД, только код, и то и другое?
- Какие пользователи или части бизнеса задействованы?
- Какие компоненты затронуты напрямую, а какие — косвенно?
Четко очерченные границы помогают не "размазать" миграцию по всей системе.
Выявление скрытых зависимостей
Частая проблема — скрытые зависимости, о которых вы узнаете уже в процессе миграции. Чтобы их найти:
- просмотрите логи обращений к старым API;
- проверьте, кто читает таблицы/топики напрямую;
- найдите скрипты и cron-задачи, которые используют старый формат данных.
Шаг 2. Планирование миграции
Формирование маршрута миграции
Теперь вы составляете план из конкретных шагов. Для каждого шага полезно описать:
- цель шага;
- предполагаемое изменение (код, схема, конфигурация);
- как будет происходить откат;
- как вы поймете, что шаг прошел успешно.
Например, для миграции схемы БД:
- Добавить новые столбцы без удаления старых.
- Начать записывать данные сразу в старый и новый столбец (dual write).
- Перенести исторические данные.
- Переключить чтение на новый столбец.
- Удалить старый столбец.
Декомпозиция на минимальные изменения
Давайте разберемся, как разбивать изменения:
- отдельный шаг на изменение схемы;
- отдельный шаг на изменение кода, который пишет;
- отдельный шаг на изменение кода, который читает;
- отдельный шаг на удаление старой логики.
Каждый шаг должен быть:
- атомарным (все или ничего);
- наблюдаемым (можно проверить результат);
- откатываемым (есть понятная инструкция).
План отката
План отката лучше продумать заранее, а не в момент инцидента. Для каждого шага:
- какие артефакты нужно сохранить (backup БД, старый конфиг);
- до какого момента откат считается безопасным;
- как вы будете возвращать трафик на старую версию.
Шаг 3. Подготовка инфраструктуры и окружений
Разделение окружений
По возможности используйте несколько окружений:
- dev — для первичной реализации;
- staging/preprod — максимально близко к бою;
- production — основное окружение.
Пошаговую миграцию вы сначала отрабатываете на staging, а уже потом переносите на prod.
Фиче-флаги и конфигурация
Фиче-флаги — один из ключевых инструментов step-by-step. Они позволяют:
- включать/выключать новую функциональность без деплоя;
- включать фичу для части пользователей.
Пример на Go, как можно использовать фиче-флаг для постепенного включения новой логики:
// Config содержит фиче-флаги
type Config struct {
EnableNewCalc bool
}
// calculateOldVersion - старая реализация
func calculateOldVersion(input int) int {
// Здесь старая безопасная логика
return input * 2
}
// calculateNewVersion - новая реализация
func calculateNewVersion(input int) int {
// Здесь новая логика, которая поначалу включена только для части трафика
return input * 3
}
// Calculate - точка входа, которая выбирает реализацию по флагу
func Calculate(cfg Config, input int) int {
if cfg.EnableNewCalc {
// Если фиче-флаг включен - используем новую реализацию
return calculateNewVersion(input)
}
// Если флаг выключен - работаем по старому
return calculateOldVersion(input)
}
Здесь вы можете включать EnableNewCalc сначала на dev, потом на части пользователей в prod, и при необходимости быстро вернуться к старой логике.
Мониторинг и алерты
Перед миграцией настройте:
- метрики ошибок (error rate);
- время ответа;
- количество запросов к старому и новому пути;
- отдельные алерты для новых компонентов.
Это позволит вам увидеть, как новая часть системы ведет себя под реальной нагрузкой.
Шаг 4. Пошаговая миграция данных (пример с базой данных)
Давайте разберемся на конкретном сценарии: вы хотите изменить структуру таблицы в базе данных без простоя.
Стратегия expand and contract
Чаще всего используют стратегию expand and contract:
- Expand — расширяем схему, добавляя новые элементы, не ломая старые.
- Transition — код постепенно начинает использовать новую схему.
- Contract — удаляем старые элементы, когда они больше не нужны.
Пример миграции столбца: rename поля
Предположим, у вас есть таблица users с колонкой full_name, и вы хотите разделить ее на first_name и last_name.
Шаг 1. Расширяем схему (expand)
SQL:
-- Добавляем новые колонки, не трогая старую
ALTER TABLE users
ADD COLUMN first_name VARCHAR(255),
ADD COLUMN last_name VARCHAR(255);
Комментарий:
- Старая колонка
full_nameвсе еще работает. - Старый код продолжает функционировать как раньше.
Шаг 2. Обновляем код записи (dual write)
Теперь давайте обновим код приложения так, чтобы он писал данные и в старый, и в новый формат.
Пример на Go:
type User struct {
ID int64
FullName string // старое поле
FirstName string // новое поле
LastName string // новое поле
}
// SaveUser - сохраняет пользователя в базу
func SaveUser(db *sql.DB, u User) error {
// Здесь мы сохраняем данные одновременно в new и old формат
_, err := db.Exec(`
UPDATE users
SET full_name = $1,
first_name = $2,
last_name = $3
WHERE id = $4
`, u.FullName, u.FirstName, u.LastName, u.ID)
// Если выполнение дошло до этой точки без ошибок, значит запись прошла успешно
return err
}
Обратите внимание:
- Система все еще читает
full_name, но уже пишет вfirst_nameиlast_name. - Новые колонки постепенно заполняются.
Шаг 3. Миграция исторических данных
Теперь вам нужно перенести старые значения из full_name в новые поля. Это лучше делать батчами.
Пример SQL процедуры или скрипта (псевдокод на Go):
// migrateBatch - мигрирует часть пользователей
func migrateBatch(db *sql.DB, limit int) error {
// Забираем пользователей, у которых новые поля еще не заполнены
rows, err := db.Query(`
SELECT id, full_name
FROM users
WHERE first_name IS NULL
LIMIT $1
`, limit)
if err != nil {
return err
}
defer rows.Close()
type rowData struct {
id int64
fullName string
}
var batch []rowData
for rows.Next() {
var r rowData
if err := rows.Scan(&r.id, &r.fullName); err != nil {
return err
}
batch = append(batch, r)
}
// Обрабатываем каждую запись
for _, r := range batch {
// Здесь вы пишете свою логику разбиения full_name на first/last
firstName, lastName := splitFullName(r.fullName)
// Обновляем запись в базе
_, err := db.Exec(`
UPDATE users
SET first_name = $1,
last_name = $2
WHERE id = $3
`, firstName, lastName, r.id)
if err != nil {
// При желании можно логировать ошибку и идти дальше, а не падать
return err
}
}
return nil
}
Комментарии:
- Скрипт запускается несколькими проходами, чтобы не блокировать базу.
- Вы контролируете нагрузку за счет параметра
limit.
Шаг 4. Переключение чтения на новую схему
После того как вы убедились, что новые поля заполнены, можно изменить код чтения:
// GetUser - читает пользователя из базы, используя новые поля
func GetUser(db *sql.DB, id int64) (User, error) {
var u User
// Здесь мы читаем только новые поля
err := db.QueryRow(`
SELECT id, first_name, last_name
FROM users
WHERE id = $1
`, id).Scan(&u.ID, &u.FirstName, &u.LastName)
if err != nil {
return User{}, err
}
// Поле FullName можем собрать для обратной совместимости
u.FullName = u.FirstName + " " + u.LastName
return u, nil
}
Теперь приложение больше не зависит от full_name, хотя колонка еще существует в базе.
Шаг 5. Сжатие схемы (contract)
И только после того, как:
- вы проверили, что ни один сервис больше не использует
full_name; - у вас прошла "выдержка" во времени (например, несколько недель);
можно удалить старую колонку:
-- Удаляем устаревшую колонку только когда уверены, что она не используется
ALTER TABLE users
DROP COLUMN full_name;
Это и есть финальный шаг contract.
Шаг 5. Пошаговая миграция API
Частый сценарий: нужно перевести клиентов с API v1 на API v2, не ломая интеграции.
Подход с версионированием
Обычно используют URL-версии:
/api/v1/users/api/v2/users
Стратегия:
- Добавляем v2 рядом с v1.
- Внутри сервера стараемся переиспользовать общую бизнес-логику.
- Постепенно переводим клиентов на v2.
- Логируем обращения к v1, чтобы видеть, кто еще не мигрировал.
- После завершения — отключаем v1.
Пример маршрутизации запросов
Давайте посмотрим, как это может выглядеть в Go с использованием стандартного http пакета:
// handleV1Users - обработчик старой версии API
func handleV1Users(w http.ResponseWriter, r *http.Request) {
// Здесь можно добавить логирование, чтобы видеть, кто использует v1
// log.Println("v1 users called")
// Реализация в терминах бизнес-логики
users := fetchUsers()
writeJSON(w, users) // записываем список пользователей в старом формате
}
// handleV2Users - обработчик новой версии API
func handleV2Users(w http.ResponseWriter, r *http.Request) {
// Здесь можно добавить расширенную функциональность или новый формат
users := fetchUsers()
writeJSON(w, transformToV2(users)) // преобразуем данные к новому формату
}
// setupRoutes - настройка роутинга
func setupRoutes(mux *http.ServeMux) {
// Регистрируем обе версии API
mux.HandleFunc("/api/v1/users", handleV1Users)
mux.HandleFunc("/api/v2/users", handleV2Users)
}
Комментарии:
- Оба обработчика используют общую функцию
fetchUsers, чтобы не дублировать бизнес-логику. - Разница — в формате ответа и дополнительных полях.
Пошаговое выключение старого API
Чтобы миграция прошла мягко:
- Сначала включаете логирование и мониторинг обращений к
/api/v1. - Информируете клиентов, указывая дедлайн отключения.
- Через некоторое время ограничиваете v1, например:
- добавляете заголовок Warning;
- уменьшаете лимиты.
- На финальном этапе либо отключаете маршрут, либо возвращаете 410 Gone.
Пример временного ответа с предупреждением:
func handleV1Users(w http.ResponseWriter, r *http.Request) {
// Добавляем предупреждение в заголовки
w.Header().Set("Warning", "199 - API v1 is deprecated and will be removed soon")
users := fetchUsers()
writeJSON(w, users)
}
Шаг 6. Пошаговая миграция сервиса (blue-green и canary)
Когда вы заменяете один сервис другим, удобно использовать техники blue-green deployment и canary release.
Blue-green: два параллельных окружения
Суть подхода:
- у вас есть два окружения: blue (текущая версия) и green (новая);
- трафик идет только в одно окружение;
- вы переключаете трафик атомарно.
Пошаговая миграция здесь выглядит так:
- Развернуть новую версию в окружении green.
- Прогнать автотесты и ручные проверки.
- Переключить весь трафик на green.
- Оставить blue в живом состоянии на время как запасной вариант.
- Через время удалить или обновить blue.
Canary: постепенное включение трафика
Canary подача позволяет запускать новую версию постепенно:
- 1% трафика → новая версия.
- При успехе — 10% трафика.
- Затем 50% и только потом 100%.
Роутинг можно реализовать на уровне балансировщика или шлюза. На прикладном уровне можно использовать простую логику:
// shouldUseNewService - решает, направлять ли запрос на новый сервис
func shouldUseNewService(userID int64, percent int) bool {
// Простая хеш-функция, чтобы распределить пользователей
hash := userID % 100
// Если значение меньше порога - используем новый сервис
return int(hash) < percent
}
Комментарии:
- Вы можете хранить параметр
percentв конфигурации и менять его без деплоя. - Логика распределения детерминирована — один и тот же пользователь всегда попадает в один сегмент.
Шаг 7. Тестирование на каждом этапе
Виды тестов при step-by-step миграции
На каждом шаге полезно запускать:
- модульные тесты — проверка отдельных частей;
- интеграционные тесты — взаимодействие компонентов;
- миграционные тесты — проверка изменений схемы/данных;
- обратную совместимость — old client → new server, new client → old server.
Пример миграционного теста
Покажу вам, как можно проверить миграцию схемы в тесте:
func TestUserMigration(t *testing.T) {
db := setupTestDB(t) // здесь мы поднимаем временную тестовую базу
// 1. Создаем пользователя в старом формате
_, err := db.Exec(`
INSERT INTO users (id, full_name)
VALUES ($1, $2)
`, 1, "Ivan Petrov")
if err != nil {
t.Fatalf("cannot insert user - %v", err)
}
// 2. Запускаем миграционный скрипт (expand + migrate)
if err := runMigrationScript(db); err != nil {
t.Fatalf("migration failed - %v", err)
}
// 3. Проверяем, что новые поля заполнены
var firstName, lastName string
err = db.QueryRow(`
SELECT first_name, last_name
FROM users
WHERE id = $1
`, 1).Scan(&firstName, &lastName)
if err != nil {
t.Fatalf("cannot select migrated user - %v", err)
}
if firstName == "" || lastName == "" {
t.Fatalf("migration did not fill new fields - got %s %s", firstName, lastName)
}
}
Здесь вы явно проверяете, что миграция не только прошла без ошибок, но и сделала то, что нужно.
Шаг 8. Документация и коммуникация
Документация шагов миграции
Для сложных миграций полезно вести отдельный документ, где вы фиксируете:
- описание каждого шага;
- дату выполнения;
- наблюдения и проблемы;
- реальное время выполнения операций;
- изменения в плане.
Эта информация пригодится:
- при следующих миграциях;
- для постмортем-анализа;
- для новых членов команды.
Коммуникация с командами и пользователями
Если ваша миграция затрагивает другие команды или внешних клиентов:
- заранее предупреждайте о дедлайнах;
- предоставляйте инструкции для их стороны;
- сообщайте о промежуточном статусе (что уже сделано, что впереди).
Это снижает количество неожиданных инцидентов и конфликтов.
Шаг 9. Окончательный переход и удаление старого
Переход на новую систему — не финал миграции. Важно аккуратно удалить старую инфраструктуру.
Проверка отсутствия трафика на старую версию
Перед окончательным отключением старой схемы, API или сервиса:
- смотрите логи и метрики обращений;
- ищите остаточные запросы;
- договариваетесь с командами, если кто-то "забыл" обновиться.
Удаление старого кода и схемы
Удаление — это тоже отдельный шаг миграции, с которым нужно обращаться аккуратно:
- удаляйте старые обработчики API;
- чистите фиче-флаги, которые стали постоянными;
- удаляйте устаревшие колонки и таблицы;
- убирайте старые сервисы и конфигурацию.
Старайтесь не оставлять "мертвый код", который никто не использует, но который мешает пониманию системы.
Заключение
Пошаговая миграция — это системный подход к изменениям, когда вы:
- заранее анализируете текущую систему и зависимости;
- планируете маршрут миграции с четкими шагами;
- обеспечиваете обратную совместимость между шагами;
- используете фиче-флаги, версионирование и техники dual write;
- тестируете и мониторите каждый этап;
- планируете откат и финальное удаление старых сущностей.
Такой подход требует больше дисциплины на старте, но значительно уменьшает риск сбоев, упрощает откладку и делает процесс прозрачным как для команды разработки, так и для бизнеса.
Если вы будете относиться к каждой миграции как к мини-проекту со своими целями, планом и критериями успеха, то даже большие изменения в системе перестанут быть "большим взрывом" и превратятся в управляемую последовательность шагов.
Частозадаваемые технические вопросы по теме статьи и ответы на них
Как поступить, если нужно изменить тип колонки (например INT → BIGINT) без простоя
- Добавьте новую колонку с нужным типом, например
user_id_bigint. - Настройте код на dual write — записывайте значение и в старую, и в новую колонку.
- Мигрируйте данные батчами из старой в новую колонку.
- Переключите чтение на новую колонку, оставив старую в резерве.
- После выдержки во времени удалите старую колонку и при необходимости переименуйте новую.
Как мигрировать большие таблицы, чтобы не заблокировать базу
- Используйте батчевые обновления с лимитом строк (например по 1000).
- Между батчами делайте паузы, чтобы снизить нагрузку.
- Применяйте онлайн-миграции (pt-online-schema-change, gh-ost для MySQL).
- Предварительно создавайте индексы и новые колонки, не затрагивая существующие данные.
- Обязательно запускайте миграцию сначала на реплике или тестовой базе.
Как обеспечить согласованность при dual write, если запись в один из источников упала
- Логируйте все неудачные операции dual write в отдельную очередь или таблицу.
- Поднимите фоновый воркер, который повторно выполняет неудавшиеся записи.
- При критичных данных подумайте об использовании транзакций, объединяющих операции, или механизмов outbox/inbox.
- Мониторьте количество "хвостов" (ошибок записи) и реагируйте на рост.
Что делать, если часть клиентов не успела перейти на новую версию API к дедлайну
- Временно оставьте старую версию только для этих клиентов, ограничив доступ по ключам или спискам.
- Введите ограничения — более строгий rate limit, отсутствие новых фич.
- Предоставьте им мини-гайд по миграции с примерами.
- Определите новый жесткий дедлайн и заранее автоматизируйте отключение.
Как протестировать миграцию, если нет стейджинга с реальными данными
- Снимите анонимизированный дамп боевой базы (уберите персональные данные).
- Разверните временное окружение, максимально похожее на прод.
- Прогоните миграцию на этом дампе, замеряя время и ресурсы.
- По результатам скорректируйте батчи, тайм-ауты и расписание продовой миграции.