{ "index": 134, "slug": "editorial-2024-04-mechanism-data-migrations", "title": "Миграция данных без разрыва контракта: expand, backfill и граница отката", "excerpt": "Как провести изменение формы данных при смешанных версиях приложения: сохранить совместимость старых reader и writer, ограничить backfill и не принять удаление старой формы за rollback.", "contentHtml": "
После релиза новая версия сервиса получает записи без нового поля, а старый worker продолжает записывать прежнюю форму. В логах растут ошибки валидации. Другой симптом тише: backfill заполняет данные, но вместе с ним увеличивается задержка обычных запросов. Остановить задачу можно, однако без журнала непонятно, какие записи уже изменились.
Цена ошибки — не только несколько ответов 500. Две версии могут по-разному прочитать одну запись, повторная попытка — создать расхождение, а откат бинарника не вернёт удалённое поле или старое значение. Главный вопрос миграции звучит так: какую форму данных каждая активная версия умеет читать и писать на каждом этапе?
Схема базы — общий протокол между версиями приложения. Поэтому до DDL нужно описать четыре роли: старый reader, старый writer, новый reader и новый writer. Пока версии работают одновременно, новая схема должна принимать допустимую старую запись, а новый reader — понимать старую форму, если заполнение ещё не закончено.
Для примера возьмём заказ. Старая форма хранит целое число копеек в amount. Новая форма хранит объект money с копейками и кодом валюты. Это не универсальная модель денег: она лишь делает явным, что старое поле можно преобразовать в рубли только при заранее известном договоре. Если валюта старых записей неизвестна, подставлять RUB нельзя.
Expand добавляет новую поверхность: колонку, таблицу или индекс. Старый код после этого всё ещё должен работать. Backfill переносит уже существующие записи ограниченными порциями. Switch меняет основной путь чтения, а затем, при необходимости, записи. Contract удаляет старый путь и старые данные. У каждого этапа свой сигнал готовности и свой способ остановки.
Эта последовательность не обещает нулевой риск. Она уменьшает размер изменения, оставляет совместимый путь и позволяет остановиться до необратимого удаления. Stripe описывает похожую схему для большой миграции: двойная запись, проверка чтения, перевод writers и удаление прежней модели. Это полезный разбор конкретной системы, а не готовая инструкция для любого хранилища.
Переходный reader должен сначала проверять новую форму, затем старую. Если обе формы присутствуют, он обязан заметить конфликт, а не молча выбрать одну. В примере ниже сумма задана в минимальных единицах, поэтому сравнение числовых значений не зависит от форматирования.
function readMoney(order) {\n const hasNew = order.money !== null &&\n typeof order.money === 'object' &&\n Number.isSafeInteger(order.money.value) &&\n typeof order.money.currency === 'string';\n const hasOld = Number.isSafeInteger(order.amount);\n\n if (hasNew && hasOld && order.money.value !== order.amount) {\n return { ok: false, reason: 'representations-conflict' };\n }\n if (hasNew) return { ok: true, value: order.money.value, currency: order.money.currency };\n if (hasOld) return { ok: true, value: order.amount, currency: 'RUB', source: 'legacy' };\n return { ok: false, reason: 'money-is-not-recoverable' };\n}\n\nfunction writeMoney(order, money) {\n if (!Number.isSafeInteger(money.value) || typeof money.currency !== 'string') {\n throw new Error('invalid-money');\n }\n return {\n ...order,\n amount: money.value,\n money: { value: money.value, currency: money.currency },\n };\n}Функция проверяет форму и конфликт, но не доказывает атомарность сохранения. В реальном сервисе список валют должен приходить из контракта или справочника, а обе записи должны попадать в одну транзакцию либо иметь документированный механизм восстановления. Если writer-ы работают через очередь или разные хранилища, одной такой функции недостаточно.
Ниже — небольшой PostgreSQL-способ проверить идею bounded backfill. Он предполагает, что amount — копейки в рублях, money — jsonb, а id монотонно упорядочивает порции. Каждая порция выполняется отдельной транзакцией и повторно выбирает только строки с пустым новым полем.
BEGIN;\n\nWITH batch AS (\n SELECT id, amount\n FROM orders\n WHERE money IS NULL AND amount IS NOT NULL\n ORDER BY id\n LIMIT 100\n FOR UPDATE SKIP LOCKED\n)\nUPDATE orders AS o\nSET money = jsonb_build_object('value', batch.amount, 'currency', 'RUB')\nFROM batch\nWHERE o.id = batch.id\nRETURNING o.id, o.money;\n\nCOMMIT;\n\nSELECT\n count(*) FILTER (WHERE money IS NULL AND amount IS NOT NULL) AS pending,\n count(*) FILTER (WHERE money IS NOT NULL) AS filled\nFROM orders;LIMIT 100 — не рекомендация для production, а воспроизводимая граница примера. Её подбирают по времени транзакции, lock wait, нагрузке на I/O и p95 обычных запросов. SKIP LOCKED позволяет не ждать уже захваченные строки, но может временно пропускать их; поэтому повторный запуск и итоговая проверка обязательны. Если в рабочем writer-е нет той же блокировки или транзакционной границы, backfill всё равно может пересечься с изменением записи.
| Симптом | Гипотеза | Проверка | Действие |
|---|---|---|---|
| Новый reader видит старую форму | Backfill не завершён или старый writer ещё активен | Сопоставить версии writers, долю старых записей и scope задачи | Оставить fallback и не начинать contract |
| Старый writer получает отказ после expand | Новое поле сделали обязательным слишком рано | Проверить DDL, default, constraint и фактический запрос | Вернуть совместимое правило или остановить rollout |
| Во время backfill вырос p95 | Задача конкурирует за CPU, I/O, locks или соединения | Сравнить окно задачи с latency, saturation и lock wait | Остановить по заранее названному порогу и сохранить partial state |
| Старое и новое значения расходятся | Пропущен write path или повтор неидемпотентен | Сравнить все пути записи и ключ повторной операции | Заморозить switch и определить источник истины |
| Новая версия зелёная, старый consumer жив | Проверили deployment, но не worker, cron или очередь | Собрать inventory активных consumers и их версий | Продлить совместимый период |
| Откат кода прошёл, данные не читаются | Rollback приложения приняли за восстановление данных | Проверить уже удалённые поля и recovery boundary | Запустить отдельный data repair или restore-процедуру |
Таблица задаёт порядок расследования, но не устанавливает причину автоматически. Сигнал должен быть связан с конкретным действием. Иначе команда одновременно меняет размер порции, версию приложения и настройки базы и теряет возможность понять результат.
Для PostgreSQL безопаснее начать с nullable-колонки без немедленного обязательного значения, когда это соответствует доменной модели:
ALTER TABLE orders ADD COLUMN money jsonb;Операция всё равно требует проверки блокировок и версии СУБД. Документация PostgreSQL отдельно описывает стоимость добавления колонки с постоянным default и случай с volatile default, при котором таблица может быть переписана. Нельзя переносить вывод «быстро и без блокировки» с одной версии, типа default или СУБД на другую. Constraint, который требует заполнения каждой старой строки, добавляют после проверки данных или отдельным совместимым шагом.
До expand полезно выполнить проверку формы и объёма:
SELECT\n count(*) AS total,\n count(*) FILTER (WHERE amount IS NULL) AS missing_amount,\n count(DISTINCT currency) AS currencies\nFROM legacy_order_amounts;Последний запрос применим только если старую валюту действительно хранили в отдельном поле. Если такой колонки нет, это не повод считать все суммы рублями: нужно найти источник валюты или отправить записи в ручной разбор.
У backfill должны быть scope, владелец, размер порции, критерий повторного запуска, журнал обработанных строк и стоп-сигнал. Минимальный журнал фиксирует время, диапазон или набор идентификаторов, количество успешно обработанных и количество пропущенных записей. Без этого остановка превращается в догадку.
Повторяемость не равна идемпотентности. Условие money IS NULL делает показанный пример повторяемым для незаполненных строк, но не решает конфликт, когда старое значение изменилось после первой записи. Для таких строк нужен version check, блокировка, журнал событий или ручная процедура — выбор зависит от writer-а и модели консистентности.
Стоп-сигналом может быть согласованный рост p95, ошибки, lock wait, saturation или расхождение контрольной выборки. Порог получает команда для конкретной среды; в статье нет измерения, из которого его можно вывести. Остановка должна сохранить partial state и ответ на три вопроса: что обработано, что осталось и какой запуск безопасен следующим.
Перед switch проверяют mixed-version сценарий: новый reader читает старую запись, старый reader читает запись после dual write, а конфликт форм обнаруживается. Полезно временно сравнивать результаты старого и нового reader без изменения пользовательского ответа. Такой контроль должен быть безопасен по latency и объёму, а расхождение — попадать в метрику или журнал.
После switch новый reader становится основным, но fallback может оставаться на период наблюдения. Тот факт, что известная выборка заполнена, не доказывает отсутствие старого writer-а в очереди или внешнем consumer-е. Сначала фиксируют inventory и отсутствие divergence, затем принимают отдельное решение о contract.
Contract удаляет колонку, таблицу, fallback, dual write или старый формат. В PostgreSQL DROP COLUMN удаляет данные этой колонки и связанные с ней ограничения. После такого шага возврат старого бинарника не восстановит удалённые значения. Recovery boundary нужно определить до удаления: backup, snapshot, журнал событий или подтверждённый data repair.
Модель подходит для совместимых изменений формы данных, когда старый и новый контракт могут сосуществовать. Она не выбирает isolation level, batch size, lock timeout, стратегию репликации или способ восстановления. Эти решения зависят от СУБД, объёма, нагрузки, SLA и стоимости ошибки.
Dual write может быть неатомарным, если формы лежат за разными транзакционными границами. ORM, cache, trigger, CDC-поток и очередь добавляют пути, которых нет в основном сервисе. PII, retention, шифрование и права доступа требуют отдельной проверки. Если новый инвариант нельзя поддержать для old writer, нужен адаптер или период остановки, а не ускорение rollout.
Если backfill нельзя ограничить порцией и остановить по наблюдаемому сигналу, он не готов к запуску. Если не найден старый consumer, старую форму не удаляют. Если данные уже потеряны, действие называют recovery или data repair, а не rollback.
Следующий этап разрешён, когда владелец может проверить пять фактов: активные версии имеют описанные read/write-пары; expand не отвергает old writer; backfill повторяем и ограничен; расхождение обнаруживается; contract имеет отдельную границу восстановления. Для финального удаления дополнительно нужно подтверждение, что старое представление не требуется ни одному заявленному consumer-у.