{ "index": 54, "slug": "editorial-2026-07-practice-migration-playbook", "title": "Миграция без прыжка: как сохранить данные и управлять откатом", "excerpt": "Пошаговая схема миграции с инвентарём, совместимыми версиями, контрольным срезом трафика и отдельным планом возврата данных.", "contentHtml": "
После переключения на новую версию часть заказов читает новое поле, а часть продолжает писать старое. HTTP-ответы остаются успешными, поэтому сбой обнаруживается позже: фильтр не находит заказ, отчёт считает две версии одной записи разными, а возврат трафика не отменяет уже записанные значения. Команда видит зелёный deploy, но не может быстро ответить, что именно возвращать.
\nУ такой ошибки несколько состояний. Код можно вернуть на предыдущую ревизию, запросы можно отправить на прежний маршрут, но данные уже могли пройти через новый преобразователь. Если эти действия не разделены до начала работ, откат превращается в импровизацию: кто-то исправляет схему, кто-то повторяет операции, а журнал показывает несовместимые версии.
\nНиже — рабочая модель для изменения контракта заказа с status на state. Это не инструкция для конкретной базы или балансировщика. Её цель — заставить миграцию отвечать на четыре вопроса: какой участок меняется, как доказать совместимость, где остановить поток и как восстановить запись после отказа.
Безопасный переход состоит не из одного cutover, а из состояний, которые можно наблюдать и покинуть. Сначала старый контракт остаётся рабочим. Затем новая схема принимает оба представления. После этого писатель создаёт согласованные значения, а сверка проверяет уже существующие записи. Только потом новый читатель получает ограниченный поток.
\nПорядок имеет значение. Если удалить status одновременно с выпуском нового читателя, неизвестно, что именно сломалось: схема, сериализация, выборка или маршрутизация. Если сначала включить двойную запись, но не определить, какое значение является источником истины, команда накопит расхождения, которые позднее будет трудно отличить от корректных преобразований.
Начните с одного маршрута, таблицы или события, а не с формулировки «перенести систему». Для GET /orders/:id запишите владельца, читателя, писателя, источник данных, индекс, кэш, очередь и внешних потребителей. Для каждого звена добавьте версию контракта и способ проверить результат.
Полезный инвентарь отвечает на вопрос «кто ещё может записать старую форму?». Один забытый batch-job способен продолжать отправлять status после переключения чтения на state. Один отчёт с собственным SQL может видеть другую картину, даже если основной API выглядит исправным. Неизвестный писатель — самостоятельный стоп-сигнал, а не поле для предположения.
Зафиксируйте границу операции. Например, candidate обслуживает только чтение заказа через один API-маршрут, а фоновая выгрузка остаётся на control. Тогда результат среза относится к конкретному маршруту и набору запросов, а не ко всей платформе. Если границы различаются, их сравнивают отдельно.
\nДля изменения имени поля примените expand/contract-последовательность. На этапе expand добавьте state, не удаляя status, и разрешите чтение обеих форм. Затем выберите источник истины: например, новое значение вычисляется из старого, пока двойная запись не станет проверяемой. При каждой записи сохраняйте правило преобразования, а не только итоговое значение.
На этапе двойной записи обработчик должен быть идемпотентным: повтор одной операции не создаёт новую сущность и не меняет результат непредсказуемо. Это требование зависит от ключа и бизнес-операции; универсальная функция «перезаписать всё» его не обеспечивает. Отдельно проверьте null, неизвестное значение, смену регистра, часовой пояс и округление, если они участвуют в преобразовании.
\nПосле сверки новый читатель может стать primary, но старый писатель ещё должен оставаться совместимым на время окна наблюдения. Contract закрывается последним: удаление поля допустимо только после поиска читателей, писателей, миграционных скриптов и восстановительных процедур. Откат приложения в этот момент уже не вернёт удалённую колонку.
\nСравнить количество строк недостаточно. Две таблицы могут иметь одинаковый размер, но разные ключи, пропущенные значения или разные нормализованные статусы. Для выборки задайте стабильный ключ, момент среза и правило сравнения. Результат должен содержать количество проверенных записей, число расхождений, тип расхождения и ссылку на повторяемый запрос.
\nУчебный SQL ниже показывает форму проверки, а не готовую команду для вашей схемы. Он отдельно считает отсутствующий ключ и сравнивает значения null-safe. На реальном стенде добавьте фильтр по согласованному срезу, лимит нагрузки, обработку удаления и защиту от чтения незавершённой записи.
\nWITH 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 скрывает потерю информации, нулевой результат всё равно не доказывает эквивалентность. Для критичных полей полезны выборочная проверка исходных значений и обратное преобразование.
Процент трафика сам по себе не является доказательством безопасности. Нужны две стороны: control на старом чтении и candidate на новом чтении, одна граница маршрута, сопоставимые ключи и одно окно наблюдения. Не смешивайте в одном числе ошибки API, расхождения данных, задержку и повторные запросы: у каждого сигнала должен быть владелец и порог.
\nДо включения candidate запишите базовую линию control. В течение окна фиксируйте объём запросов, долю ошибок, p95 или другой согласованный показатель задержки, mismatches и обращения к fallback. Порог не следует выдумывать в статье: его определяет SLO и риск конкретной операции. Важно, чтобы команда заранее знала, какое событие останавливает расширение.
\n| Симптом | Вероятная граница | Проверка | Решение |
|---|---|---|---|
| Новый читатель получает пустое поле | Схема или двойная запись | Сопоставить ключи и момент первой записи в обеих формах | Остановить candidate; восстановить запись и повторить сверку |
| Старый и новый отчёт расходятся | Преобразование или собственный потребитель | Сравнить одну запись по исходному ключу и правилам нормализации | Исправить контракт отчёта до расширения потока |
| Ошибок API нет, но растут mismatches | Семантика данных | Разложить расхождения на null, ключ, значение и время записи | Не считать HTTP 2xx разрешением на cutover |
| После возврата маршрута появляются новые расхождения | Write state | Проверить, какой писатель принимал данные в окне | Вернуть совместимый писатель или применить проверенное преобразование |
| Нельзя определить момент остановки | Решение и наблюдаемость | Найти порог, окно, owner и команду остановки | Оставить control и не расширять candidate |
Возврат кода меняет исполняемую версию. Возврат маршрута меняет, куда идут запросы. Восстановление данных меняет, какой писатель и какой формат принимают новые записи. Эти действия могут выполняться в разном порядке. Поэтому runbook должен содержать три отдельные команды, три проверки результата и одного ответственного за решение.
\nДокументация Kubernetes уточняет границу rollback Deployment: ревизия создаётся при изменении Pod template, а возврат к предыдущей ревизии откатывает именно эту часть Deployment. Это возвращает образ, параметры и другие элементы шаблона Pod, но не SQL-транзакции, сообщения очереди, записи во внешнем сервисе или уже опубликованный контракт. Их состояние описывается отдельными шагами.
\nЕсли data state необратим, откат должен быть не «вернуть старое», а заранее проверенный способ продолжить работу: dual-read, обратное преобразование, остановка записи или восстановление из согласованной копии. Выбор зависит от потерь и бизнес-операции. До среза выполните его на тестовом наборе и зафиксируйте, как обнаруживаются частичные результаты.
\nСоберите перед запуском одну карточку. В ней должны быть route boundary, owner, source, target, версия преобразования, запрос сверки, контрольные метрики, окно, trigger остановки и действия для кода, маршрута и данных. Пример ниже намеренно возвращает stop, если обязательное поле отсутствует. Положительный ответ говорит только о полноте карточки, а не о готовности production-среды.
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Эта схема подходит для постепенного изменения совместимого контракта, но не является универсальным планом восстановления. Она не решает конкурентные записи, задержку репликации, несовместимую семантику удаления, смену ключа, перестроение индексов, миграцию файлов или восстановление внешнего сервиса. Для каждой такой границы нужен отдельный план данных и тест отказа.
\nPostgreSQL описывает конкретные ограничения логической репликации: конфликт ограничения может остановить репликацию до ручного разрешения, а отсутствующая строка при UPDATE или DELETE может быть пропущена. Поэтому одинаковое число строк не доказывает, что два состояния равны. Проверьте версию PostgreSQL, replica identity, права, фильтры публикации и статистику конфликтов на целевой конфигурации.
Учебный SQL и JavaScript не подключаются к базе, не управляют трафиком и не подтверждают безопасность реального cutover. Они показывают форму проверки. Перед production-запуском нужны rehearsal на близком объёме данных, резервный план, доступ к остановке потока, журнал результата и человек, который имеет право отменить расширение.
\nПереход можно выносить на решение владельца, когда для одной границы воспроизводятся исходная запись, новая запись и правило их сравнения; control и candidate измеряются в сопоставимом окне; а отказ приводит к заранее названным действиям для кода, маршрута и данных. Отдельно должны быть проверены неизвестный писатель, частичная запись и конфликт при восстановлении.
\nЕсли команда способна вернуть только контейнер, но не объясняет судьбу записей, это не rollback миграции. Если есть нулевая сверка, но неизвестно, кто писал в окно, это не доказательство совместимости. Готовность — это не зелёный deploy, а повторяемая проверка с понятным стоп-триггером и обратимым состоянием.
\nUPDATE и DELETE. Это ограничение нужно учитывать при выборе ключа сверки.