DarkRiDDeR13 мин

Безопасная миграция данных: модель совместимости версий и цена contract

ДанныеМиграции

Симптом миграционной ошибки часто скрыт до тех пор, пока rollout не становится смешанным. Новая версия reader-а уже ждёт новое представление, старая версия writer-а ещё пишет старое, а schema change объявлен завершённым, потому что команда DDL прошла. Через несколько минут часть записей видна одному сервису и неполна для другого. Цена — не только data mismatch. Команда не может ответить, какую версию нужно откатить, потому что изменение формы данных и изменение кода уже смешались в одном факте.

Причина — неверная единица рассуждения. Мы обсуждаем колонку, таблицу или mapper, хотя реальная единица — пара reader/writer во времени. Schema — общий протокол между версиями приложения. Пока в сервисе есть хотя бы две версии, необходимо явно описать, какую форму каждая читает и пишет. Тогда dual write перестаёт быть фразой из runbook и становится ограниченным правилом: кто пишет две формы, сколько оно живёт и какой evidence снимает обязанность.

Симптом → причина → проверка → действие

  1. Симптом. Новый reader ждёт new shape, старый writer продолжает создавать old shape, а schema change уже считается готовым.
  2. Причина. Совместимость описали как свойство колонки, а не как четыре роли: old/new reader и old/new writer.
  3. Проверка. Составить матрицу версий, определить значение отсутствующего поля и явно назвать, какой consumer ещё требует old representation.
  4. Действие. Оставить совместимый период, сделать backfill отдельной управляемой работой и вынести contract в следующее решение с evidence.

Четыре роли вместо одного слова «совместимость»

В модели достаточно четырех ролей: old reader, old writer, new reader, new writer. Reader отвечает, какую форму он примет; writer — какую создаст. Из этого получаются четыре направления, и каждое нужно назвать. New reader, который умеет old/new, защищает от неполного backfill. New writer, который продолжает писать old/new, защищает old reader в mixed fleet. Если новую форму выпускают так, что old writer получает отказ, expand уже не expand: схема стала зависеть от порядка rollout.

Нельзя заменять эту таблицу тезисом «мы разворачиваем быстро». Даже быстрый rollout имеет границы: rollback, retries, long-lived worker, отдельный consumer, вручную запущенная утилита, batch job. Не требуется inventarize весь мир заранее. Требуется назвать область, на которую вы опираетесь, и сделать опасные неизвестности blockers. Если владелец не знает, есть ли old writer, условие contract не выполнено. Это не бюрократия, а честная причина не стирать старую форму.

Матрица версий и форм, которую стоит согласовать до schema change
ВерсияЧитаетПишетДопустимый этапРиск без контракта
v1 / oldold shapeold shapeexpand и migrateновая schema может отвергнуть ещё живой writer
v2 / transitionnew or old shapeold and new shapemigrate и switchнеявный fallback скрывает неполный backfill
v3 / newnew shapenew shapeпосле evidence contractранний выпуск может отрезать v1 consumer
backfill draftold record как входnew representation как результаттолько в миграционном окненеограниченная работа конкурирует с пользовательским потоком
rollback actionсовместимое представлениене восстанавливает удалённые фактыдо contractвозврат бинарника ошибочно принимают за data recovery
Матрица совместимости: v1 читает и пишет старое представление, v2 читает обе формы и пишет обе, v3 использует новое. Зелёные клетки обозначают разрешённые пары в переходном периоде, красная клетка показывает удаление старой формы при всё ещё живом v1.
Матрица — контракт рассуждения, не снимок deployment. Она не говорит, какие версии реально запущены и не измеряет качество или полноту данных.

Expand: почему «nullable» не равно «безопасно»

Nullable field часто выбирают как быстрый expand. Это может быть разумно: old writer не обязан сразу знать новое поле, а new reader может трактовать отсутствие как старую форму. Но безопасность возникает только после двух дополнительных договорённостей. Во-первых, отсутствие должно иметь точную семантику: «ещё не мигрировано», «не применимо» и «значение потеряно» не одно и то же. Во-вторых, new writer не может одним release убрать старую форму, если old reader всё ещё возможен. Nullable устраняет один вид schema rejection, но не выбирает semantics и не завершает миграцию.

