2 lines
20 KiB
JSON
2 lines
20 KiB
JSON
{"index":6,"slug":"editorial-2027-11-practice-mistakes-revisions","title":"Миграция схемы БД без простоя: совместимые фазы expand, switch, contract","excerpt":"Как изменить схему при работающем старом и новом коде: сначала добавить совместимую форму, затем заполнить и проверить данные, переключить чтение и только после этого удалить старую колонку.","contentHtml":"<p>Миграция падает не только на самом <code>ALTER TABLE</code>. Частый симптом выглядит так: новый релиз уже пишет в <code>display_name</code>, а экземпляр старого кода ещё читает <code>first_name</code> и <code>last_name</code>. Другой вариант — DDL ждёт блокировку, пока пользовательские запросы продолжают работать. В обоих случаях одна команда пытается поменять контракт сразу для базы, writer и reader.</p><p>Цена ошибки — ошибки чтения, потерянное значение имени или очередь запросов. Откат бинарника не возвращает данные, которые новый writer уже записал только в новую форму, а обратный DDL не восстановит информацию после необратимого преобразования. Главный вопрос миграции поэтому такой: как сделать каждый промежуточный шаг совместимым с уже работающими потребителями?</p><h2>Совместимость — это свойство перехода</h2><p>Под <em>reader</em> будем понимать любой код, который читает строку, а под <em>writer</em> — код, который её создаёт или изменяет. Переход совместим, если после каждого шага любой ещё работающий reader может прочитать запись, созданную любым writer. Это правило относится и к фоновым задачам, отчётам, админке, скриптам и другим сервисам, а не только к основному HTTP-приложению.</p><p>Схема <code>old -> new</code> не выкатывается одной миграцией. Сначала расширяем контракт, потом некоторое время обслуживаем две формы, затем переключаем чтение и лишь в конце сужаем контракт. Именно такой смысл у паттерна expand and contract в документации GitLab: expand сохраняет обратную совместимость, migrate переводит потребителей, contract удаляет совместимость.</p><figure><img src='/assets/editorial/2027/mistakes-revisions-2027-advice-timeline.svg' alt='Временная линия миграции: expand добавляет новую форму, dual write заполняет обе, switch переводит чтение, contract удаляет старую' loading='lazy' /><figcaption>Удаление старой формы — последняя точка, а не часть первого выката.</figcaption></figure><h2>Пример: составное имя становится одной колонкой</h2><p>Пусть в таблице <code>users</code> уже есть nullable-колонки <code>first_name</code> и <code>last_name</code>. Новому экрану нужен <code>display_name</code>. Правило форматирования в примере учебное: соединить непустые части одним пробелом. В реальном домене нужно отдельно решить порядок имени, локаль, пробелы, пустые строки и допустимое отсутствие значения.</p><div class='table-scroll'><table><caption>Что разрешено на каждой фазе</caption><thead><tr><th scope='col'>Фаза</th><th scope='col'>Reader</th><th scope='col'>Writer</th><th scope='col'>Действие</th><th scope='col'>Выход из фазы</th></tr></thead><tbody><tr><td>Expand</td><td>Старая форма</td><td>Старая форма</td><td>Добавить nullable <code>display_name</code> без требования для старого кода</td><td>Старый binary читает и пишет как прежде</td></tr><tr><td>Dual write</td><td>Новая форма с fallback</td><td>Обе формы в одной операции</td><td>Обновлять колонку при каждой записи и заполнить старые строки</td><td>Проверка расхождений и готовности readers</td></tr><tr><td>Switch</td><td>Новая форма, fallback виден</td><td>Обе формы</td><td>Перевести основной путь чтения и наблюдать ошибки</td><td>Ни один потребитель не использует fallback</td></tr><tr><td>Contract</td><td>Новая форма</td><td>Новая форма</td><td>Убрать fallback, затем удалить старые колонки отдельными шагами</td><td>Есть подтверждённый план отката или принято решение жить без него</td></tr></tbody></table></div><p>Fallback в этой таблице — не молчаливый костыль. Он должен быть измеримым: например, код увеличивает счётчик чтений старой формы и пишет идентификатор потребителя. Если fallback не виден, команда не может доказать, что contract безопасен.</p><h2>Expand: сначала меняем схему</h2><p>Для PostgreSQL 16 минимальный первый шаг может выглядеть так:</p><pre><code>BEGIN;\nSET LOCAL lock_timeout = '1s';\nALTER TABLE users ADD COLUMN display_name text;\nCOMMIT;</code></pre><p><code>1s</code> здесь — проектный пример, а не универсальное значение. При тайм-ауте транзакция должна завершиться с ошибкой, а миграционный runner — оставить понятный результат для повторного запуска. Документация PostgreSQL указывает, что требуемый lock level зависит от формы <code>ALTER TABLE</code>, а без специальной оговорки берётся <code>ACCESS EXCLUSIVE</code>. Поэтому nullable-колонка без backfill всё равно может ждать уже занятую блокировку.</p><p>После expand старый binary не должен требовать новую колонку. На этом шаге не добавляйте без проверки <code>NOT NULL</code>, тяжёлый default, изменение типа и удаление старых полей в ту же операцию. У этих действий другая стоимость и другой риск. Сначала отдельно подтвердите, что схема появилась, старые запросы всё ещё проходят, а повторный запуск миграции обрабатывается вашей системой миграций.</p><h2>Dual write и backfill: две формы, одно правило</h2><p>Новый writer должен в одной логической операции сохранить обе формы. Простейшая чистая функция показывает контракт, но не притворяется ORM или транзакцией:</p><pre><code>function formatDisplayName(firstName, lastName) {\n return [firstName, lastName]\n .filter((part) => part !== null && part !== undefined && part !== '')\n .join(' ');\n}\n\nfunction readDisplayName(row) {\n if (row.display_name !== null && row.display_name !== undefined) {\n return row.display_name;\n }\n return formatDisplayName(row.first_name, row.last_name);\n}\n\nfunction writeUser(input) {\n return {\n ...input,\n display_name: formatDisplayName(input.first_name, input.last_name),\n };\n}\n\nconsole.assert(\n readDisplayName({ display_name: null, first_name: 'Ada', last_name: 'Lovelace' }) ===\n 'Ada Lovelace',\n);\nconsole.assert(\n writeUser({ first_name: 'Ada', last_name: 'Lovelace' }).display_name ===\n 'Ada Lovelace',\n);</code></pre><p>В приложении результат <code>writeUser</code> нужно записать атомарно с изменением исходных полей. Если ORM выполняет два независимых запроса, ошибка между ними создаёт ещё один промежуточный формат. Поэтому проверяйте не только функцию, но и границу транзакции, обработку повторной попытки и поведение при конфликте.</p><p>Старые строки заполняются отдельным backfill. Идемпотентная пачка для PostgreSQL 16 может быть такой:</p><pre><code>UPDATE users\nSET display_name = trim(concat_ws(' ', first_name, last_name))\nWHERE id > $1\n AND id <= $2\n AND display_name IS NULL\n AND (first_name IS NOT NULL OR last_name IS NOT NULL);</code></pre><p>Параметры <code>$1</code> и <code>$2</code>, размер пачки и пауза между пачками зависят от runner и нагрузки. Условие <code>display_name IS NULL</code> защищает уже заполненную строку от повторной записи, но не решает доменное различие между «пусто» и «ещё не обработано». Если исходные поля меняются во время backfill, задайте конфликтное правило: общий транзакционный writer, версия строки или повторная сверка.</p><h2>Switch: переводим чтение после проверки данных</h2><p>Перед switch сравните старую и новую формы, а не просто посчитайте обработанные строки. Для примера полезно разделить строки без исходных данных и строки с расхождением:</p><pre><code>SELECT\n count(*) FILTER (\n WHERE display_name IS NULL\n AND (first_name IS NOT NULL OR last_name IS NOT NULL)\n ) AS missing_display_name,\n count(*) FILTER (\n WHERE display_name IS NOT NULL\n AND display_name <> trim(concat_ws(' ', first_name, last_name))\n ) AS different_display_name\nFROM users;</code></pre><p>Нулевой результат этих двух счётчиков ещё не доказывает готовность всей системы. Нужно проверить потребителей: старый binary, новый binary, фоновые workers, экспорт, SQL-запросы и кэш. Для каждой записи в журнале изменения должно быть понятно, кто её пишет и какая форма будет прочитана после переключения.</p><p>Переводите reader отдельно от writer. Сначала новый reader использует <code>display_name</code> и считает fallback. Затем включайте новый путь постепенно, если такая возможность есть. Наблюдайте ошибки чтения, задержку, lock wait, отставание реплик и количество fallback. При росте ошибки верните reader на совместимый fallback, но оставьте dual write: откат чтения не должен остановить поддержание обеих форм.</p><h2>Contract: удаляем только доказанно ненужное</h2><p>После switch старые колонки ещё нужны для rollback и для забытых потребителей. Удаляйте их минимум двумя отдельными изменениями: сначала код и fallback, затем схема. Для старых данных полезно иметь явный сигнал готовности, например отсутствие fallback за согласованное окно наблюдения и успешные проверки всех потребителей. Само по себе отсутствие ошибок в основном endpoint не является таким сигналом.</p><p>Индексы и ограничения требуют отдельного плана. PostgreSQL описывает <code>CREATE INDEX CONCURRENTLY</code> как способ не блокировать обычные вставки, обновления и удаления, но такая операция делает больше работы, ждёт другие транзакции и не выполняется внутри транзакционного блока. Она не превращает любой DDL в безопасный для production и не отменяет проверку диска, CPU, репликации и времени ожидания.</p><p>Если нужно добавить уникальность или <code>NOT NULL</code>, разделите проверку существующих данных и изменение контракта. Например, constraint можно сначала добавить как <code>NOT VALID</code>, затем проверить и валидировать отдельной операцией, если конкретный тип ограничения и версия PostgreSQL это поддерживают. Не копируйте этот приём для каждого ограничения: синтаксис, блокировки и поведение нужно сверить с документацией вашей версии.</p><h2>Пошаговый runbook</h2><ol><li>Составьте список readers и writers, включая фоновые задачи и внешние SQL-доступы. Зафиксируйте старую форму, новую форму, владельца каждого потребителя и совместимый путь чтения.</li><li>Опишите доменное преобразование. Для имени зафиксируйте правила пустых значений, пробелов, локали, длины и редких случаев. Учебный <code>concat_ws</code> не заменяет это решение.</li><li>Проверьте expand на копии production-данных или на среде с сопоставимым объёмом. Запишите lock wait, план остановки, лимит ожидания и способ повторного запуска.</li><li>Выпустите только расширение схемы. Проверка выхода: старый binary читает и пишет старые поля, новая колонка не обязательна, миграция повторяется безопасно.</li><li>Включите dual write и fallback. Проверьте атомарность записи, повторную попытку и метрику fallback. Не запускайте backfill, пока writer не защищает новые изменения.</li><li>Запускайте backfill ограниченными идемпотентными пачками. После каждой пачки сохраняйте диапазон, количество обновлений и ошибку; при остановке продолжайте с последнего подтверждённого диапазона.</li><li>Сверьте значения и конфликтные строки. Ненулевые расхождения — причина остановить switch, а не повод подобрать фильтр, который скроет проблему.</li><li>Переведите чтение на новую форму, оставив fallback. Наблюдайте ошибки, задержку, реплики и долю fallback; при деградации верните только чтение, сохранив dual write.</li><li>После подтверждённого ухода старых потребителей уберите fallback и старые поля отдельным выпуском. Перед удалением проверьте, что план rollback описывает уже созданные новые записи, а не только возврат версии приложения.</li></ol><h2>Ограничения и критерий готовности</h2><p>Expand/switch/contract не гарантирует нулевую блокировку и не заменяет резервное копирование, репликацию и проверку восстановления. На результат влияют major-версия PostgreSQL, размер и партиционирование таблицы, индексы, триггеры, ORM, внешние readers, длительные транзакции, политика lock timeout и способ доставки релиза. Для MySQL, другой СУБД или другой major-версии PostgreSQL нельзя механически переносить lock behavior из этого примера.</p><p>К contract переходите только когда новая форма заполнена и сверена, каждый известный writer поддерживает её, readers больше не обращаются к старым полям, fallback не нужен по наблюдаемому сигналу, а команда понимает необратимые последствия удаления. Если один пункт нельзя доказать запросом, метрикой, логом или тестом, остановитесь на dual write и продолжите сбор сведений.</p><p>На практике полезно начать с маленькой таблицы или копии данных: выполнить expand, остановить backfill, вернуть старый binary и проверить чтение записи, созданной новым writer. Такой сценарий показывает реальный путь отката лучше, чем зелёный финальный smoke test.</p><h2>Проверяемые источники</h2><ul><li><a href='https://docs.gitlab.com/development/multi_version_compatibility/' target='_blank' rel='noopener noreferrer'>GitLab Docs: Backwards compatibility across updates</a> — официальное описание expand, migrate и contract для сосуществования версий.</li><li><a href='https://www.postgresql.org/docs/16/sql-altertable.html' target='_blank' rel='noopener noreferrer'>PostgreSQL 16 Documentation: ALTER TABLE</a> — формы изменения таблиц, различия lock level, валидация ограничений и условия для <code>NOT VALID</code>.</li><li><a href='https://www.postgresql.org/docs/16/sql-createindex.html' target='_blank' rel='noopener noreferrer'>PostgreSQL 16 Documentation: CREATE INDEX</a> — ограничения, стоимость и транзакционное поведение <code>CONCURRENTLY</code>.</li></ul>"}
|