{ "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 создают колонку, таблицу или индекс, который не требует от старого кода неизвестного значения. Новая поверхность может быть nullable, но nullable не означает безопасно. Нужно описать, что значит отсутствие, какие записи допустимы и когда значение станет обязательным. Если old writer не способен сохранить новый инвариант, его нельзя превратить в ошибку одним DDL-шагом.
Стоимость операции зависит от движка, версии и объекта. В документации PostgreSQL описаны разные последствия для добавления колонки, default и ограничений. Поэтому нельзя переносить обещание «без блокировки» с одной СУБД на другую. Перед запуском проверяют конкретную операцию на выбранной версии движка и учитывают её влияние на размер таблицы, lock и репликацию.
Backfill — отдельный поток изменения состояния. Для него нужны область записей, размер управляемой порции, правило повторного запуска, owner, журнал результата и условие остановки. «Запустить джобу до конца» не является планом. Конец может не наступить, а повторный запуск может дважды применить преобразование.
Стоп-сигнал должен быть наблюдаемым: превышение согласованной задержки, рост ошибок, lock wait, нарушение контрольной выборки или явная команда владельца. Значения порции и пороги нельзя брать из этого учебного текста. Их получают для конкретной среды. Учебный код не читает метрики и не даёт производственных результатов.
Остановка не означает провал. Она должна оставить состояние, которое можно описать: какие записи обработаны, какие пропущены, что делает следующий запуск и сохраняется ли старая форма. Если после stop никто не может ответить на эти вопросы, backfill не готов к запуску.
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 отдельно отмечает дополнительную стоимость записи и постепенное увеличение нагрузки. В собственном проекте эти параметры нужно измерять отдельно.
Эта модель не выбирает 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-данных.