{ "index": 135, "slug": "editorial-2024-04-practice-data-migrations", "title": "Миграция данных без ловушки отката: expand, migrate, contract", "excerpt": "Как менять схему при смешанных версиях приложения: сохранить совместимость, ограничить backfill, проверить отрицательный путь и не принять удаление старого поля за обычный rollback.", "contentHtml": "
После релиза новая версия сервиса отвечает ошибкой на запись: обязательное поле ещё не заполняет старый writer, то есть компонент, который сохраняет запись. В другой попытке поле сделали nullable, запустили backfill без предела и получили рост задержек. В обоих случаях DDL прошло успешно. Ошибка появилась позже, когда версии приложения стали жить рядом. Цена ошибки — потерянные записи, очередь повторных запросов и отсутствие честного пути назад. Откат бинарника не возвращает данные, которые уже перезаписаны или удалены.
\nБезопасная миграция — это не одна команда изменения схемы. Это период совместимости между старым и новым reader (компонентом чтения), старым и новым writer, затем отдельное преобразование данных и только потом удаление старой формы. Такой маршрут называют expand–migrate–contract. Он не делает операцию безопасной автоматически. Он раскладывает риск на этапы, для каждого этапа задаёт проверку и оставляет границу, после которой rollback приложения уже недостаточен.
\nПредставьте запись заказа. Старая форма хранит имя клиента в поле customer_name. Новая форма должна хранить ссылку customer_id. Если сразу удалить старое поле, старый сервис перестанет писать. Если сразу потребовать customer_id, старые строки и старые workers станут ошибками. Поэтому сначала добавляют новую поверхность, не запрещая старую.
На этапе expand новая схема должна принимать старую форму. New reader читает обе формы. New writer может записать новую форму и, пока жив старый consumer, сохраняет совместимое старое значение. Backfill переносит уже существующие строки. Только после переключения всех readers и writers появляется основание для contract. У каждого перехода должен быть owner, стоп-сигнал и ответ на вопрос: что сохраняется, если процесс остановить на этой строке?
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старая версия получает отказ после добавления поля. | Expand уже требует new shape. | Проверить SQL-ограничения и запись old writer в отдельной транзакции. | Снять обязательность с новой колонки, проверить запись old writer и выпускать reader до writer. |
| Backfill перегружает основную базу. | Нет размера партии, лимита скорости и стоп-сигнала. | Сверить нагрузку job с бюджетом обычных запросов и проверить остановку на середине. | Ограничить batch, concurrency и время запуска. Сохранить cursor и повторный запуск. |
| New reader видит пустое значение. | Заполнение приняли за доказательство полноты. | Проверить долю старой формы, правила для null и список ещё живых writers. | Оставить fallback и не переходить к contract. |
| После отката код читает новую схему, но старых данных нет. | Удаление данных назвали rollback. | Спросить, каким действием восстанавливаются удалённые строки и откуда берётся копия. | Остановить contract. Подготовить backup/restore или совместимое представление заранее. |
| Миграция прошла на стенде, а в production неизвестен mixed fleet. | Rehearsal проверила сценарий, но не фактические версии и объём. | Проверить deployment inventory, consumers, lock behavior и размер выборки. | Считать стенд только учебной проверкой. Собрать production evidence до переключения. |
Таблица отделяет наблюдаемый симптом от решения. Если нельзя назвать проверку, команда пока не знает, какой риск она закрывает. Если действие меняет данные, оно должно иметь отдельный журнал, владельца и правило повторного запуска.
\nExpand меняет структуру так, чтобы old code продолжал работать. Это может быть nullable-колонка, новая таблица или новый индекс. Конкретная операция зависит от СУБД. Нельзя переносить обещание «добавление поля быстро» с одной версии и одного типа default на другую. В PostgreSQL поведение ALTER TABLE, блокировки и переписывание таблицы зависят от команды и параметров. Проверяйте документацию именно своей версии.
Для примера с заказом expand добавляет customer_id, но не удаляет customer_name и не делает ссылку обязательной для старых строк. New reader использует customer_id, если он есть, иначе временно читает старое имя. Такой fallback должен иметь owner и условие удаления. Иначе временный путь станет постоянным и команда не поймёт, закончена ли миграция.
function readCustomer(order) {\n if (order.customer_id != null) {\n return { kind: 'id', value: order.customer_id };\n }\n\n if (order.customer_name != null) {\n return { kind: 'legacy-name', value: order.customer_name };\n }\n\n return { kind: 'invalid', value: order.id };\n}\n\n// Учебный пример: не подключается к базе и не доказывает\n// корректность данных в реальном сервисе.\nКод показывает три ветки. Новая форма имеет приоритет. Старая форма остаётся читаемой. Отсутствие обеих форм не превращается в тихий default. В настоящем сервисе проверка должна также учитывать права, конкурентную запись и семантику ошибок. Имена в примере вымышлены.
\nНиже — самостоятельная демонстрация для временных таблиц. Её можно вставить в psql PostgreSQL 16: она не обращается к рабочей схеме, добавляет nullable-колонку, переносит две строки и показывает остаток старой формы. Имена клиентов здесь используются только для учебного соответствия; в реальной миграции совпадение должно опираться на устойчивый уникальный ключ.
CREATE TEMP TABLE customers (\n id bigint PRIMARY KEY,\n name text UNIQUE NOT NULL\n);\nCREATE TEMP TABLE orders (\n id bigint PRIMARY KEY,\n customer_name text NOT NULL\n);\n\nINSERT INTO customers VALUES (101, 'Ada'), (102, 'Grace');\nINSERT INTO orders VALUES (1, 'Ada'), (2, 'Grace');\n\n-- Expand: старый writer по-прежнему может писать customer_name.\nALTER TABLE orders ADD COLUMN customer_id bigint;\n\n-- Migrate: ограниченный набор строк, повторный запуск пропускает заполненные.\nUPDATE orders AS o\nSET customer_id = c.id\nFROM customers AS c\nWHERE o.customer_name = c.name\n AND o.customer_id IS NULL;\n\nSELECT id, customer_id\nFROM orders\nORDER BY id;\nSELECT count(*) FILTER (WHERE customer_id IS NULL) AS remaining\nFROM orders;\nОжидаемый результат для этого набора — две строки с идентификаторами 101 и 102 и remaining = 0. Это не доказательство готовности production: в рабочем запуске нужны размер партии, cursor, лимит скорости, наблюдение за блокировками и проверка конкурирующих writers. Если имя не сопоставляется однозначно, строку нельзя заполнять случайным совпадением.
Backfill отвечает на вопрос «как преобразовать старые строки». Он не должен менять договор совместимости. Запускайте его как отдельный процесс с областью, cursor, размером партии, лимитом скорости, idempotency-правилом и измеримым стоп-сигналом. Партия из тысячи строк не является безопасной сама по себе. Она может запускаться без конца, конкурировать с пользовательскими запросами или повторно менять одну строку после сбоя.
\nИдемпотентный шаг проверяет текущее состояние перед записью. Если строка уже имеет корректный customer_id, повторный запуск её пропускает. Если соответствие неоднозначно, job должна остановиться или отправить строку на ручной разбор. Она не должна выбирать первый результат молча. В миграции данных неопределённость — это сигнал остановки, а не повод увеличить batch.
Учебный псевдокод ниже ограничен памятью процесса. Он не читает настоящую БД, не измеряет locks, не запускает транзакции и не сообщает о готовности production.
\nfor (const batch of batches(records, 100)) {\n const updates = [];\n\n for (const record of batch) {\n if (record.customer_id != null) continue;\n\n const match = lookupCustomer(record.customer_name);\n if (match.kind !== 'unique') {\n throw new Error(`stop: ${record.id} needs review`);\n }\n\n updates.push({ id: record.id, customer_id: match.id });\n }\n\n applyUpdates(updates);\n saveCursor(batch.at(-1).id);\n}\nВ примере ошибка останавливает весь учебный проход. В production решение может быть другим: отдельная quarantine-очередь, транзакция на партию или ручное подтверждение. Важно другое: неоднозначная строка не получает случайное значение, cursor сохраняется, а повторный запуск видит уже обработанные записи. Учебный пример не является готовой библиотекой миграции.
\nПереключение чтения не равно завершению backfill. Оно означает, что new reader умеет обработать остаток старой формы и команда согласовала, что делать с null, конфликтом и повторной записью. После переключения наблюдайте ошибки, долю fallback, расхождения двух форм и время обработки. Не удаляйте старое поле сразу после первого зелёного графика.
\nПока old writer или долгоживущий worker ещё может работать, new writer должен сохранять совместимость. Это может быть dual write, событие для отдельного consumer или другой явно описанный механизм. Dual write тоже создаёт риск: записи могут завершиться только в одной форме, а порядок событий может расходиться. Поэтому нужна проверка расхождений и правило исправления. Сам термин dual write ничего не гарантирует.
\nStripe описывает похожий четырёхэтапный путь для своей онлайн-миграции: dual write, перевод чтений, перевод записей и удаление старых данных. Это инженерный разбор инфраструктуры Stripe, а не универсальная гарантия. В другой системе нужно отдельно проверить объём, lock behavior, задержки, ретраи и все пути записи.
\nContract удаляет старую колонку, таблицу, индекс, fallback или dual write. Это полезный финал, но не обычный rollback. Возврат к старому бинарнику восстановит код, а не удалённые значения. Если старое представление нужно для восстановления, его сохраняют до contract: backup проверяют восстановлением, копию снабжают сроком хранения, а divergence связывают с понятным действием.
\nНе называйте contract готовым по одному признаку. Green build не знает о ручном SQL-клиенте. Нулевой fallback за минуту не доказывает, что вчерашний worker завершился. Полная проверка должна охватывать writers, readers, jobs, очереди, отчёты и восстановление. Если список consumers неполон, безопасное действие — продлить совместимый период.
\nExpand–migrate–contract не выбирает isolation level, batch size, lock timeout, retention, backup policy или график запуска. Он не заменяет требования к PII, disaster recovery, capacity planning и проверку прав. PostgreSQL, Stripe и учебный JavaScript-пример описывают разные границы. Их нельзя объединять в обещание zero downtime.
\nДля конкретной миграции критерий готовности проверяем: old и new версии имеют записанный контракт; expand не отвергает old writer; backfill можно остановить и повторить; неоднозначные записи не получают default; список consumers подтверждён; fallback и расхождения наблюдаемы; backup восстановлен на тестовой копии; contract имеет отдельное решение и срок хранения recovery-артефактов.
\nЕсли хотя бы один пункт не подтверждён, миграция не готова к следующему необратимому шагу. Это не провал плана. Это точная граница знания: команда видит, какой факт нужно получить до изменения данных.
\n