2 lines
14 KiB
JSON
2 lines
14 KiB
JSON
{"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 && !oldReads && !newReads) {\n return { phase: 'unsafe', reason: 'new-writer-has-no-compatible-reader' };\n }\n if (newWrites && !oldWrites) {\n return { phase: 'expand', reason: 'new-write-path-can-be-added-with-old-readers' };\n }\n if (newReads && oldReads && newWrites) {\n return { phase: 'switch', reason: 'both-readers-and-writers-understand-format' };\n }\n if (oldReads && !newReads && !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>"}
|