Files
progcode/editorial/agent-rewrites/006.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

2 lines
14 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":6,"slug":"editorial-2027-11-practice-mistakes-revisions","title":"Миграция схемы БД без простоя: совместимые фазы expand, switch, contract","excerpt":"Как изменить схему при работающем старом и новом коде: проверить совместимость чтения и записи, пережить backfill и удалить старую форму только после явного сигнала.","contentHtml":"<p>Команда <code>ALTER TABLE</code> проходит на пустой базе, но на большой таблице может ждать блокировку и задержать пользовательские запросы. Другой симптом появляется после выката: новый writer сохраняет только новую форму данных, а ещё работающий старый reader ищет старую колонку и получает ошибку или неполную запись.</p><p>Цена ошибки — очередь запросов, простой части сервиса и откат приложения, который уже не возвращает совместимость со схемой. Откат кода не отменяет записи, сделанные новым writer, а обратное изменение типа или удаление данных может оказаться необратимым. Поэтому миграцию проектируют как последовательность совместимых состояний, а не как один SQL-файл.</p><h2>Тезис: сначала совместимость, потом переключение</h2><p>Безопасный порядок такой: добавить новую форму данных, научить код работать со старой и новой формами, переключить чтение, проверить потребителей и только затем удалить старую форму. Старый и новый binary некоторое время живут одновременно, экземпляры обновляются не синхронно, а миграция может остановиться между фазами.</p><p>Рассмотрим замену вычисляемого имени из <code>first_name</code> и <code>last_name</code> на колонку <code>display_name</code>. Сначала новая колонка должна быть совместима со старым кодом: она nullable или имеет безопасное значение по правилам домена. Пока старый reader ещё работает, новый writer не может отказаться от старых полей без fallback.</p><h2>Механизм: матрица reader и writer</h2><p>Перед DDL выпишите четыре возможности: умеет ли старый reader читать новую форму, умеет ли новый reader читать её, пишет ли старый writer старую форму и пишет ли новый writer обе формы. Из этой матрицы видно, на какой фазе находится система и где возникнет несовместимость.</p><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><th scope=\"col\">Контроль</th></tr></thead><tbody><tr><td>Expand</td><td>старая форма</td><td>старая форма</td><td>добавить nullable-колонку или совместимый индекс</td><td>старый binary продолжает работать</td></tr><tr><td>Dual write</td><td>старая форма, новая с fallback</td><td>обе формы</td><td>заполнять новую форму пачками или при записи</td><td>сверять значения и ошибки записи</td></tr><tr><td>Switch</td><td>новая форма с fallback</td><td>обе формы</td><td>перевести reader после проверки данных</td><td>наблюдать долю чтения fallback</td></tr><tr><td>Contract</td><td>новая форма</td><td>новая форма</td><td>удалить старую форму отдельным изменением</td><td>есть сигнал, что старые потребители ушли</td></tr><tr><td>Rollback</td><td>старая или fallback</td><td>совместимая запись</td><td>вернуть binary без потери данных</td><td>путь отката проверен до switch</td></tr></tbody></table></div><p>Новый writer без совместимого reader — небезопасное состояние. Двойная запись решает только доставку данных в две формы; она не доказывает, что значения одинаковы, что backfill не перезапишет более свежую запись и что все потребители готовы к switch.</p><h2>DDL — операция с ресурсом</h2><p>Изменение таблицы зависит от блокировок, объёма работы и конкретной версии PostgreSQL. В review смотрите на lock mode, время ожидания, границы транзакции, индексы, триггеры, репликацию и план восстановления. Добавление колонки, создание индекса, backfill и изменение типа имеют разную стоимость. Объединять их в одну «маленькую миграцию» нельзя без проверки.</p><p>Backfill — отдельная нагрузка, а не деталь миграции схемы. Большой <code>UPDATE</code> конкурирует с пользовательскими запросами и может увеличить WAL. Идемпотентные ограниченные пачки позволяют остановить работу и продолжить её позже. Размер пачки, пауза и условие обновления зависят от вашей нагрузки; пример ниже не задаёт универсальные значения.</p><figure><img src=\"/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg\" alt=\"Последовательность миграции схемы: expand, двойная запись, switch и contract разделены контрольными точками\" loading=\"lazy\" /><figcaption>Старая и новая формы сосуществуют до тех пор, пока проверяемый сигнал не разрешит удалить старую.</figcaption></figure><h2>Минимальный рабочий пример</h2><p>Небольшая функция формализует главный запрет: нельзя включать новую запись, если нет reader, который понимает новую форму. Это учебная проверка совместимости; она не подключается к базе, не запускает DDL и не заменяет проверку конкретного кластера.</p><pre><code>function classifyMigrationStep({ oldReads, newReads, oldWrites, newWrites }) {\n if (newWrites &amp;&amp; !oldReads &amp;&amp; !newReads) {\n return { phase: 'unsafe', reason: 'new-writer-has-no-compatible-reader' };\n }\n if (newWrites &amp;&amp; !oldWrites) {\n return { phase: 'expand', reason: 'new-write-path-can-be-added-with-old-readers' };\n }\n if (newReads &amp;&amp; oldReads &amp;&amp; newWrites) {\n return { phase: 'switch', reason: 'both-readers-and-writers-understand-format' };\n }\n if (oldReads &amp;&amp; !newReads &amp;&amp; !newWrites) {\n return { phase: 'contract', reason: 'remove-format-only-after-consumers-move' };\n }\n return { phase: 'inspect', reason: 'compatibility-matrix-is-incomplete' };\n}\n\nconsole.log(classifyMigrationStep({\n oldReads: false,\n newReads: false,\n oldWrites: false,\n newWrites: true,\n}).phase);\n// unsafe</code></pre><p>В настоящей миграции вместо boolean-признаков нужны конкретные версии приложения, формы записи и список потребителей. Проверка должна отвечать на вопрос «кто прочитает запись после этого шага?», а не только на вопрос «принял ли SQL сервер?».</p><h2>Порядок выполнения</h2><ol><li>Опишите старую и новую формы данных. Укажите каждый reader и writer, версию binary и допустимый fallback.</li><li>Проверьте DDL на блокировки, размер таблицы, индексы, транзакцию, репликацию и план восстановления. Для production-объёма используйте среду с похожими данными, если это возможно в вашей процедуре.</li><li>Добавьте новую форму без требования, которое сломает старый binary. Сначала проверьте, что старый код продолжает читать и писать прежнюю форму.</li><li>Включите двойную запись или backfill идемпотентными пачками. Сверяйте количество обработанных строк, контрольные значения и случаи, когда более свежая запись уже существует.</li><li>Переведите чтение на новую форму с fallback. Наблюдайте ошибки, latency, lock wait и долю чтения старой формы. Fallback должен быть виден, иначе нельзя понять, ушли ли старые потребители.</li><li>Удалите fallback и старую форму отдельным изменением после окна наблюдения. Сохраните понятный сигнал, что старый reader больше не обращается к колонке и rollback-путь больше не требуется.</li></ol><h2>Почему rollback не равен обратной миграции</h2><p>Откат приложения возвращает код, но не обязательно возвращает схему. Если новый writer заполняет только <code>display_name</code>, старый reader без fallback может увидеть пустое значение. Если преобразование типа потеряло информацию, обратный DDL не восстановит её. Поэтому старый reader должен оставаться совместимым с данными, которые создал новый writer, а путь отката нужно определить до switch.</p><p>Проверяйте промежуточные состояния: сразу после expand, во время частичной двойной записи, после остановки backfill и после переключения только чтения. В каждом состоянии остановка приложения или миграции должна иметь понятное продолжение. Финальный smoke test не проверяет совместимость всех этих переходов.</p><h2>Ограничения и критерий готовности</h2><p>Эта схема не выбирает lock mode, размер пачки, стратегию индексации или настройки WAL для вашего кластера. На результат влияют версия PostgreSQL, расширения, ORM, триггеры, партиционирование, репликация, размер таблицы и политика блокировок. Документация описывает свойства операций, но не разрешает выполнять их без проверки вашей нагрузки.</p><p>Миграция готова к contract, когда новая форма заполнена и сверена, новый reader работает без скрытой зависимости от старой формы, старый binary больше не является потребителем, а rollback-путь проверен на промежуточном состоянии. Если хотя бы один пункт нельзя доказать наблюдением или проверкой, оставьте старую форму и продолжите расследование.</p><h2>Проверяемые источники</h2><ul><li><a href=\"https://www.postgresql.org/docs/16/ddl-alter.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 16 Documentation — Modifying Tables</a> — операции изменения таблиц могут зависеть от блокировок и объёма работы. Источник терминов, а не инструкция для конкретного кластера.</li><li><a href=\"https://www.rfc-editor.org/rfc/rfc9110.html\" target=\"_blank\" rel=\"noopener noreferrer\">RFC 9110 — HTTP Semantics</a> — семантика методов, статусов и условных запросов важна для совместимого API вокруг миграции. Документ не описывает схему базы, ORM или порядок выката приложения.</li></ul>"}