8 lines
25 KiB
JSON
8 lines
25 KiB
JSON
{
|
||
"index": 52,
|
||
"slug": "editorial-2026-07-field-migration-playbook",
|
||
"title": "Безопасный переход между старой и новой схемой",
|
||
"excerpt": "Как перенести поле или формат данных без разрыва совместимости: разделить чтение и запись, проверить реальные границы и заранее назвать состояние отката.",
|
||
"contentHtml": "<p>После релиза старый клиент продолжает отправлять <code>display_name</code>, а новый уже ожидает объект <code>profile_name</code>. Часть запросов проходит чтение, но запись нового поля заканчивается ошибкой в старом обработчике. Команда возвращает трафик на прежний маршрут и видит зелёный статус, хотя несколько записей уже изменились. Цена такой путаницы — потерянные обновления, повторные операции и ручное восстановление согласованности.</p>\n<p>Переход между схемами безопасен, когда у него есть две независимые истории: как меняется код и маршрут, и как меняется состояние данных. Откат первой истории не отменяет вторую. Поэтому сначала фиксируют границу перехода, затем вводят новую форму как совместимое расширение, и только после проверки удаляют старую.</p>\n<h2>Сначала зафиксируйте границу перехода</h2>\n<p>Запишите один конкретный объект, а не «миграцию сервиса целиком». Для поля это могут быть таблица <code>profiles</code>, запись с идентификатором <code>42</code>, endpoint <code>PUT /profiles/42</code> и два consumer: старое мобильное приложение и новая web-версия. Такая запись позволяет связать симптом с запросом и данными. Если объект, владелец или потребитель не названы, причина ещё не проверяема.</p>\n<p>Разделите четыре состояния. <code>Code state</code> описывает версии reader и writer. <code>Traffic state</code> показывает, какой маршрут получает запросы. <code>Data state</code> говорит, заполнены ли обе формы и чем их сравнивают. <code>Recovery state</code> объясняет, что можно вернуть после частичной записи. Один флаг <code>migrated</code> не заменяет эту карту: он не говорит, кто уже читает новую форму.</p>\n<table><thead><tr><th>Состояние</th><th>Что фиксируем</th><th>Наблюдаемый сигнал</th><th>Граница решения</th></tr></thead><tbody><tr><td>Код</td><td>Версия reader и writer, поддерживаемые поля</td><td>Ответ старого и нового клиента на одной записи</td><td>Новая версия не делает старую форму обязательной</td></tr><tr><td>Трафик</td><td>Маршрут, доля, control и candidate</td><td>Версия обработчика в журнале запроса</td><td>Сравниваются одинаковые route boundary</td></tr><tr><td>Данные</td><td>Источник, целевая форма, правило сверки</td><td>Счётчик несовпадений и выборка записей</td><td>Нет скрытого расхождения между формами</td></tr><tr><td>Восстановление</td><td>Триггер, владелец, traffic return и data return</td><td>Зафиксированный результат возврата</td><td>Понятно, что произойдёт после частичного успеха</td></tr></tbody></table>\n<p>Control и candidate нужны даже при малой доле нового трафика. Без control новая версия сравнивается сама с собой. Доля 10% — это только распределение запросов, а не доказательство совместимости. Владелец перехода также должен различать «можно продолжать наблюдение» и «можно менять схему»: это разные решения.</p>\n<h2>Расширяйте схему в несколько фаз</h2>\n<p>Для замены строки на объект используйте расширение и последующее сужение. Сначала новая форма существует рядом со старой и остаётся необязательной. Затем writer формирует обе формы из одного входа. После заполнения старых записей readers переходят на новую форму с безопасным fallback. Лишь после окна наблюдения отключают старую запись и удаляют старый consumer.</p>\n<ol><li>Инвентаризируйте все readers и writers выбранного поля, включая фоновые задачи, кеши и повторную доставку сообщений.</li><li>Добавьте <code>profile_name</code> без немедленного требования для старых записей. Новая запись должна сохранять и <code>display_name</code>, и объект.</li><li>Заполните пропуски отдельной процедурой. На каждой порции считайте ошибки преобразования и несовпадения, а не только число обработанных строк.</li><li>Переключите новый reader на новую форму, но оставьте fallback для записи, которая ещё не прошла backfill.</li><li>Подавайте трафик ступенями. После каждой ступени проверяйте код ответа, расхождения данных, повторы записи и ошибки конкретного consumer.</li><li>Отключите старую запись отдельным изменением. Удаляйте старую колонку и контракт только после нулевого чтения за согласованное окно и проверки восстановления.</li></ol>\n<p>Такой порядок называется expand-and-contract, но название не является гарантией. Если новая и старая формы имеют разную семантику, автоматическое копирование строки в объект может создать корректный по типу, но неверный по смыслу результат. В этом случае сначала нужно определить правило преобразования и список значений, которые нельзя преобразовать автоматически.</p>\n<figure><img src=\"/assets/editorial/2026/migration-playbook-2026-transition-evidence-loop.svg\" alt=\"Схема перехода между старой и новой формой: фиксированный объект, критерий восстановления, доказательства и отдельная ветка остановки\"><figcaption>Переход проходит от конкретной записи и маршрута к проверяемому критерию. Неполное условие останавливает следующую ступень.</figcaption></figure>\n<h2>Сделайте чтение и запись совместимыми</h2>\n<p>Reader должен знать, какая форма имеет приоритет, а writer — что обе формы получаются из одного нормализованного значения. Fallback не должен молча склеивать два источника. Если формы расходятся, верните ошибку сверки или создайте отдельный сигнал для восстановления. Иначе новая версия будет показывать одно имя, а старый клиент — другое.</p>\n<pre><code>type LegacyRecord = { display_name: string };\ntype ProfileName = { value: string; locale: string };\ntype RecordV2 = LegacyRecord & { profile_name?: ProfileName };\n\nfunction normalize(input: string): ProfileName {\n return { value: input.trim(), locale: 'ru-RU' };\n}\n\nfunction writeBoth(input: string): RecordV2 {\n const profileName = normalize(input);\n return {\n display_name: profileName.value,\n profile_name: profileName,\n };\n}\n\nfunction readForNewClient(record: RecordV2): ProfileName | null {\n if (record.profile_name) return record.profile_name;\n if (record.display_name.trim() === '') return null;\n return normalize(record.display_name);\n}\n\nfunction hasConflict(record: RecordV2): boolean {\n return Boolean(\n record.profile_name\n && record.profile_name.value !== record.display_name,\n );\n}</code></pre>\n<p>В примере <code>normalize</code> — проектное правило, а не свойство PostgreSQL или HTTP. Его нужно согласовать с предметной областью: пробелы, регистр и локаль могут быть значимыми. Функция <code>hasConflict</code> намеренно не выбирает «более новую» форму. При конфликте безопаснее остановить запись и сохранить исходные значения для расследования, чем потерять одно из них.</p>\n<p>Для writer отдельно задайте повторную обработку. Если запрос оборвался после записи старой формы, но до записи новой, повтор может завершить операцию. Если повтор создаёт новую побочную запись или меняет значение ещё раз, одного HTTP-метода недостаточно. Храните ключ операции, результат и версию входа там, где это требуется вашему хранилищу.</p>\n<h2>Проверьте одну запись и одну повторную попытку</h2>\n<p>До переключения трафика возьмите одну тестовую запись и прогоните полный цикл: старый запрос, новый запрос, двойная запись, чтение обеими версиями и повтор после искусственного обрыва ответа. В реальной базе сначала проверьте план и блокировки на копии или в тестовой среде. В PostgreSQL добавление необязательной колонки и заполнение строк — разные операции, поэтому их и измеряют отдельно.</p>\n<pre><code>-- Фаза expand: новая форма пока допускает NULL.\nALTER TABLE profiles ADD COLUMN profile_name jsonb;\n\n-- Проверка согласованности после backfill.\nSELECT count(*) AS conflicts\nFROM profiles\nWHERE profile_name IS NOT NULL\n AND profile_name->>'value' <> display_name;\n\n-- Фаза contract выполняется только после нулевого conflicts\n-- и подтверждения, что старые readers больше не обращаются к колонке.\n-- ALTER TABLE profiles DROP COLUMN display_name;</code></pre>\n<p>Этот SQL не является готовой миграцией для любого проекта. В нём нет блокировок, размера таблицы, индексов, прав, времени выполнения и правила обработки <code>NULL</code>. Запрос сверяет только одно поле и может быть недостаточен для реального контракта. Перед <code>DROP COLUMN</code> сохраните экспорт или другой согласованный способ восстановления: удалённое значение не вернётся от одного отката приложения.</p>\n<p>Повторную запись проверяйте тем же ключом операции. Если в API используется <code>POST</code>, не называйте его идемпотентным только из-за того, что сервер старается распознать повторы. Идемпотентность — свойство намеренного эффекта при нескольких одинаковых запросах; его нужно реализовать и проверить на уровне приложения. Для каждой операции зафиксируйте: ключ, вход, первую запись, ответ и результат повторной попытки.</p>\n<h2>Разделяйте возврат трафика и возврат данных</h2>\n<p>Возврат трафика меняет, какой код получает следующий запрос. Возврат данных меняет записи, журнал преобразований или источник чтения. Эти действия могут выполняться в разное время и иметь разного владельца. Если новая версия уже сохранила <code>profile_name</code>, переключение маршрута на старый reader не отменит эту запись.</p>\n<p>На уровне Kubernetes команда <code>kubectl rollout undo</code> возвращает Deployment к прежней ревизии Pod template. Это полезный способ вернуть контейнер и его конфигурацию, но он не восстанавливает строки в базе и не меняет внешний балансировщик, если тот управляется отдельно. После команды проверяйте фактическое состояние Deployment и отдельно — состояние записи, очереди и кеша.</p>\n<pre><code>kubectl rollout history deployment/profile-api\nkubectl rollout undo deployment/profile-api --to-revision=12\nkubectl rollout status deployment/profile-api\n\n# Отдельно проверяем данные через приложение или read-only запрос:\ncurl -sS https://api.example.test/profiles/42 -H 'X-Client-Version: legacy'</code></pre>\n<p>Команды требуют подходящих прав и контекста кластера; URL и номер ревизии здесь учебные. Не запускайте их в production по копированию из статьи. Сначала определите, кто владеет маршрутизацией, кто владеет схемой и какой сигнал подтверждает результат каждого возврата.</p>\n<h2>Остановитесь по конкретному симптому</h2>\n<table><thead><tr><th>Симптом</th><th>Проверка</th><th>Вероятная граница</th><th>Действие</th></tr></thead><tbody><tr><td>Старый клиент получает 500 после записи</td><td>Сравнить его payload, схему ответа и версию writer</td><td>Новая форма стала обязательной слишком рано</td><td>Вернуть optional-поле и двойную запись</td></tr><tr><td>Новый клиент видит старое имя</td><td>Проверить приоритет reader и наличие новой формы у записи</td><td>Backfill не завершён или кеш не инвалидирован</td><td>Оставить fallback и найти источник устаревшего ответа</td></tr><tr><td>Формы одной записи расходятся</td><td>Сравнить нормализованный вход, время записи и ключ операции</td><td>Две записи не были атомарными или повторились</td><td>Остановить ступень и запустить reconciliation</td></tr><tr><td>После rollback приложения данные остались новыми</td><td>Сопоставить revision Deployment и data state записи</td><td>Возвращён только Pod template</td><td>Отдельно выбрать restore или новый reader с fallback</td></tr><tr><td>Старая колонка всё ещё читается</td><td>Посчитать обращения по consumer, кешу и фоновой задаче</td><td>Не найден параллельный reader</td><td>Не удалять колонку и продолжить инвентаризацию</td></tr><tr><td>Повтор запроса создаёт второй эффект</td><td>Повторить тот же input и ключ после обрыва ответа</td><td>Операция не идемпотентна на уровне приложения</td><td>Добавить дедупликацию и проверку результата повтора</td></tr></tbody></table>\n<p>Остановка должна оставлять диагностический артефакт: идентификатор записи, request id, версию consumer, обе формы данных и причину остановки. Общий статус «ошибка миграции» не помогает восстановить порядок событий. Чем меньше вход фиксирован, тем быстрее можно отличить ошибку преобразования от ошибки маршрутизации.</p>\n<h2>Критерий готовности следующей ступени</h2>\n<p>Переход можно расширять только после проверки на выбранном срезе. Для каждой ступени сохраните значения следующих полей: одинаковая route boundary у control и candidate; версия reader и writer; окно наблюдения; число записей с обеими формами; число конфликтов; количество повторов; trigger возврата; traffic return; data return; владелец решения. Нулевой конфликт без указанного окна не является универсальным доказательством: он может означать, что выборка не охватила нужные записи.</p>\n<ol><li>Проверьте, что старый reader получает допустимый ответ до переключения.</li><li>Проверьте, что новый reader одинаково обрабатывает новую форму и fallback.</li><li>Повторите запись с тем же ключом и сравните побочный эффект.</li><li>Намеренно остановите тестовый переход и подтвердите оба результата: маршрут вернулся, а данные либо восстановлены, либо явно остались в новой форме, которую умеет читать старый клиент.</li><li>Только после этого увеличивайте долю candidate и записывайте решение в журнал изменения.</li></ol>\n<p>После достижения 100% нового reader не переходите сразу к удалению. Оставьте отдельное окно для фоновых задач, кешей, отложенных сообщений и клиентов, которые обновляются медленнее сервера. Старую форму отключайте отдельным релизом, чтобы при проблеме можно было вернуть writer без смешения двух причин.</p>\n<h2>Ограничения применимости</h2>\n<p>Этот порядок подходит для расширения контракта, когда старый и новый reader могут временно сосуществовать. Он не решает несовместимое изменение смысла, шифрование с другой схемой ключей, перенос между хранилищами без общего источника истины или миграцию, в которой каждое чтение вызывает необратимый побочный эффект. Там нужен отдельный план восстановления и, возможно, остановка записи на время сверки.</p>\n<p>Учебные идентификаторы, доли трафика и ответы не являются измерениями конкретной системы. Нельзя по ним утверждать отсутствие потерь, безопасное время выполнения SQL или успешный rollout. Kubernetes, PostgreSQL и HTTP имеют свои версии, настройки и права. Проверяйте команды на версии вашего кластера, размер таблицы, политики кеширования и фактический контракт API.</p>\n<p>Fallback тоже имеет цену. Он может скрыть незаполненную новую форму, а двойная запись — увеличить задержку и число мест отказа. Если читатель не может отличить «нового значения нет» от «новое значение не удалось сохранить», fallback маскирует дефект. Введите отдельный сигнал для пропуска, конфликта и технической ошибки, иначе решение будет принято по неполному наблюдению.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://kubernetes.io/docs/concepts/workloads/controllers/deployment/\" target=\"_blank\" rel=\"noopener\">Kubernetes Documentation: Deployments</a> — описывает историю ревизий, <code>kubectl rollout undo</code>, проверку статуса и то, что откат возвращает Pod template Deployment, а не произвольное состояние данных.</li><li><a href=\"https://www.postgresql.org/docs/current/ddl-alter.html\" target=\"_blank\" rel=\"noopener\">PostgreSQL Documentation: Modifying Tables</a> — разделяет изменение определения таблицы и данных, описывает добавление колонки с <code>NULL</code> по умолчанию и предупреждает, что <code>DROP COLUMN</code> удаляет находившиеся в ней данные.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener\">RFC 9110: HTTP Semantics</a> — определяет safe- и idempotent-методы и объясняет, почему автоматический повтор запроса требует проверки семантики операции, а не только факта сетевого сбоя.</li></ul>"
|
||
}
|