PostgreSQL 16 показывает, почему полезно читать документацию движка до плана. В ней указано, что добавление колонки с non-volatile default использует сохранённое в metadata значение для existing rows, тогда как volatile default или изменение типа могут требовать rewrite. Там же описаны scans и locks при constraint operations. Из этого не следует, что нужно копировать PostgreSQL path в другой движок. Следует более скромное правило: contract изменения должен содержать версию СУБД и конкретную операцию, а не слово «легковесная миграция».

Migrate: backfill — это поток с бюджетом

Backfill не является фоновым шумом. Он читает старые записи, пишет новое представление, может повторяться, соперничать за индекс, соединение, журнал и capacity. Поэтому полезно писать не «запустим джобу», а небольшую спецификацию: идентификатор области, правило отбора, idempotency key или equivalent invariants, диапазон параллелизма как проектная настройка, stop condition и действие после stop. Значения нельзя брать из чужой статьи. Их получает owner через измерение и rehearsal выбранной среды.

Важна разница между guard и результатом. Guard может остановить synthetic route, если нет declared stop condition. Но guard не говорит, что выбранная порция безопасна под реальной нагрузкой. Google SRE Book формулирует это широко: пройденный test не доказывает reliability, а рискованный инструмент должен быть изолирован барьером. В миграции барьером может быть scope, ограниченный доступ, отдельная среда и явное право owner-а. Не стоит превращать эту общую мысль в готовую схему database permissions.

Пример: closed input contract вместо свободного migration object

Ниже фиксированная учебная модель принимает не описание реальной базы, а один из embedded case id. Это нарочно узко. Если в input добавить databaseUrl, migrationFile или режим реального запуска, функция отклоняет его до классификации. Так fixture не создаёт впечатление, что умеет проверить реальные connection string, таблицы, SQL или deployments. В рабочем инструменте договор может быть шире, но тогда его входы и права должны быть предметом отдельного security review.

import {
  createFixedSyntheticMigrationInput,
  inspectSyntheticDataMigration,
  planSyntheticDataMigration,
} from './upgrade-2024-04.mjs';

const input = createFixedSyntheticMigrationInput('fixed-contract-with-mixed-fleet');
const report = inspectSyntheticDataMigration(input);
const plan = planSyntheticDataMigration(report);

console.log({
  verdict: report.verdict,
  reasons: report.reasons,
  draftActions: plan.actions,
});

// Работает только с embedded fixed synthetic records в памяти.
// Не открывает БД, не исполняет SQL и не запускает миграцию.

Case fixed-unbounded-backfill возвращает специальную причину backfill-has-no-declared-bounded-stop-condition. Он не говорит «база перегружена» — никаких метрик не читалось. Это принципиально. Симптом реальной перегрузки нуждается в настоящем signal, но уже в design можно запретить запуск процесса без того, что будет этот signal интерпретировать и останавливать. Модель отделяет проверяемую форму плана от эмпирического ответа о capacity.

Switch: совместимость данных не равна переключению трафика

После backfill возникает соблазн сделать switch одним флагом. Но data compatibility и traffic exposure не совпадают. У новой формы может быть заполнено 100% записей в известной области, а новый reader может всё ещё получать старую запись из retry, реплики, очереди или временной интеграции. Поэтому switch требует своих критериев: какой reader переключаем, по какому input, что считается divergence, кто останавливает rollout и остаётся ли старый representation записываемым.

Это также место для честной границы наблюдения. Rehearsal способен показать заранее выбранный mixed-version scenario и stop path. Он не гарантирует, что production обладает той же формой данных, распределением нагрузки или внешними consumer-ами. Google SRE Book отличает hermetic testing от production tests и подчёркивает, что поведение реальной среды не исчерпывается одним test result. Для автора M7 это означает простую речь: называть rehearsal доказательством конкретного маршрута, не доказательством отсутствия всех рисков.

Contract: условие удаления формулируется отрицательно

Слабое условие contract звучит так: «новый код уже раскатан». Сильнее звучит отрицательное: «не осталось declared old reader/writer, которому требуется old representation; data owner подтвердил результат; recovery boundary описана». Такое условие труднее подделать красивым дашбордом. Оно заставляет спросить о batch job, failed rollout, законсервированном consumer и rollback path до удаления.

