Files
progcode/editorial/agent-rewrites/133.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
17 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 133,
"slug": "editorial-2024-04-field-data-migrations",
"title": "Миграция данных без ловушки: совместимость, backfill и безопасный contract",
"excerpt": "Новая форма данных не становится безопасной от одного успешного deploy. Разбираем expand/migrate/contract, ограниченный backfill и признаки, по которым нужно остановить удаление старого представления.",
"contentHtml": "<p>Симптом обычно появляется после deploy: новая версия сервиса читает новое поле, а часть записей всё ещё хранит старую форму. Затем backfill начинает нагружать базу, а старый worker продолжает писать только старое представление. В логах растёт доля fallback-чтений, обработка очереди замедляется, а команда уже обсуждает удаление старой колонки. Цена ошибки — не только откат релиза. Код можно вернуть, но уже записанные данные не обязаны вернуться в прежнюю форму. Пользователь увидит пустое значение, а восстановление потребует отдельного data repair.</p>\n<p>Тезис простой: миграция данных — это не одна команда DDL и не один зелёный deploy. Сначала нужно сохранить совместимость версий, затем ограниченно перенести данные, после этого доказать готовность нового чтения и только в конце удалить старую форму. Каждый переход должен иметь собственную проверку. Если хотя бы один потребитель неизвестен, старое представление остаётся.</p>\n<h2>Механизм: expand, migrate, switch, contract</h2>\n<p>В старой системе заказ хранится в полях <code>status</code> и <code>amount</code>. Новая версия хочет хранить объект <code>summary</code>. На первом шаге схема получает новую форму, но старый writer не должен ломаться. Новый reader принимает обе формы. Новый writer временно записывает обе. Это expand.</p>\n<p>На втором шаге backfill обрабатывает старые записи. Он не должен проходить по таблице без границы. Нужны область работы, размер порции, владелец, идемпотентность и заранее определённый сигнал остановки. Это migrate. Важен не сам факт запуска job, а понятный результат частичного выполнения: какие записи обработаны и что произойдёт после остановки.</p>\n<p>Затем система переключает чтение на новую форму. Fallback к старой форме ещё нужен, пока не проверены старые записи, отложенные worker-ы и все читатели. Успешное чтение новой записи не доказывает, что старых потребителей больше нет. Switch опирается на наблюдаемые данные, а не на дату релиза.</p>\n<p>Contract — отдельное решение. Старую форму можно удалить только после подтверждения, что старый reader и writer больше не участвуют, backfill завершён с понятным критерием, а восстановление не зависит от удаляемых данных. Если условие не доказано, contract откладывают. Это отрицательный путь, а не неполная миграция.</p>\n<h2>Учебный пример совместимости</h2>\n<p>Ниже — ограниченный пример на JavaScript. Он проверяет только заявленные версии и формы. Функция не обращается к базе, не запускает SQL и не измеряет нагрузку. Поэтому результат <code>stop</code> означает «не переходить к следующему этапу в этом сценарии», а не verdict для production.</p>\n<pre><code>const contract = {\n oldReader: true,\n oldWriter: true,\n newReaderAcceptsOld: true,\n newReaderAcceptsNew: true,\n newWriterWritesBoth: true,\n backfillHasStop: false,\n oldConsumersFound: true,\n};\n\nfunction decideMigration(state) {\n const compatible =\n state.oldReader &&\n state.oldWriter &&\n state.newReaderAcceptsOld &&\n state.newReaderAcceptsNew &&\n state.newWriterWritesBoth;\n\n if (!compatible) {\n return { phase: 'expand', action: 'stop', reason: 'version mismatch' };\n }\n\n if (!state.backfillHasStop) {\n return { phase: 'migrate', action: 'stop', reason: 'unbounded backfill' };\n }\n\n if (state.oldConsumersFound) {\n return { phase: 'contract', action: 'stop', reason: 'old consumer remains' };\n }\n\n return { phase: 'contract', action: 'review', reason: 'evidence required' };\n}\n\nconsole.log(decideMigration(contract));\n// { phase: 'migrate', action: 'stop', reason: 'unbounded backfill' }</code></pre>\n<p>Код защищает порядок рассуждения. Он сначала проверяет совместимость, потом наличие stop condition, затем старых потребителей. В production эти признаки получают из реестра версий, логов, метрик, запросов к данным и согласованного runbook. Нельзя заменить их булевыми значениями из фикстуры. Учебный результат ограничен демонстрацией ветвления.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика перехода между формами данных</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Новая форма есть только у части записей</td><td>Backfill ещё не закончен или новые записи обходят dual write</td><td>Сравнить доли old/new по времени записи и источнику</td><td>Оставить fallback, остановить contract, исправить writer</td></tr><tr><td>Backfill замедляет рабочие запросы</td><td>Широкий scope, слишком большая порция или конкурирующая нагрузка</td><td>Проверить latency, lock wait, размер batch и границу выборки</td><td>Остановить job, сузить scope и определить лимит до нового запуска</td></tr><tr><td>Старая версия получает ошибку записи</td><td>Schema constraint введён раньше совместимого writer</td><td>Воспроизвести запись v1 на тестовой копии и проверить порядок deploy</td><td>Вернуть совместимое расширение, не маскировать ошибку retry</td></tr><tr><td>После deploy растёт fallback</td><td>Reader видит old data или новый writer не заполнил поле</td><td>Разделить fallback по версии, endpoint и типу записи</td><td>Сохранить старую ветку и найти источник несовместимых записей</td></tr><tr><td>Все тесты зелёные, но consumer неизвестен</td><td>Тест проверяет сценарий, а не весь fleet</td><td>Сверить владельцев, worker-ы, cron, batch и старые clients</td><td>Не удалять старую форму до найденного доказательства</td></tr><tr><td>Нужен срочный rollback после очистки</td><td>Удаление данных ошибочно назвали обратимым</td><td>Проверить backup, retention и возможность read-back старой формы</td><td>Перейти к data repair или restore-плану, не обещать обычный rollback</td></tr></tbody></table></div>\n<h2>Иллюстрация перехода</h2>\n<figure><img src=\"/assets/editorial/2024/data-migrations-2024-rehearsal-gate.svg\" alt=\"Переход миграции данных через совместимость версий, ограниченный backfill, stop gate и отдельное решение о contract\" loading=\"lazy\" /><figcaption>Существующая схема показывает контрольные точки миграции. Gate не запускает операцию и не подтверждает production-готовность: он фиксирует вопросы, на которые должны ответить реальные данные и владельцы системы.</figcaption></figure>\n<p>Иллюстрация полезна именно как граница ответственности. Совместимость версий проверяет контракт приложения. Backfill проверяет состояние данных и нагрузку. Contract проверяет отсутствие зависимости от старой формы. Ни один этап не доказывает остальные.</p>\n<h2>Порядок действий</h2>\n<ol><li>Опишите old reader, old writer, new reader и new writer. Для каждой пары запишите, какую форму она читает и пишет.</li><li>Сделайте expand совместимым: добавьте новую форму так, чтобы допустимый старый writer не получил отказ. Отдельно проверьте constraints, default, trigger и порядок deploy для вашей СУБД.</li><li>Включите dual write только там, где можно определить поведение при частичной ошибке. Если две записи не входят в одну транзакционную границу, опишите reconciliation.</li><li>Задайте backfill scope, batch boundary, owner, повторный запуск и stop signal. Перед стартом назовите состояние данных после остановки.</li><li>Запустите ограниченную проверку на разрешённой среде. Сравните old и new representation, ошибки, пропуски, время обработки и влияние на рабочий трафик.</li><li>Переключайте чтение по evidence. Оставьте fallback и сигнализируйте его использование, пока старые записи и потребители не проверены.</li><li>Отдельно подтвердите отсутствие old consumer. Проверьте код, расписания, очереди, фоновые задачи, batch-процессы и внешние клиенты.</li><li>Составьте recovery boundary. Укажите, что возвращает deploy, что восстанавливается из данных и в какой момент нужен restore или repair.</li><li>Удаляйте старую форму последней операцией. Если один критерий не выполнен, остановитесь на migrate или switch и зафиксируйте причину.</li></ol>\n<h2>Ограничения и отрицательный путь</h2>\n<p>Expand/contract не делает миграцию беспростойной. Dual write может дать расхождение, если запись в одну систему прошла, а в другую нет. Backfill может конкурировать с индексами, блокировками и репликацией. Внешний клиент может использовать старое поле без регистрации. ORM может добавить собственный cache или изменить порядок чтения. Эти случаи требуют проверки конкретной системы.</p>\n<p>Не переносите синтаксис PostgreSQL на другую СУБД. Даже в PostgreSQL команда, которая добавила constraint, не равна доказательству, что все старые строки уже проверены. Не считайте зелёный тест доказательством надёжности. Не увеличивайте batch, если неизвестна причина нагрузки. Не запускайте contract после одного удачного прогона. Если обнаружили несовместимость, правильное действие — остановить переход и сохранить старую форму.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Миграция готова к contract только тогда, когда одновременно выполнены пять условий: все допустимые версии читают нужную форму; writer-ы не создают неподдерживаемые записи; backfill имеет завершённый scope и повторяемый результат; использование old representation и fallback равно нулю в согласованном окне наблюдения; recovery-план проверен для оставшейся границы риска. Число и длительность окна должны определить владельцы системы по своим SLO и traffic profile. В этой статье они не выдумываются.</p>\n<p>Если хотя бы одно условие нельзя подтвердить, критерий не выполнен. Это не повод скрыть расхождение за словом «почти». Оставьте старую форму, остановите удаление и соберите недостающее evidence. Такой отказ дешевле восстановления данных после необратимого contract.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://stripe.com/blog/online-migrations\" target=\"_blank\" rel=\"noopener noreferrer\">Stripe Engineering: Online migrations at scale</a> — официальный разбор четырёхфазного перехода с dual write, проверкой согласованности и последующим удалением старого представления. Это опыт Stripe, а не гарантия для другой инфраструктуры.</li><li><a href=\"https://www.postgresql.org/docs/current/sql-altertable.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL Documentation: ALTER TABLE</a> — официальная документация о свойствах изменения таблиц, включая <code>NOT VALID</code> и <code>VALIDATE CONSTRAINT</code>. Детали относятся к PostgreSQL и требуют проверки версии и конфигурации.</li><li><a href=\"https://sre.google/sre-book/testing-reliability/\" target=\"_blank\" rel=\"noopener noreferrer\">Google SRE Book: Testing for Reliability</a> — официальный материал о том, почему проход тестов не доказывает надёжность всей системы и почему проверка должна быть связана с наблюдаемым поведением.</li></ul>"
}