{ "index": 134, "slug": "editorial-2024-04-mechanism-data-migrations", "title": "Миграция данных без разрыва контракта: expand, backfill и граница отката", "excerpt": "Как провести изменение формы данных при смешанных версиях приложения: сохранить совместимость старых reader и writer, ограничить backfill и не принять удаление старой формы за rollback.", "contentHtml": "

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

Цена ошибки — не только несколько 500. Смешанные версии могут по-разному прочитать одну запись. Повторная попытка может создать расхождение. Откат бинарника не вернёт удалённое поле или старое значение. Если команда не знает, какие записи уже изменились, она теряет безопасную границу восстановления.

Тезис: мигрируют не колонки, а контракт во времени

Схема базы — общий протокол между версиями приложения. Поэтому изменение нужно проверять для четырёх ролей: old reader, old writer, new reader и new writer. Пока две версии могут одновременно обслуживать запросы, новая схема обязана принимать допустимую старую форму. Новая версия должна уметь читать обе формы, если backfill ещё не закончен.

Надёжный маршрут разделяет четыре события: expand добавляет новую поверхность, migrate приводит старые записи к новой форме, switch переводит чтение и запись, contract удаляет старый путь. Эти этапы могут иметь разные владельцы, риски и критерии. Их нельзя прятать в одну миграцию, один релиз или одну фразу «схема уже готова».

Механизм совместимости

Представим запись заказа. Старая форма хранит сумму в поле amount, новая — объект money с суммой и валютой. В transition-периоде новая версия читает обе формы. Новый writer сохраняет обе формы, пока старый reader ещё возможен. Backfill заполняет money только для записей, где значение можно вывести без потери смысла.

function readAmount(order) {\n  if (order.money && Number.isFinite(order.money.value)) {\n    return { value: order.money.value, currency: order.money.currency };\n  }\n\n  if (Number.isFinite(order.amount)) {\n    return { value: order.amount, currency: 'RUB' };\n  }\n\n  return { ok: false, reason: 'amount-is-not-recoverable' };\n}\n\nfunction writeOrder(order, money) {\n  return {\n    ...order,\n    amount: money.value,\n    money: { value: money.value, currency: money.currency },\n  };\n}

Это учебный пример. Он не подключается к базе, не проверяет валюту по справочнику, не знает транзакционную границу и не доказывает, что dual write атомарен. Его задача уже: явно показать fallback и отрицательный путь. Если старое поле не позволяет однозначно восстановить валюту, функция должна остановиться, а не записать правдоподобное значение.

В production-протоколе нужно дополнительно определить семантику отсутствующего поля. null может означать «ещё не обработано», «значение неприменимо» или «данные потеряны». Эти состояния нельзя различать по догадке. Их фиксируют в контракте до начала backfill.

Симптом → причина → проверка → действие

Диагностика миграции по наблюдаемому сигналу
СимптомПричинаПроверкаДействие
Новый reader получает записи без нового поляBackfill не завершён или old writer ещё активенСопоставить версию writer, долю старой формы и область backfillОставить fallback, остановить contract и уточнить owner перехода
Старый writer получает отказ после expandСхема стала обязательной раньше rollout кодаПроверить запросы old writer и правила default/constraintВернуть совместимое правило или остановить выкладку
После запуска backfill растёт p95Фоновая работа конкурирует за CPU, I/O, lock или соединенияСравнить окно backfill с latency, saturation и ожиданием ресурсовОстановить работу по заранее заданному signal и сохранить partial state
Данные в старой и новой форме расходятсяDual write не покрывает путь обновления или повторяется неидемпотентноСравнить write paths, ключ операции и правило повторного запускаЗаморозить switch, определить источник истины и исправить расхождение
Новая версия зелёная, старый consumer ещё живГотовность оценили по одному deploymentПроверить workers, очереди, cron и внешних потребителейНе удалять старую форму; продлить совместимый период
Rollback кода прошёл, данные не читаютсяОткат приложения ошибочно приняли за recovery данныхПроверить форму записей, уже удалённые поля и recovery boundaryПерейти к data repair или restore-процедуре, если она предусмотрена

Таблица задаёт направление расследования, а не готовую причину. Один симптом может иметь несколько источников. Каждое действие должно ссылаться на конкретный signal и менять только одну переменную, иначе результат нельзя интерпретировать.

Expand: добавить новую форму, не сломав старую

На expand создают колонку, таблицу или индекс, который не требует от старого кода неизвестного значения. Новая поверхность может быть nullable, но nullable не означает безопасно. Нужно описать, что значит отсутствие, какие записи допустимы и когда значение станет обязательным. Если old writer не способен сохранить новый инвариант, его нельзя превратить в ошибку одним DDL-шагом.

Стоимость операции зависит от движка, версии и объекта. В документации PostgreSQL описаны разные последствия для добавления колонки, default и ограничений. Поэтому нельзя переносить обещание «без блокировки» с одной СУБД на другую. Перед запуском проверяют конкретную операцию на выбранной версии движка и учитывают её влияние на размер таблицы, lock и репликацию.

\"Матрица
Матрица показывает допустимый переход версий. Она не подтверждает, какие версии реально запущены, и не измеряет полноту данных.

Migrate: backfill с бюджетом и стоп-сигналом

Backfill — отдельный поток изменения состояния. Для него нужны область записей, размер управляемой порции, правило повторного запуска, owner, журнал результата и условие остановки. «Запустить джобу до конца» не является планом. Конец может не наступить, а повторный запуск может дважды применить преобразование.

Стоп-сигнал должен быть наблюдаемым: превышение согласованной задержки, рост ошибок, lock wait, нарушение контрольной выборки или явная команда владельца. Значения порции и пороги нельзя брать из этого учебного текста. Их получают для конкретной среды. Учебный код не читает метрики и не даёт производственных результатов.

Остановка не означает провал. Она должна оставить состояние, которое можно описать: какие записи обработаны, какие пропущены, что делает следующий запуск и сохраняется ли старая форма. Если после stop никто не может ответить на эти вопросы, backfill не готов к запуску.

Switch и contract: два разных решения

Switch переводит reader на новую форму. До него проверяют, что новый reader понимает старую запись, новая запись имеет ожидаемую семантику, а divergence можно обнаружить. После switch fallback ещё может оставаться. Факт, что известная выборка заполнена, не доказывает отсутствие старого writer-а в очереди, worker-е или внешнем consumer-е.

Contract удаляет старую колонку, таблицу, индекс, fallback или dual write. Это необратимее обычного rollback. Возврат старого бинарника может вернуть старую логику, но не удалит уже записанные новые значения и не восстановит очищенную историю. Поэтому contract выполняют отдельным решением после доказательства отсутствия declared old reader/writer и после фиксации recovery boundary.

В официальном описании онлайн-миграции Stripe переход разделён на dual write, переключение readers, переключение writers и удаление старых данных. Это полезный шаблон последовательности, но не готовая настройка для другой БД. Сам Stripe отдельно отмечает дополнительную стоимость записи и постепенное увеличение нагрузки. В собственном проекте эти параметры нужно измерять отдельно.

Порядок действий

  1. Опишите old и new shape. Назовите обязательные поля, значение отсутствия, правила преобразования и случаи, которые нельзя восстановить.
  2. Составьте матрицу old/new reader и writer. Включите сервисы, worker-ы, очереди, cron и внешних потребителей. Неизвестную роль пометьте как blocker.
  3. Выберите expand, который сохраняет работу declared old writer. Проверьте DDL, lock и поведение конкретной версии СУБД по официальной документации.
  4. Выпустите совместимый reader и writer. Убедитесь, что fallback виден в коде и наблюдении, а dual write имеет понятное правило повторения.
  5. Оформите backfill: scope, owner, bounded batch, idempotency, stop signal, partial state и действие после остановки.
  6. Проведите ограниченную проверку mixed-version сценария. Зафиксируйте только проверенный результат; не называйте учебный или изолированный прогон доказательством production-ready.
  7. Переключите reader по заранее названному evidence. Оставьте старую форму доступной на период наблюдения.
  8. Проверьте отсутствие старых consumers, расхождения данных и необработанной области. Только после этого принимайте отдельное решение о contract.

Ограничения и отрицательный путь

Эта модель не выбирает isolation level, batch size, lock timeout, формат журнала, стратегию репликации или восстановление из backup. Она не решает вопросы PII, retention, шифрования и прав доступа. Dual write может быть неатомарным, если две формы лежат за разными транзакционными границами. ORM, cache, trigger и очередь могут добавить пути, которых нет в основном сервисе.

Если новый инвариант нельзя поддержать для old writer, не пытайтесь ускорить rollout. Оставьте expand совместимым, добавьте адаптер или выберите отдельный период остановки. Если backfill нельзя bounded-ить, не запускайте его «на пробу». Если не найден old consumer, не удаляйте старую форму. Если уже произошла потеря данных, называйте действие recovery или data repair, а не rollback.

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

Миграция готова к следующему этапу, когда документированный owner может проверить пять фактов: каждая активная версия имеет описанные read/write-пары; old writer не отвергается; backfill имеет повторяемость, границу и stop signal; divergence обнаруживается; contract имеет отдельный recovery boundary. Для финального удаления дополнительно нужно подтверждение, что старое представление больше не требуется ни одному заявленному consumer-у.

Учебный пример выше можно проверить на четырёх входах: новая форма, старая форма, неполная старая форма и конфликтующие значения. Ожидаемый результат — корректное чтение первых двух и явный отказ последних двух. Эта проверка подтверждает логику функции, но не поведение базы, нагрузку, deployment или полноту production-данных.

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

" }