Пошаговая миграция step-by-step - практическое руководство для разработчиков

05 января 2026
Автор

Олег Марков

Введение

Пошаговая миграция (step-by-step migration) — это подход, при котором вы переносите систему, данные или функциональность не одним большим "рывком", а серией небольших контролируемых шагов. Такой подход применяют при:

  • смене версии фреймворка или языка;
  • миграции монолита на микросервисы;
  • изменении структуры базы данных;
  • переносе инфраструктуры в облако;
  • внедрении новой архитектуры (например, CQRS, event sourcing).

Суть пошаговой миграции в том, чтобы вы могли:

  • запускать новые части системы параллельно со старыми;
  • иметь возможность отката на каждом этапе;
  • минимизировать риски и простои;
  • постепенно переводить пользователей и данные на новую версию.

Давайте разберем, как вы можете выстроить этот процесс по шагам и что важно учесть, чтобы миграция прошла предсказуемо.

Общие принципы пошаговой миграции

Основная идея подхода step-by-step

Пошаговая миграция опирается на несколько ключевых принципов:

  1. Малые инкрементальные изменения
    Лучше сделать 10 маленьких безопасных шагов, чем один гигантский, который сложно протестировать и откатить.

  2. Обратная совместимость между шагами
    Старый и новый код, старый и новый формат данных какое-то время живут вместе. Каждый шаг должен быть совместим как минимум в одну сторону.

  3. Возможность быстрого отката
    На каждом этапе вы должны понимать, как вернуться в исходное состояние с минимальными потерями.

  4. Наблюдаемость и измеримость
    На каждом шаге вы собираете метрики и логи, чтобы вовремя заметить проблемы.

  5. Изоляция рисков
    Новая логика запускается сначала для малого процента пользователей или в ограниченном окружении.

Типичные сценарии, где нужен step-by-step

Чтобы вам было проще связать теорию с практикой, перечислю типичные сценарии:

  • миграция схемы базы данных без остановки сервиса;
  • замена одного сервиса другим (например, внутреннего авторизационного сервиса на внешний);
  • перевод API с одной версии на другую;
  • перенос очередей сообщений с одной шины на другую (RabbitMQ → Kafka);
  • переход с монолитного приложения на набор микросервисов.

Дальше мы будем рассматривать общую логику, а затем несколько типовых примеров с техническими деталями.

Шаг 1. Анализ текущего состояния

Инвентаризация системы

Перед любыми изменениями важно понять, что именно вы собираетесь мигрировать. Здесь полезно составить "карту" системы:

  • какие компоненты участвуют (сервисы, базы, внешние интеграции);
  • какие данные где хранятся;
  • какие протоколы и форматы используются (JSON, gRPC, XML);
  • какие SLA и RPO/RTO действуют (допустимый простой, потеря данных).

Определение границ миграции

Смотрите, на этом этапе вы отвечаете на вопросы:

  • Что именно вы меняете — только схему БД, только код, и то и другое?
  • Какие пользователи или части бизнеса задействованы?
  • Какие компоненты затронуты напрямую, а какие — косвенно?

Четко очерченные границы помогают не "размазать" миграцию по всей системе.

Выявление скрытых зависимостей

Частая проблема — скрытые зависимости, о которых вы узнаете уже в процессе миграции. Чтобы их найти:

  • просмотрите логи обращений к старым API;
  • проверьте, кто читает таблицы/топики напрямую;
  • найдите скрипты и cron-задачи, которые используют старый формат данных.

Шаг 2. Планирование миграции

Формирование маршрута миграции

Теперь вы составляете план из конкретных шагов. Для каждого шага полезно описать:

  • цель шага;
  • предполагаемое изменение (код, схема, конфигурация);
  • как будет происходить откат;
  • как вы поймете, что шаг прошел успешно.

Например, для миграции схемы БД:

  1. Добавить новые столбцы без удаления старых.
  2. Начать записывать данные сразу в старый и новый столбец (dual write).
  3. Перенести исторические данные.
  4. Переключить чтение на новый столбец.
  5. Удалить старый столбец.

Декомпозиция на минимальные изменения

