{ "index": 133, "slug": "editorial-2024-04-field-data-migrations", "title": "Миграция данных без ловушки: совместимость, backfill и безопасный contract", "excerpt": "Новая форма данных не становится безопасной от одного успешного deploy. Разбираем expand/migrate/contract, ограниченный backfill и признаки, по которым нужно остановить удаление старого представления.", "contentHtml": "
Симптом обычно появляется после deploy: новая версия сервиса читает новое поле, а часть записей всё ещё хранит старую форму. Затем backfill начинает нагружать базу, а старый worker продолжает писать только старое представление. В логах растёт доля fallback-чтений, обработка очереди замедляется, а команда уже обсуждает удаление старой колонки. Цена ошибки — не только откат релиза. Код можно вернуть, но уже записанные данные не обязаны вернуться в прежнюю форму. Пользователь увидит пустое значение, а восстановление потребует отдельного data repair.
\nТезис простой: миграция данных — это не одна команда DDL и не один зелёный deploy. Сначала нужно сохранить совместимость версий, затем ограниченно перенести данные, после этого доказать готовность нового чтения и только в конце удалить старую форму. Каждый переход должен иметь собственную проверку. Если хотя бы один потребитель неизвестен, старое представление остаётся.
\nВ старой системе заказ хранится в полях status и amount. Новая версия хочет хранить объект summary. На первом шаге схема получает новую форму, но старый writer не должен ломаться. Новый reader принимает обе формы. Новый writer временно записывает обе. Это expand.
На втором шаге backfill обрабатывает старые записи. Он не должен проходить по таблице без границы. Нужны область работы, размер порции, владелец, идемпотентность и заранее определённый сигнал остановки. Это migrate. Важен не сам факт запуска job, а понятный результат частичного выполнения: какие записи обработаны и что произойдёт после остановки.
\nЗатем система переключает чтение на новую форму. Fallback к старой форме ещё нужен, пока не проверены старые записи, отложенные worker-ы и все читатели. Успешное чтение новой записи не доказывает, что старых потребителей больше нет. Switch опирается на наблюдаемые данные, а не на дату релиза.
\nContract — отдельное решение. Старую форму можно удалить только после подтверждения, что старый reader и writer больше не участвуют, backfill завершён с понятным критерием, а восстановление не зависит от удаляемых данных. Если условие не доказано, contract откладывают. Это отрицательный путь, а не неполная миграция.
\nНиже — ограниченный пример на JavaScript. Он проверяет только заявленные версии и формы. Функция не обращается к базе, не запускает SQL и не измеряет нагрузку. Поэтому результат stop означает «не переходить к следующему этапу в этом сценарии», а не verdict для production.
const contract = {\n oldReader: true,\n oldWriter: true,\n newReaderAcceptsOld: true,\n newReaderAcceptsNew: true,\n newWriterWritesBoth: true,\n backfillHasStop: false,\n oldConsumersFound: true,\n};\n\nfunction decideMigration(state) {\n const compatible =\n state.oldReader &&\n state.oldWriter &&\n state.newReaderAcceptsOld &&\n state.newReaderAcceptsNew &&\n state.newWriterWritesBoth;\n\n if (!compatible) {\n return { phase: 'expand', action: 'stop', reason: 'version mismatch' };\n }\n\n if (!state.backfillHasStop) {\n return { phase: 'migrate', action: 'stop', reason: 'unbounded backfill' };\n }\n\n if (state.oldConsumersFound) {\n return { phase: 'contract', action: 'stop', reason: 'old consumer remains' };\n }\n\n return { phase: 'contract', action: 'review', reason: 'evidence required' };\n}\n\nconsole.log(decideMigration(contract));\n// { phase: 'migrate', action: 'stop', reason: 'unbounded backfill' }\nКод защищает порядок рассуждения. Он сначала проверяет совместимость, потом наличие stop condition, затем старых потребителей. В production эти признаки получают из реестра версий, логов, метрик, запросов к данным и согласованного runbook. Нельзя заменить их булевыми значениями из фикстуры. Учебный результат ограничен демонстрацией ветвления.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Новая форма есть только у части записей | Backfill ещё не закончен или новые записи обходят dual write | Сравнить доли old/new по времени записи и источнику | Оставить fallback, остановить contract, исправить writer |
| Backfill замедляет рабочие запросы | Широкий scope, слишком большая порция или конкурирующая нагрузка | Проверить latency, lock wait, размер batch и границу выборки | Остановить job, сузить scope и определить лимит до нового запуска |
| Старая версия получает ошибку записи | Schema constraint введён раньше совместимого writer | Воспроизвести запись v1 на тестовой копии и проверить порядок deploy | Вернуть совместимое расширение, не маскировать ошибку retry |
| После deploy растёт fallback | Reader видит old data или новый writer не заполнил поле | Разделить fallback по версии, endpoint и типу записи | Сохранить старую ветку и найти источник несовместимых записей |
| Все тесты зелёные, но consumer неизвестен | Тест проверяет сценарий, а не весь fleet | Сверить владельцев, worker-ы, cron, batch и старые clients | Не удалять старую форму до найденного доказательства |
| Нужен срочный rollback после очистки | Удаление данных ошибочно назвали обратимым | Проверить backup, retention и возможность read-back старой формы | Перейти к data repair или restore-плану, не обещать обычный rollback |
Иллюстрация полезна именно как граница ответственности. Совместимость версий проверяет контракт приложения. Backfill проверяет состояние данных и нагрузку. Contract проверяет отсутствие зависимости от старой формы. Ни один этап не доказывает остальные.
\nExpand/contract не делает миграцию беспростойной. Dual write может дать расхождение, если запись в одну систему прошла, а в другую нет. Backfill может конкурировать с индексами, блокировками и репликацией. Внешний клиент может использовать старое поле без регистрации. ORM может добавить собственный cache или изменить порядок чтения. Эти случаи требуют проверки конкретной системы.
\nНе переносите синтаксис PostgreSQL на другую СУБД. Даже в PostgreSQL команда, которая добавила constraint, не равна доказательству, что все старые строки уже проверены. Не считайте зелёный тест доказательством надёжности. Не увеличивайте batch, если неизвестна причина нагрузки. Не запускайте contract после одного удачного прогона. Если обнаружили несовместимость, правильное действие — остановить переход и сохранить старую форму.
\nМиграция готова к contract только тогда, когда одновременно выполнены пять условий: все допустимые версии читают нужную форму; writer-ы не создают неподдерживаемые записи; backfill имеет завершённый scope и повторяемый результат; использование old representation и fallback равно нулю в согласованном окне наблюдения; recovery-план проверен для оставшейся границы риска. Число и длительность окна должны определить владельцы системы по своим SLO и traffic profile. В этой статье они не выдумываются.
\nЕсли хотя бы одно условие нельзя подтвердить, критерий не выполнен. Это не повод скрыть расхождение за словом «почти». Оставьте старую форму, остановите удаление и соберите недостающее evidence. Такой отказ дешевле восстановления данных после необратимого contract.
\nNOT VALID и VALIDATE CONSTRAINT. Детали относятся к PostgreSQL и требуют проверки версии и конфигурации.