{ "index": 133, "slug": "editorial-2024-04-field-data-migrations", "title": "Миграция данных без ловушки: совместимость, backfill и безопасный contract", "excerpt": "Новая колонка не становится безопасной от одного deploy. Разбираем полевой кейс: старый writer ещё жив, backfill не имеет границы остановки, а удаление прежней формы нельзя объявлять rollback-операцией.", "contentHtml": "

Симптом появляется сразу после deploy: новая версия сервиса читает поле summary, а старый worker продолжает писать только status и amount. Часть строк уже имеет новую форму, часть — нет. Затем backfill начинает конкурировать с рабочими запросами, и кто-то предлагает удалить старую колонку, чтобы «закрыть миграцию». Это не один дефект схемы, а смешение трёх состояний: код ещё совместим не со всеми данными, перенос не имеет управляемой границы, а очистку принимают за обратимую операцию.

\n

Разберём типичный полевой кейс, а не будем выдавать его за журнал конкретного инцидента. Главный вопрос такой: какие доказательства нужны до переключения чтения и до удаления старой формы? Ответ практический: сначала сохранить совместимость версий, затем перенести ограниченную область данных, потом проверить новый путь и только после этого принимать отдельное решение о contract. Если неизвестен хотя бы один старый потребитель, правильное действие — остановиться.

\n

Кейс: схема уже изменилась, а writer — ещё нет

\n

Представим таблицу orders. До изменения приложение хранит короткий итог заказа в двух колонках:

\n
status = 'paid'\namount = 125000
\n

Новая версия хочет единый объект summary, например { status: 'paid', amountCents: 125000 }. Схему расширили, но старый сервис не знает о новом поле. Если новая колонка сразу станет обязательной, старый writer начнёт получать отказ. Если сделать её nullable и сразу переключить reader, старые записи могут превратиться в пустой экран. Если запустить backfill без границы, проблема совместимости сменится проблемой нагрузки.

\n

Первое действие — составить список участников. В него входят HTTP-сервисы, workers, cron-задачи, импортёры, отчёты и внешние клиенты. Для каждого нужно записать версию, что он читает и что пишет. «Мы нашли все вызовы» — гипотеза, пока её не сверили с кодом, расписаниями, очередями и логами. Один забытый worker делает ранний contract опасным даже при зелёных тестах основного сервиса.

\n

Expand: совместимость важнее красивой новой схемы

\n

Expand — это изменение, после которого допустимые старые и новые версии ещё могут работать одновременно. Новая колонка или таблица появляются без требования, которое отвергнет старый writer. Новый reader понимает обе формы. Новый writer на переходный период записывает обе формы либо пишет новую форму так, что старый reader остаётся работоспособным.

\n

Слово «nullable» описывает только одно свойство схемы. Оно не отвечает, что означает отсутствие значения: запись ещё не перенесена, поле к этому заказу неприменимо или преобразование потеряло данные. Эти три случая нельзя смешивать одним NULL. Если смысл отсутствия не определён, reader будет вынужден угадывать, а fallback превратится в скрытый источник расхождений.

\n

Для PostgreSQL есть отдельная полезная граница. В документации PostgreSQL 16 указано, что constraint можно добавить как NOT VALID: существующие строки не сканируются сразу, но новые вставки и обновления уже проверяются. Позже VALIDATE CONSTRAINT сканирует таблицу и подтверждает условие; команда получает SHARE UPDATE EXCLUSIVE lock. Это пример различия между «правило добавлено» и «все старые строки ему соответствуют». Он относится к PostgreSQL 16 и не переносит поведение на MySQL, другую версию, ORM или managed service.

\n

Матрица reader/writer для переходного периода

\n

Матрица помогает обнаружить несовместимую пару до запуска данных. В ней нет попытки описать весь 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 больше не нужны
\n

Таблица показывает важное ограничение: успешный v2 reader не доказывает отсутствие v1 writer. Аналогично, заполненные новые строки не доказывают, что отложенная очередь или внешний импортёр перестали писать старую форму. Поэтому переходы reader, writer и schema cleanup не стоит объединять в один release.

\n

Migrate: backfill должен иметь размер и остановку

\n

Backfill — это рабочий процесс, который читает старые строки и создаёт для них новую форму. У него должны быть scope, размер порции, порядок обхода, повторный запуск, владелец и stop condition. Scope отвечает на вопрос «какие строки мы переносим», stop condition — «когда прекращаем работу даже при неполном результате». Без второго параметра команда не умеет безопасно остановиться: при росте latency остаётся только спорить, продолжать ли job.

\n

Для большой таблицы полезен ключ-граница, а не неопределённое «обработать всё». Например, один запуск может работать с диапазоном идентификаторов до заранее записанного upperBound. Следующий запуск берёт новый диапазон после проверки результата. Но сам диапазон не гарантирует безопасность: нужно измерить lock wait, время запроса, lag реплики, ошибки и долю расхождений в выбранной среде. Порог нельзя взять из этой статьи, потому что его задают размер таблицы, индексы, traffic profile и SLO конкретной системы.

\n

GitLab в официальном руководстве разделяет schema migration и batched background migration: большие изменения данных выносятся в пакетную фоновую обработку, а схема меняется отдельно. Это полезное правило организации работ, но не готовая команда для чужого стека. Для PostgreSQL, Rails, очереди и версии GitLab нужны собственные ограничения и проверки.

\n

Воспроизводимый пример: остановить план до реального запуска

\n

Ниже — самостоятельный Node.js-скрипт. Он не подключается к базе: в массиве зафиксированы три записи, одна из которых уже имеет summary. Скрипт считает, что запись с новой формой можно пропустить, а старую — преобразовать. Если в отчёте остались ошибки преобразования или не задана граница диапазона, он возвращает stop. Это пример проверки структуры плана, не измерение производительности и не разрешение на production backfill.

\n
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-полем.

\n

Switch: новое чтение должно иметь наблюдаемый критерий

\n

После backfill читатель можно переключать постепенно. Сначала v2 умеет прочитать old и new, затем для выбранной области сравниваются результаты двух представлений. Расхождение нужно считать отдельно от обычной ошибки запроса: иначе fallback будет выглядеть как успешный ответ. Полезные поля наблюдения — версия reader, endpoint, идентификатор операции, причина fallback и направление записи. Чувствительные значения в лог не попадают.

\n

Нельзя заменить evidence календарём. Для переключения нужны хотя бы завершённый scope выбранной области, понятная доля fallback, отсутствие новых ошибок преобразования и подтверждённый список старых writers. Окно наблюдения и численные пороги определяет владелец сервиса по своему SLO. В этом тексте нет выдуманного «24 часа без ошибок»: для одной системы это может быть мало, для другой — не иметь смысла из-за недельной периодичности batch.

\n

Google SRE Book формулирует границу шире: тесты проверяют конкретные области эквивалентности и уменьшают неопределённость после изменения, но зелёный тест не доказывает надёжность всей системы. Поэтому rehearsal и тесты сравнения — аргументы для switch, а не автоматический приказ удалить старую форму.

\n

Contract: удаление — отдельное и иногда необратимое решение

\n

Contract начинается тогда, когда старое представление больше не нужно ни одному допустимому читателю и writer-у. До этого момента его можно сделать невидимым для нового пути, но не следует физически удалять. Сначала прекращают старые записи, затем подтверждают отсутствие old consumer, потом удаляют код fallback и только после этого рассматривают удаление колонки или таблицы. На каждом шаге нужен read-back состояния.

\n

Rollback к прежнему binary возвращает код, а не обязательно данные. Если contract уже удалил колонку, очистил старую таблицу или преобразовал значение с потерей информации, восстановление потребует data repair или restore из резервной копии. Поэтому в runbook полезно разделить три границы: что откатывает deploy, что восстанавливает application path и что требует восстановления данных. Слово «rollback» без этого разделения создаёт ложное чувство безопасности.

\n
Схема перехода данных: mixed fleet проходит через проверку совместимости, ограниченный backfill и stop gate; при неизвестном старом потребителе стрелка останавливается перед contract
Stop gate не запускает миграцию и не читает production-метрики. Он фиксирует условия, которые должны быть доказаны в конкретной системе до удаления старой формы.
\n

Порядок проверки перед удалением

\n
  1. Опишите old и new representation. Отдельно зафиксируйте смысл отсутствующего значения, правило преобразования и случаи потери данных.
  2. Составьте матрицу версий reader/writer. Неизвестную клетку пометьте как blocker, а не заполняйте предположением.
  3. Сделайте expand, который не ломает разрешённый старый writer. Проверьте constraints, defaults, triggers, индексы и порядок deploy по документации вашей СУБД.
  4. Опишите backfill как bounded job: scope, ключ-граница, batch, повторный запуск, owner, stop condition и ожидаемое partial state.
  5. Проведите rehearsal на копии или разрешённом стенде с тем же типом данных. Проверьте conversion error, retry, остановку и запись прогресса.
  6. Включите метрики fallback и divergence. Разделите их по reader, writer, endpoint и типу записи, чтобы одна успешная ветка не скрыла старую.
  7. Переключайте чтение постепенно. Оставьте совместимый fallback до завершения согласованного окна и read-back результата.
  8. Отдельно проверьте workers, cron, очереди, импорты и внешние clients. Удаление старого consumer из основного репозитория не закрывает весь fleet.
  9. Запишите recovery boundary. Для каждого шага укажите, возвращается ли кодом, исправляется ли данными или требует restore.
  10. Рассмотрите contract последним. Если backfill не ограничен, старый consumer неизвестен или recovery не проверен, остановитесь на текущем шаге.
\n

Ограничения: где этот алгоритм не даёт готового рецепта

\n

Expand/migrate/switch/contract не решает автоматически dual write между двумя базами. Если запись в одну систему подтверждена, а во вторую нет, потребуется reconciliation или другая архитектура согласованности. ORM может кэшировать старую форму. Реплика может отставать. Очередь может повторить сообщение. Внешний клиент может использовать старое поле без регистрации. Для каждого случая нужно отдельно определить идемпотентность, порядок событий и источник истины.

\n

Пример SQL из документации PostgreSQL нельзя переносить на другую СУБД. Даже в PostgreSQL NOT VALID не означает, что старые строки уже соответствуют constraint; это состояние до отдельной валидации. Материал Stripe — описание миграции Stripe с их хранилищем и инструментами, а не обещание нулевого простоя в вашем проекте. Источник Google объясняет роль тестов, но не вычисляет допустимый batch или capacity базы.

\n

Если наблюдение показывает рост lock wait, lag, ошибок преобразования или divergence, остановка — ожидаемый результат контроля. Сначала сохраняют старую форму, фиксируют частичный прогресс и выясняют причину. Увеличить параллелизм или удалить fallback без этой проверки значит расширить blast radius, а не ускорить завершение.

\n

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

\n

Contract можно рассматривать только при одновременном выполнении пяти условий: все разрешённые версии читают совместимую форму; writer-ы не создают неподдерживаемые строки; backfill завершил явно заданный scope и даёт повторяемый результат; fallback и divergence наблюдаются в согласованном окне с результатом, который устраивает владельца; recovery boundary проверена для каждого необратимого шага.

\n

Это не универсальный чек-лист допуска. Владелец конкретной системы должен добавить версию СУБД, размер и форму данных, ограничения прав, репликацию, расписание редких consumers и критерии SLO. Но логика останется той же: если доказательство отсутствует, старое представление не удаляют. Возврат к совместимому коду дешевле, чем восстановление информации, которую уже физически стерли.

\n

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

\n" }