{ "index": 54, "slug": "editorial-2026-07-practice-migration-playbook", "title": "Миграция без прыжка: как сохранить данные и управлять откатом", "excerpt": "Пошаговая схема миграции с инвентарём, совместимыми версиями, контрольным срезом трафика и отдельным планом возврата данных.", "contentHtml": "

После переключения на новую версию часть заказов читает новое поле, а часть продолжает писать старое. HTTP-ответы остаются успешными, поэтому сбой обнаруживается позже: фильтр не находит заказ, отчёт считает две версии одной записи разными, а возврат трафика не отменяет уже записанные значения. Команда видит зелёный deploy, но не может быстро ответить, что именно возвращать.

\n

У такой ошибки несколько состояний. Код можно вернуть на предыдущую ревизию, запросы можно отправить на прежний маршрут, но данные уже могли пройти через новый преобразователь. Если эти действия не разделены до начала работ, откат превращается в импровизацию: кто-то исправляет схему, кто-то повторяет операции, а журнал показывает несовместимые версии.

\n

Ниже — рабочая модель для изменения контракта заказа с status на state. Это не инструкция для конкретной базы или балансировщика. Её цель — заставить миграцию отвечать на четыре вопроса: какой участок меняется, как доказать совместимость, где остановить поток и как восстановить запись после отказа.

\n

Миграция — это последовательность состояний

\n

Безопасный переход состоит не из одного cutover, а из состояний, которые можно наблюдать и покинуть. Сначала старый контракт остаётся рабочим. Затем новая схема принимает оба представления. После этого писатель создаёт согласованные значения, а сверка проверяет уже существующие записи. Только потом новый читатель получает ограниченный поток.

\n

Порядок имеет значение. Если удалить status одновременно с выпуском нового читателя, неизвестно, что именно сломалось: схема, сериализация, выборка или маршрутизация. Если сначала включить двойную запись, но не определить, какое значение является источником истины, команда накопит расхождения, которые позднее будет трудно отличить от корректных преобразований.

\n
\"Четыре
Каждый переход имеет проверку и стоп-ветку. Пока не названы владелец, состояние данных, контрольная сторона и условие возврата, поток не расширяется.
\n

Инвентарь ограничивает область риска

\n

Начните с одного маршрута, таблицы или события, а не с формулировки «перенести систему». Для GET /orders/:id запишите владельца, читателя, писателя, источник данных, индекс, кэш, очередь и внешних потребителей. Для каждого звена добавьте версию контракта и способ проверить результат.

\n

Полезный инвентарь отвечает на вопрос «кто ещё может записать старую форму?». Один забытый batch-job способен продолжать отправлять status после переключения чтения на state. Один отчёт с собственным SQL может видеть другую картину, даже если основной API выглядит исправным. Неизвестный писатель — самостоятельный стоп-сигнал, а не поле для предположения.

\n

Зафиксируйте границу операции. Например, candidate обслуживает только чтение заказа через один API-маршрут, а фоновая выгрузка остаётся на control. Тогда результат среза относится к конкретному маршруту и набору запросов, а не ко всей платформе. Если границы различаются, их сравнивают отдельно.

\n

Совместимость начинается с формата записи

\n

Для изменения имени поля примените expand/contract-последовательность. На этапе expand добавьте state, не удаляя status, и разрешите чтение обеих форм. Затем выберите источник истины: например, новое значение вычисляется из старого, пока двойная запись не станет проверяемой. При каждой записи сохраняйте правило преобразования, а не только итоговое значение.

\n

На этапе двойной записи обработчик должен быть идемпотентным: повтор одной операции не создаёт новую сущность и не меняет результат непредсказуемо. Это требование зависит от ключа и бизнес-операции; универсальная функция «перезаписать всё» его не обеспечивает. Отдельно проверьте null, неизвестное значение, смену регистра, часовой пояс и округление, если они участвуют в преобразовании.

\n

После сверки новый читатель может стать primary, но старый писатель ещё должен оставаться совместимым на время окна наблюдения. Contract закрывается последним: удаление поля допустимо только после поиска читателей, писателей, миграционных скриптов и восстановительных процедур. Откат приложения в этот момент уже не вернёт удалённую колонку.

\n

Сверка должна ловить смысловые расхождения

\n

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

\n

Учебный SQL ниже показывает форму проверки, а не готовую команду для вашей схемы. Он отдельно считает отсутствующий ключ и сравнивает значения null-safe. На реальном стенде добавьте фильтр по согласованному срезу, лимит нагрузки, обработку удаления и защиту от чтения незавершённой записи.

\n
WITH compared AS (\n  SELECT\n    old.id AS old_id,\n    new.id AS new_id,\n    old.status AS old_value,\n    new.state AS new_value\n  FROM orders_old AS old\n  FULL OUTER JOIN orders_new AS new ON new.id = old.id\n)\nSELECT\n  count(*) AS checked,\n  count(*) FILTER (WHERE old_id IS NULL) AS missing_old,\n  count(*) FILTER (WHERE new_id IS NULL) AS missing_new,\n  count(*) FILTER (\n    WHERE old_id IS NOT NULL\n      AND new_id IS NOT NULL\n      AND old_value IS DISTINCT FROM new_value\n  ) AS mismatched\nFROM compared;
\n

Ожидаемый результат нужно определить до запуска: допустимое число расхождений, перечень разрешённых преобразований и действие при каждом типе. Если normalize скрывает потерю информации, нулевой результат всё равно не доказывает эквивалентность. Для критичных полей полезны выборочная проверка исходных значений и обратное преобразование.

\n

Контрольный срез сравнивает одинаковые условия

\n

Процент трафика сам по себе не является доказательством безопасности. Нужны две стороны: control на старом чтении и candidate на новом чтении, одна граница маршрута, сопоставимые ключи и одно окно наблюдения. Не смешивайте в одном числе ошибки API, расхождения данных, задержку и повторные запросы: у каждого сигнала должен быть владелец и порог.

\n

До включения candidate запишите базовую линию control. В течение окна фиксируйте объём запросов, долю ошибок, p95 или другой согласованный показатель задержки, mismatches и обращения к fallback. Порог не следует выдумывать в статье: его определяет SLO и риск конкретной операции. Важно, чтобы команда заранее знала, какое событие останавливает расширение.

\n
Симптомы и решения на границах миграции
СимптомВероятная границаПроверкаРешение
Новый читатель получает пустое полеСхема или двойная записьСопоставить ключи и момент первой записи в обеих формахОстановить candidate; восстановить запись и повторить сверку
Старый и новый отчёт расходятсяПреобразование или собственный потребительСравнить одну запись по исходному ключу и правилам нормализацииИсправить контракт отчёта до расширения потока
Ошибок API нет, но растут mismatchesСемантика данныхРазложить расхождения на null, ключ, значение и время записиНе считать HTTP 2xx разрешением на cutover
После возврата маршрута появляются новые расхожденияWrite stateПроверить, какой писатель принимал данные в окнеВернуть совместимый писатель или применить проверенное преобразование
Нельзя определить момент остановкиРешение и наблюдаемостьНайти порог, окно, owner и команду остановкиОставить control и не расширять candidate
\n

Rollback нужно разделить на три действия

\n

Возврат кода меняет исполняемую версию. Возврат маршрута меняет, куда идут запросы. Восстановление данных меняет, какой писатель и какой формат принимают новые записи. Эти действия могут выполняться в разном порядке. Поэтому runbook должен содержать три отдельные команды, три проверки результата и одного ответственного за решение.

\n

Документация Kubernetes уточняет границу rollback Deployment: ревизия создаётся при изменении Pod template, а возврат к предыдущей ревизии откатывает именно эту часть Deployment. Это возвращает образ, параметры и другие элементы шаблона Pod, но не SQL-транзакции, сообщения очереди, записи во внешнем сервисе или уже опубликованный контракт. Их состояние описывается отдельными шагами.

\n

Если data state необратим, откат должен быть не «вернуть старое», а заранее проверенный способ продолжить работу: dual-read, обратное преобразование, остановка записи или восстановление из согласованной копии. Выбор зависит от потерь и бизнес-операции. До среза выполните его на тестовом наборе и зафиксируйте, как обнаруживаются частичные результаты.

\n

Карточка перехода делает решение воспроизводимым

\n

Соберите перед запуском одну карточку. В ней должны быть route boundary, owner, source, target, версия преобразования, запрос сверки, контрольные метрики, окно, trigger остановки и действия для кода, маршрута и данных. Пример ниже намеренно возвращает stop, если обязательное поле отсутствует. Положительный ответ говорит только о полноте карточки, а не о готовности production-среды.

\n
function evaluateMigration(card) {\n  const required = [\n    'route', 'owner', 'source', 'target',\n    'reconciliation', 'writeRecovery', 'rollbackTrigger'\n  ];\n\n  const missing = required.filter((key) => !card[key]);\n  if (missing.length > 0) {\n    return { status: 'stop', reason: 'missing-fields', missing };\n  }\n\n  if (card.control === card.candidate) {\n    return { status: 'stop', reason: 'same-traffic-boundary' };\n  }\n\n  return { status: 'review', reason: 'card-is-complete' };\n}\n\nconsole.log(evaluateMigration({\n  route: 'orders-read',\n  owner: 'orders-team',\n  source: 'orders.status',\n  target: 'orders.state',\n  reconciliation: 'key-and-normalized-value',\n  writeRecovery: 'resume-compatible-writer',\n  rollbackTrigger: 'mismatch-over-threshold',\n  control: 'orders-read-v1',\n  candidate: 'orders-read-v2'\n}));\n// { status: 'review', reason: 'card-is-complete' }
\n

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

\n

Порядок действий перед расширением

\n
  1. Выбрать одну границу операции и записать владельца, читателей, писателей, хранилище, кэш, очередь и внешние зависимости.
  2. Добавить новую форму без удаления старой; проверить чтение старой, новой и смешанной записи.
  3. Зафиксировать источник истины, правило преобразования, идемпотентность и обработку неизвестных значений.
  4. Включить двойную запись на тестовой выборке и повторить операцию, чтобы проверить повторяемость результата.
  5. Сформировать контрольный запрос: ключ, срез, нормализация, допустимые расхождения и лимит нагрузки.
  6. Снять базовую линию control, затем включить небольшой candidate с известным владельцем и окном наблюдения.
  7. При каждом стоп-триггере остановить расширение, записать сигнал и выполнить отдельные действия для кода, маршрута и данных.
  8. Расширять поток только после сверки фактических данных и решения владельца сервиса; не удалять старый контракт в том же изменении.
  9. После окна наблюдения повторить поиск зависимостей, выключить старую запись и только затем удалить совместимость по плану.
\n

Ограничения применимости

\n

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

\n

PostgreSQL описывает конкретные ограничения логической репликации: конфликт ограничения может остановить репликацию до ручного разрешения, а отсутствующая строка при UPDATE или DELETE может быть пропущена. Поэтому одинаковое число строк не доказывает, что два состояния равны. Проверьте версию PostgreSQL, replica identity, права, фильтры публикации и статистику конфликтов на целевой конфигурации.

\n

Учебный SQL и JavaScript не подключаются к базе, не управляют трафиком и не подтверждают безопасность реального cutover. Они показывают форму проверки. Перед production-запуском нужны rehearsal на близком объёме данных, резервный план, доступ к остановке потока, журнал результата и человек, который имеет право отменить расширение.

\n

Критерий готовности

\n

Переход можно выносить на решение владельца, когда для одной границы воспроизводятся исходная запись, новая запись и правило их сравнения; control и candidate измеряются в сопоставимом окне; а отказ приводит к заранее названным действиям для кода, маршрута и данных. Отдельно должны быть проверены неизвестный писатель, частичная запись и конфликт при восстановлении.

\n

Если команда способна вернуть только контейнер, но не объясняет судьбу записей, это не rollback миграции. Если есть нулевая сверка, но неизвестно, кто писал в окно, это не доказательство совместимости. Готовность — это не зелёный deploy, а повторяемая проверка с понятным стоп-триггером и обратимым состоянием.

\n

Проверяемые источники

" }