Если старое поле или таблица уже удалены, возврат application binary не обещает старые данные. Поэтому cleanup не следует приклеивать к switch как автоматический хвост. В Stripe case удаление устаревших данных и прекращение старых writes были отдельной финальной фазой после перехода. Это исторический пример их системы, не срок для вашей команды. Его ценность в разделении обязательств: прежде чем убрать старое, убедиться, что источник истины действительно сменился, а не только один экран показывает новый ответ.

Порядок проектирования механизма

  1. Определить old и new representation без терминов «как-нибудь nullable». Зафиксировать значение отсутствия, default и ошибки преобразования.
  2. Составить матрицу v1/v2/v3 для reader и writer. Если клетка неизвестна, обозначить её blocker, а не предположением.
  3. Выбрать expand, который сохраняет declared old writer. Сверить операцию с документацией именно используемой версии database engine.
  4. Задать migration route: scope, повторяемость, owner, bounded work, stop signal и что произойдёт с partial progress.
  5. Проверить rehearsal только против сформулированного scenario: version mix, data shape, stop route и rollback draft.
  6. Переключить reader по evidence, а не по дате. Сохранить compatible path до результата review.
  7. Вынести contract в отдельное решение. При неизвестном old consumer или recovery gap оставить старое представление.

Ограничения и следующий шаг

Модель не описывает replication lag, foreign keys, triggers, generated columns, ORM caching, timezone, encryption, archival policy, data residency и audit trail. Она не определяет, можно ли делать dual write атомарно: это зависит от границы транзакции и выбранных систем. Не путайте synthetic route со схемой production permissions. PostgreSQL 16 documentation не даёт поведению другого хранилища, Stripe не даёт ваш traffic profile, а Google SRE Book не вычисляет database capacity.

Следующий шаг — взять один ожидаемый schema change и написать compatibility matrix из пяти строк: v1 reader, v1 writer, v2 reader, v2 writer, contract criterion. Если хотя бы одна строка требует предположения, не запускать backfill «на пробу». Сначала закрыть вопрос owner-ом, логикой старой формы или ограниченной rehearsal. В этом и есть практичный рост автора: не искать универсальный migration tool, а сделать несовместимость видимой до того, как она станет данными.

Историческая граница апреля 2024

Материал ограничен источниками, доступными к апрелю 2024: Stripe case 2017, PostgreSQL 16 от сентября 2023 и Google SRE Book. Термины expand, migrate и contract здесь — способ вести инженерный разговор, а не стандарт SQL. Для неизвестного движка каждое поведение нужно перепроверить по его официальной документации и собственной rehearsal.

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

  • Stripe Engineering: Online migrations at scale, 02.02.2017 — Первичный инженерный разбор Stripe, опубликованный задолго до апреля 2024: четыре фазы — dual write, переключение чтений, переключение записей и удаление старого — применены к их subscriptions. Это наблюдение Stripe для конкретной инфраструктуры и объёма данных, а не обещание, что любая БД выдержит такой маршрут без собственных лимитов и проверки.
  • PostgreSQL 16: ALTER TABLE — Первичная документация PostgreSQL 16. Она описывает PostgreSQL-специфичные свойства ADD COLUMN, NOT VALID и VALIDATE CONSTRAINT, включая разные блокировки и проверку старых строк. Эти детали нельзя переносить на другую СУБД, ORM или managed service без её собственной документации и rehearsal.
  • PostgreSQL 16 released, 14.09.2023 — Официальное сообщение PostgreSQL Global Development Group фиксирует историческую доступность версии 16 до апреля 2024. Оно подтверждает дату версии, но не доказывает версию, настройки, размер таблиц или lock-поведение чьей-либо системы.
  • Google SRE Book: Testing for Reliability — Первичный материал Google SRE Book, доступный до апреля 2024: тест снижает неопределённость, но проход теста не доказывает надёжность; рискованные инструменты требуют отдельного барьера. Это общий принцип release engineering, не руководство по синтаксису или нагрузке конкретной базы данных.