Давайте разберемся, как разбивать изменения:

  • отдельный шаг на изменение схемы;
  • отдельный шаг на изменение кода, который пишет;
  • отдельный шаг на изменение кода, который читает;
  • отдельный шаг на удаление старой логики.

Каждый шаг должен быть:

  • атомарным (все или ничего);
  • наблюдаемым (можно проверить результат);
  • откатываемым (есть понятная инструкция).

План отката

План отката лучше продумать заранее, а не в момент инцидента. Для каждого шага:

  • какие артефакты нужно сохранить (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:

  1. Expand — расширяем схему, добавляя новые элементы, не ломая старые.
  2. Transition — код постепенно начинает использовать новую схему.
  3. 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

Стратегия:

  1. Добавляем v2 рядом с v1.
  2. Внутри сервера стараемся переиспользовать общую бизнес-логику.
  3. Постепенно переводим клиентов на v2.
  4. Логируем обращения к v1, чтобы видеть, кто еще не мигрировал.
  5. После завершения — отключаем 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

Чтобы миграция прошла мягко:

  1. Сначала включаете логирование и мониторинг обращений к /api/v1.
  2. Информируете клиентов, указывая дедлайн отключения.
  3. Через некоторое время ограничиваете v1, например:
    • добавляете заголовок Warning;
    • уменьшаете лимиты.
  4. На финальном этапе либо отключаете маршрут, либо возвращаете 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 (новая);
  • трафик идет только в одно окружение;
  • вы переключаете трафик атомарно.

Пошаговая миграция здесь выглядит так:

  1. Развернуть новую версию в окружении green.
  2. Прогнать автотесты и ручные проверки.
  3. Переключить весь трафик на green.
  4. Оставить blue в живом состоянии на время как запасной вариант.
  5. Через время удалить или обновить blue.

Canary: постепенное включение трафика

Canary подача позволяет запускать новую версию постепенно:

  1. 1% трафика → новая версия.
  2. При успехе — 10% трафика.
  3. Затем 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) без простоя

  1. Добавьте новую колонку с нужным типом, например user_id_bigint.
  2. Настройте код на dual write — записывайте значение и в старую, и в новую колонку.
  3. Мигрируйте данные батчами из старой в новую колонку.
  4. Переключите чтение на новую колонку, оставив старую в резерве.
  5. После выдержки во времени удалите старую колонку и при необходимости переименуйте новую.

Как мигрировать большие таблицы, чтобы не заблокировать базу

  1. Используйте батчевые обновления с лимитом строк (например по 1000).
  2. Между батчами делайте паузы, чтобы снизить нагрузку.
  3. Применяйте онлайн-миграции (pt-online-schema-change, gh-ost для MySQL).
  4. Предварительно создавайте индексы и новые колонки, не затрагивая существующие данные.
  5. Обязательно запускайте миграцию сначала на реплике или тестовой базе.

Как обеспечить согласованность при dual write, если запись в один из источников упала

  1. Логируйте все неудачные операции dual write в отдельную очередь или таблицу.
  2. Поднимите фоновый воркер, который повторно выполняет неудавшиеся записи.
  3. При критичных данных подумайте об использовании транзакций, объединяющих операции, или механизмов outbox/inbox.
  4. Мониторьте количество "хвостов" (ошибок записи) и реагируйте на рост.

Что делать, если часть клиентов не успела перейти на новую версию API к дедлайну

  1. Временно оставьте старую версию только для этих клиентов, ограничив доступ по ключам или спискам.
  2. Введите ограничения — более строгий rate limit, отсутствие новых фич.
  3. Предоставьте им мини-гайд по миграции с примерами.
  4. Определите новый жесткий дедлайн и заранее автоматизируйте отключение.

Как протестировать миграцию, если нет стейджинга с реальными данными

  1. Снимите анонимизированный дамп боевой базы (уберите персональные данные).
  2. Разверните временное окружение, максимально похожее на прод.
  3. Прогоните миграцию на этом дампе, замеряя время и ресурсы.
  4. По результатам скорректируйте батчи, тайм-ауты и расписание продовой миграции.
Рефакторинг под FSD - пошаговое руководство с примерамиСтрелочка вправо

Все гайды по Fsd

Открыть базу знаний

Отправить комментарий