{ "index": 54, "slug": "editorial-2026-07-practice-migration-playbook", "title": "Миграция без прыжка: как сохранить данные и управлять откатом", "excerpt": "Пошаговая схема миграции с инвентарём, совместимыми версиями, контрольным срезом трафика и отдельным планом возврата данных.", "contentHtml": "
После переключения на новую версию часть заказов читает новые поля, а часть записывает старые. HTTP-ответы остаются успешными. Ошибка проявляется позже: фильтр не находит заказ, отчёт считает две версии одной записи разными, а возврат трафика не возвращает уже записанные данные. Команда видит зелёный deploy, но не может быстро ответить, что именно откатывать.
\nЦена ошибки — не только простой. Оператор повторяет операции, разработчик сверяет несовместимые логи, а ручное исправление может создать дубликаты. Чем дольше работают две схемы, тем больше записей пересекают границу. Поэтому миграцию нельзя сводить к копированию и последующему cutover.
\nТезис простой: безопасный переход состоит из совместимых состояний, ограниченного среза трафика и заранее названного пути возврата. Каждый этап должен отвечать на четыре вопроса: что меняется, кто владеет состоянием, как проверяется переход и что вернётся при отказе. Если ответа нет, этап останавливается.
\nРассмотрим учебный пример. Сервис заказов хранит поле status, а новая версия хочет использовать state. Нельзя сразу удалить старое поле. Сначала новая схема принимает оба имени, затем приложение пишет оба значения, потом команда сверяет записи и переводит чтение на новое поле. Только после этого старый контракт можно убрать.
Такой порядок разделяет четыре разных изменения. Схема должна принять новый формат. Писатель должен создать согласованные значения. Читатель должен уметь сравнить старое и новое представление. Маршрутизатор должен направить ограниченный поток на новый путь. Если один шаг смешать с другим, откат приложения не отменит изменение данных.
\nНачните с одного маршрута, а не со всей системы. Запишите его владельца, читателя, писателя, запись и внешние зависимости. Для примера это GET /orders/:id, таблица orders, обработчик записи и индекс, которым пользуется отчёт.
Связи важнее списка файлов. Если известен маршрут, но неизвестен писатель, нельзя оценить совместимость записи. Если известен писатель, но нет читателя отчёта, нельзя определить, где появится расхождение. Пустое звено — это не мелкая недостача документа. Это причина остановить переход до проверки.
\nКопия отвечает только на вопрос «создан ли второй набор». Она не отвечает, совпадают ли ключи, как обрабатываются новые записи и куда вернётся запись при отказе. Поэтому карточка перехода должна хранить источник, назначение, способ сверки и состояние восстановления.
\nДля учебного сценария достаточно такой модели:
\nconst migration = {\n route: 'orders-read-v1',\n owner: 'orders-team',\n source: 'orders.status',\n target: 'orders.state',\n reconciliation: 'same-keys-and-normalized-values',\n writeRecovery: 'resume-source-writes',\n traffic: { control: 'orders-read-v1', candidate: 'orders-read-v2' },\n rollback: {\n trigger: 'contract-mismatch-in-observation-window',\n traffic: 'restore-control-route',\n data: 'resume-source-writes'\n }\n};\nЭтот объект не подключается к базе, балансировщику или системе метрик. Он только показывает минимальные поля, которые нужно назвать до реальной операции. В проекте вместо строк должны стоять реальные маршруты, команды сверки, владельцы и процедуры восстановления.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Новый читатель видит пустое поле | Копия создана, но запись не синхронизирована | Сравнить ключи и нормализованные значения на одной выборке | Остановить срез и вернуть чтение на control |
| Старый и новый отчёты расходятся | Разные правила преобразования | Сравнить результат одного заказа в обоих представлениях | Исправить преобразование до расширения среза |
| После rollback появляются новые расхождения | Возврат маршрута не вернул write state | Проверить, какой писатель принимал записи в окне | Возобновить источник или применить обратное преобразование |
| Нельзя выбрать момент остановки | Не назван trigger и владелец решения | Найти условие, окно наблюдения и ответственного | Не считать миграцию готовой |
| Кандидат работает лучше, но сравнение спорное | Нет control с тем же маршрутом | Сверить route boundary, запросы и окно | Создать сопоставимую контрольную сторону |
Число «10% трафика» само по себе ничего не доказывает. Нужна контрольная сторона с тем же типом запроса, сопоставимым окном и одинаковыми правилами подсчёта ошибок. В учебной модели orders-read-v1 — control, а orders-read-v2 — candidate. Это имена границ, а не рекомендация направлять ровно десять процентов реального трафика.
Сравнивайте не только HTTP-коды. Проверьте долю ошибок контракта, расхождение значений, задержку и долю повторных запросов. Порог зависит от сервиса и его SLO. Если порог не определён, результат «ошибок не заметили» нельзя использовать как разрешение расширить срез.
\nВозврат версии приложения возвращает код. Возврат маршрута возвращает поток запросов. Восстановление данных возвращает способ обработки записей. Эти действия могут иметь разные триггеры и разных владельцев. Фраза «откатим релиз» не описывает ни одного из них.
\nУкажите условие остановки до начала среза. Например: «в окне наблюдения появился mismatch контракта для нормализованного значения». Затем укажите, кто принимает решение, куда возвращается чтение и какой писатель принимает новые данные после возврата. Если запись уже прошла только через новую схему, одного переключения маршрута недостаточно.
\nОфициальная документация Kubernetes прямо ограничивает смысл rollback Deployment: при возврате ревизии восстанавливается Pod template. Это полезное различие. Возврат контейнера не отменяет SQL-изменения, сообщения в очереди или внешний API-контракт. Такие состояния нужно проектировать отдельно.
\nСледующая функция демонстрирует fail-closed проверку. Она возвращает причину остановки, если отсутствует контрольная сторона, обратимое состояние данных или условие rollback. Пример учебный: он не вызывает внешние системы и не подтверждает готовность реального перехода.
\nfunction checkMigration(card) {\n if (!card.route || !card.owner || !card.source || !card.target) {\n return { status: 'stop-missing-inventory' };\n }\n if (!card.reconciliation || !card.writeRecovery) {\n return { status: 'stop-irreversible-data-state' };\n }\n if (card.traffic?.control === card.traffic?.candidate) {\n return { status: 'stop-missing-control-boundary' };\n }\n if (!card.rollback?.trigger || !card.rollback?.traffic || !card.rollback?.data) {\n return { status: 'stop-unnamed-rollback' };\n }\n return { status: 'ready-for-environment-specific-review' };\n}\n\nconsole.log(checkMigration(migration));\n// { status: 'ready-for-environment-specific-review' }\nПоложительный результат означает только, что учебная структура заполнена. Он не означает, что данные совпали, срез безопасен или команда может выполнять cutover. В реальном проекте функция должна дополняться проверкой конкретной базы, схемы, метрик, прав и процедуры восстановления.
\nСхема не выбирает способ репликации и не задаёт универсальный процент трафика. Она не решает конфликты конкурентной записи, задержку репликации, изменение индексов или восстановление внешних потребителей. PostgreSQL предупреждает, что логическая репликация может остановиться на конфликте ограничений, а некоторые отсутствующие строки при обновлении или удалении пропускаются. Значит, одну сверку количества строк нельзя считать доказательством эквивалентности.
\nСхема также не заменяет rehearsal. Учебный объект проверяет полноту описания, но не проверяет реальную выборку. Для production нужны контрольные запросы, журнал изменений, лимит времени, доступ к процедуре восстановления и ответственный, который может остановить переход. Если хотя бы один из этих элементов не проверен, критерий готовности не выполнен.
\nПереход готов к отдельному решению владельца, когда для выбранного маршрута можно воспроизвести одну запись в старом и новом представлении, показать правило сверки, назвать control и candidate, а также выполнить отрицательный сценарий с точным trigger. Отдельно должно быть понятно, как новые записи вернутся к источнику. Если команда может только вернуть контейнер, но не объяснить судьбу данных, миграция не готова.
\n