{ "index": 52, "slug": "editorial-2026-07-field-migration-playbook", "title": "Безопасный переход между старой и новой схемой", "excerpt": "Как перенести поле или формат данных без разрыва совместимости: разделить чтение и запись, назвать состояние отката и остановиться при неполной проверке.", "contentHtml": "
Запросы к старой версии API проходят, а часть новых клиентов получает 500 после записи нового поля. Через несколько минут становится непонятно, что возвращать: трафик на старый endpoint или данные к прежнему формату. Если откатить только маршрут, старый код может прочитать уже изменённую запись. Если откатить только схему, новый код потеряет обязательное поле. Цена ошибки — потерянные обновления, повторные операции и ручное восстановление согласованности.
\nБезопасный переход начинается с совместимости, а не с переключателя. Старая и новая версии должны некоторое время читать общий набор данных. Каждое изменение делят на независимые состояния: код, маршрут и данные. Для каждого состояния называют условие возврата. Тогда отказ возвращает систему в известное состояние, а не просто включает старый URL.
\nРассмотрим поле display_name, которое нужно заменить на объект profile_name. Старый клиент ожидает строку. Новый клиент ожидает объект с языком и значением. Удалять строку сразу нельзя: старый reader ещё может работать после переключения части трафика.
Эти шаги защищают только совместимость формата. Они не гарантируют правильность бизнес-правил, отсутствие дублей или сохранность данных после ошибочного повторного запроса. Такие свойства проверяют отдельно.
\nНиже учебный фрагмент на TypeScript. Он не подключается к базе и не показывает результат конкретного сервиса. Его задача — сделать порядок совместимости явным.
\ntype LegacyRecord = { display_name: string };\ntype MigratedRecord = LegacyRecord & {\n profile_name?: { value: string; locale: string };\n};\n\nfunction writeBoth(input: string): MigratedRecord {\n return {\n display_name: input,\n profile_name: { value: input, locale: 'ru-RU' },\n };\n}\n\nfunction readForNewClient(record: MigratedRecord): string {\n return record.profile_name?.value ?? record.display_name;\n}\n\nfunction canRemoveLegacyField(state: {\n legacyReads: number;\n newReads: number;\n dataBackfillComplete: boolean;\n}): boolean {\n return state.legacyReads === 0\n && state.newReads > 0\n && state.dataBackfillComplete;\n}\nВ этом примере запись остаётся совместимой, пока существуют старые readers. Fallback защищает новую версию от неполной миграции данных, но не исправляет пустое или неверное значение. Функция удаления требует трёх наблюдаемых условий: старые чтения не встречаются, новая форма действительно читается, перенос данных завершён. В настоящей системе пороги и окно наблюдения задаёт владелец данных.
\n| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Старый клиент получает 500 после записи | Новая форма стала обязательной для старого reader | Сравнить payload старого клиента и схему ответа | Вернуть optional-поле и сохранить старую форму |
| Новый клиент видит пустое имя | Нет fallback или запись прошла только в старую форму | Проверить обе формы одной записи | Добавить fallback и двойную запись |
| После возврата маршрута данные не совпадают | Откатили traffic state, но не определили data state | Сопоставить версию reader с формой записи | Остановить переключение и назвать восстановление данных |
| Старая колонка остаётся востребованной | В системе есть старый consumer или кеш | Посчитать обращения по имени поля и версии клиента | Не удалять колонку; найти consumer |
| Повторная запись создаёт разные значения | Двойная запись неидемпотентна | Повторить request с тем же idempotency key | Сделать запись идемпотентной |
Возврат трафика меняет адрес или долю запросов. Он не возвращает уже записанные значения. Возврат данных меняет записи, журнал преобразований или источник чтения. Он не гарантирует, что запросы снова попадут в старый код. Эти операции имеют разные условия и риски.
\nНапример, новая версия записала profile_name, а старый reader читает display_name. Маршрут можно вернуть, только если старая форма сохраняется и содержит корректное значение. Если двойной записи не было, переключатель маршрута скрывает проблему до следующего чтения. Поэтому «можно откатить» — неполная формулировка. Нужно назвать объект отката, триггер и состояние после него.
Остановитесь, если описан только успешный путь: новая версия читает новую форму, а старый маршрут считается запасным. Здесь нет ответа, какие данные уже изменились, кто читает старую форму, что запускает возврат и как проверить его результат. Отсутствие ответа — причина не продолжать переход, а не повод подставить «откатить при ошибке».
\nconst migration = {\n trafficState: 'candidate-25-percent',\n dataState: 'dual-write',\n rollback: {\n trigger: '',\n trafficState: 'legacy-100-percent',\n dataState: '',\n },\n};\n\nconst safeToSwitch = Boolean(\n migration.rollback.trigger\n && migration.rollback.trafficState\n && migration.rollback.dataState\n);\n\nif (!safeToSwitch) {\n throw new Error('rollback state is incomplete');\n}\nЭтот код — учебная проверка структуры, а не механизм управления трафиком или базой. Он намеренно возвращает отрицательный путь. Пустой триггер и пустое состояние данных нельзя заменить общим словом «ошибка»: разные сбои требуют разных условий и действий.
\nСовместимая схема не решает проблему удаления данных, если старый и новый формат имеют разную семантику. Fallback может скрыть неполный перенос. Двойная запись может удвоить побочный эффект. Кеш может отдавать старую форму после изменения источника. Очередь может повторить сообщение. Для этих границ нужны идемпотентность, версия события, контроль источника и явное состояние обработки.
\nУчебные значения не описывают пропускную способность, ошибки или поведение конкретной среды. Нельзя по ним утверждать, что переход завершился, что откат безопасен или что данные согласованы. Такие утверждения требуют наблюдений из системы за определённое окно и с понятной выборкой.
\nПереход готов к следующему проценту трафика, когда одновременно выполнены четыре условия: старая и новая версии читают доступные формы; запись формирует согласованные формы из одного входа; названы отдельный триггер возврата трафика и отдельный способ восстановления данных; после возврата обе версии снова читают ожидаемое значение. Если хотя бы одно условие нельзя проверить по конкретной записи, запросу или счётчику, переход останавливают на текущей доле.
\nПосле окончания перехода удаляйте старую форму в отдельном изменении. Сначала зафиксируйте нулевое чтение старого поля за согласованное окно, затем отключите запись старой формы, после этого удалите consumer и только потом меняйте схему. Каждый шаг должен иметь обратимое предыдущее состояние или явно названную причину необратимости.
\n