{ "index": 133, "slug": "editorial-2024-04-field-data-migrations", "title": "Миграция данных без ловушки: совместимость, backfill и безопасный contract", "excerpt": "Новая колонка не становится безопасной от одного deploy. Разбираем полевой кейс: старый writer ещё жив, backfill не имеет границы остановки, а удаление прежней формы нельзя объявлять rollback-операцией.", "contentHtml": "
Симптом появляется сразу после deploy: новая версия сервиса читает поле summary, а старый worker продолжает писать только status и amount. Часть строк уже имеет новую форму, часть — нет. Затем backfill начинает конкурировать с рабочими запросами, и кто-то предлагает удалить старую колонку, чтобы «закрыть миграцию». Это не один дефект схемы, а смешение трёх состояний: код ещё совместим не со всеми данными, перенос не имеет управляемой границы, а очистку принимают за обратимую операцию.
Разберём типичный полевой кейс, а не будем выдавать его за журнал конкретного инцидента. Главный вопрос такой: какие доказательства нужны до переключения чтения и до удаления старой формы? Ответ практический: сначала сохранить совместимость версий, затем перенести ограниченную область данных, потом проверить новый путь и только после этого принимать отдельное решение о contract. Если неизвестен хотя бы один старый потребитель, правильное действие — остановиться.
\nПредставим таблицу orders. До изменения приложение хранит короткий итог заказа в двух колонках:
status = 'paid'\namount = 125000\nНовая версия хочет единый объект summary, например { status: 'paid', amountCents: 125000 }. Схему расширили, но старый сервис не знает о новом поле. Если новая колонка сразу станет обязательной, старый writer начнёт получать отказ. Если сделать её nullable и сразу переключить reader, старые записи могут превратиться в пустой экран. Если запустить backfill без границы, проблема совместимости сменится проблемой нагрузки.
Первое действие — составить список участников. В него входят HTTP-сервисы, workers, cron-задачи, импортёры, отчёты и внешние клиенты. Для каждого нужно записать версию, что он читает и что пишет. «Мы нашли все вызовы» — гипотеза, пока её не сверили с кодом, расписаниями, очередями и логами. Один забытый worker делает ранний contract опасным даже при зелёных тестах основного сервиса.
\nExpand — это изменение, после которого допустимые старые и новые версии ещё могут работать одновременно. Новая колонка или таблица появляются без требования, которое отвергнет старый writer. Новый reader понимает обе формы. Новый writer на переходный период записывает обе формы либо пишет новую форму так, что старый reader остаётся работоспособным.
\nСлово «nullable» описывает только одно свойство схемы. Оно не отвечает, что означает отсутствие значения: запись ещё не перенесена, поле к этому заказу неприменимо или преобразование потеряло данные. Эти три случая нельзя смешивать одним NULL. Если смысл отсутствия не определён, reader будет вынужден угадывать, а fallback превратится в скрытый источник расхождений.
Для PostgreSQL есть отдельная полезная граница. В документации PostgreSQL 16 указано, что constraint можно добавить как NOT VALID: существующие строки не сканируются сразу, но новые вставки и обновления уже проверяются. Позже VALIDATE CONSTRAINT сканирует таблицу и подтверждает условие; команда получает SHARE UPDATE EXCLUSIVE lock. Это пример различия между «правило добавлено» и «все старые строки ему соответствуют». Он относится к PostgreSQL 16 и не переносит поведение на MySQL, другую версию, ORM или managed service.
Матрица помогает обнаружить несовместимую пару до запуска данных. В ней нет попытки описать весь deployment: это минимальный контракт конкретного поля.
\n| Участник | Читает old | Читает new | Пишет old | Пишет new | Что проверить |
|---|---|---|---|---|---|
| v1 reader | да | нет | — | — | new не должен стать единственным источником до удаления v1 |
| v1 writer | — | — | да | нет | expand не должен отвергать запись без summary |
| v2 reader | да | да | — | — | fallback считать и разделять по версии |
| v2 writer | — | — | да | да | расхождение двух записей должно иметь понятный recovery |
| contract | нет | да | нет | да | old consumer и old representation больше не нужны |
Таблица показывает важное ограничение: успешный v2 reader не доказывает отсутствие v1 writer. Аналогично, заполненные новые строки не доказывают, что отложенная очередь или внешний импортёр перестали писать старую форму. Поэтому переходы reader, writer и schema cleanup не стоит объединять в один release.
\nBackfill — это рабочий процесс, который читает старые строки и создаёт для них новую форму. У него должны быть scope, размер порции, порядок обхода, повторный запуск, владелец и stop condition. Scope отвечает на вопрос «какие строки мы переносим», stop condition — «когда прекращаем работу даже при неполном результате». Без второго параметра команда не умеет безопасно остановиться: при росте latency остаётся только спорить, продолжать ли job.
\nДля большой таблицы полезен ключ-граница, а не неопределённое «обработать всё». Например, один запуск может работать с диапазоном идентификаторов до заранее записанного upperBound. Следующий запуск берёт новый диапазон после проверки результата. Но сам диапазон не гарантирует безопасность: нужно измерить lock wait, время запроса, lag реплики, ошибки и долю расхождений в выбранной среде. Порог нельзя взять из этой статьи, потому что его задают размер таблицы, индексы, traffic profile и SLO конкретной системы.
GitLab в официальном руководстве разделяет schema migration и batched background migration: большие изменения данных выносятся в пакетную фоновую обработку, а схема меняется отдельно. Это полезное правило организации работ, но не готовая команда для чужого стека. Для PostgreSQL, Rails, очереди и версии GitLab нужны собственные ограничения и проверки.
\nНиже — самостоятельный Node.js-скрипт. Он не подключается к базе: в массиве зафиксированы три записи, одна из которых уже имеет summary. Скрипт считает, что запись с новой формой можно пропустить, а старую — преобразовать. Если в отчёте остались ошибки преобразования или не задана граница диапазона, он возвращает stop. Это пример проверки структуры плана, не измерение производительности и не разрешение на production backfill.
const records = [\n { id: 101, status: 'paid', amount: 125000, summary: null },\n { id: 102, status: 'cancelled', amount: 9900, summary: { status: 'cancelled', amountCents: 9900 } },\n { id: 103, status: 'paid', amount: 0, summary: null },\n];\n\nconst plan = { upperBound: 103, batchSize: 2, stopOnError: true };\n\nfunction toSummary(record) {\n if (!Number.isInteger(record.amount) || record.amount < 0) {\n return { ok: false, reason: 'amount-is-not-a-non-negative-integer' };\n }\n\n return {\n ok: true,\n value: { status: record.status, amountCents: record.amount },\n };\n}\n\nfunction inspectBackfill(rows, migrationPlan) {\n if (!Number.isInteger(migrationPlan.upperBound) || migrationPlan.batchSize < 1) {\n return { action: 'stop', reason: 'bounded-plan-is-missing' };\n }\n\n const candidates = rows.filter((row) => row.id <= migrationPlan.upperBound);\n const results = candidates.map((row) => {\n if (row.summary !== null) return { id: row.id, action: 'skip' };\n const converted = toSummary(row);\n return converted.ok\n ? { id: row.id, action: 'backfill', summary: converted.value }\n : { id: row.id, action: 'error', reason: converted.reason };\n });\n\n const errors = results.filter((result) => result.action === 'error');\n if (errors.length && migrationPlan.stopOnError) {\n return { action: 'stop', reason: 'conversion-error', errors };\n }\n\n return { action: 'review', upperBound: migrationPlan.upperBound, results };\n}\n\nconsole.log(JSON.stringify(inspectBackfill(records, plan), null, 2));\n// action: review; запись id=103 попадёт в backfill\n// В production здесь нужны транзакция, retry policy и запись прогресса.\nЧтобы повторить пример, сохраните блок в файл migration-check.mjs и выполните node migration-check.mjs. Результат review означает только то, что три синтетические строки прошли эту проверку. Скрипт не знает о конкурентной записи, транзакционной границе, репликации, правах, шифровании и нагрузке. Эти вопросы нельзя «дописать» одним boolean-полем.
После backfill читатель можно переключать постепенно. Сначала v2 умеет прочитать old и new, затем для выбранной области сравниваются результаты двух представлений. Расхождение нужно считать отдельно от обычной ошибки запроса: иначе fallback будет выглядеть как успешный ответ. Полезные поля наблюдения — версия reader, endpoint, идентификатор операции, причина fallback и направление записи. Чувствительные значения в лог не попадают.
\nНельзя заменить evidence календарём. Для переключения нужны хотя бы завершённый scope выбранной области, понятная доля fallback, отсутствие новых ошибок преобразования и подтверждённый список старых writers. Окно наблюдения и численные пороги определяет владелец сервиса по своему SLO. В этом тексте нет выдуманного «24 часа без ошибок»: для одной системы это может быть мало, для другой — не иметь смысла из-за недельной периодичности batch.
\nGoogle SRE Book формулирует границу шире: тесты проверяют конкретные области эквивалентности и уменьшают неопределённость после изменения, но зелёный тест не доказывает надёжность всей системы. Поэтому rehearsal и тесты сравнения — аргументы для switch, а не автоматический приказ удалить старую форму.
\nContract начинается тогда, когда старое представление больше не нужно ни одному допустимому читателю и writer-у. До этого момента его можно сделать невидимым для нового пути, но не следует физически удалять. Сначала прекращают старые записи, затем подтверждают отсутствие old consumer, потом удаляют код fallback и только после этого рассматривают удаление колонки или таблицы. На каждом шаге нужен read-back состояния.
\nRollback к прежнему binary возвращает код, а не обязательно данные. Если contract уже удалил колонку, очистил старую таблицу или преобразовал значение с потерей информации, восстановление потребует data repair или restore из резервной копии. Поэтому в runbook полезно разделить три границы: что откатывает deploy, что восстанавливает application path и что требует восстановления данных. Слово «rollback» без этого разделения создаёт ложное чувство безопасности.
\nExpand/migrate/switch/contract не решает автоматически dual write между двумя базами. Если запись в одну систему подтверждена, а во вторую нет, потребуется reconciliation или другая архитектура согласованности. ORM может кэшировать старую форму. Реплика может отставать. Очередь может повторить сообщение. Внешний клиент может использовать старое поле без регистрации. Для каждого случая нужно отдельно определить идемпотентность, порядок событий и источник истины.
\nПример SQL из документации PostgreSQL нельзя переносить на другую СУБД. Даже в PostgreSQL NOT VALID не означает, что старые строки уже соответствуют constraint; это состояние до отдельной валидации. Материал Stripe — описание миграции Stripe с их хранилищем и инструментами, а не обещание нулевого простоя в вашем проекте. Источник Google объясняет роль тестов, но не вычисляет допустимый batch или capacity базы.
Если наблюдение показывает рост lock wait, lag, ошибок преобразования или divergence, остановка — ожидаемый результат контроля. Сначала сохраняют старую форму, фиксируют частичный прогресс и выясняют причину. Увеличить параллелизм или удалить fallback без этой проверки значит расширить blast radius, а не ускорить завершение.
\nContract можно рассматривать только при одновременном выполнении пяти условий: все разрешённые версии читают совместимую форму; writer-ы не создают неподдерживаемые строки; backfill завершил явно заданный scope и даёт повторяемый результат; fallback и divergence наблюдаются в согласованном окне с результатом, который устраивает владельца; recovery boundary проверена для каждого необратимого шага.
\nЭто не универсальный чек-лист допуска. Владелец конкретной системы должен добавить версию СУБД, размер и форму данных, ограничения прав, репликацию, расписание редких consumers и критерии SLO. Но логика останется той же: если доказательство отсутствует, старое представление не удаляют. Возврат к совместимому коду дешевле, чем восстановление информации, которую уже физически стерли.
\nNOT VALID, последующей VALIDATE CONSTRAINT, проверок существующих строк и lock level. Синтаксис и поведение ограничены PostgreSQL 16.