8 мин

Изменения схемы без простоя с паттерном expand/contract

Планируйте и выпускайте изменения схемы без простоя с паттерном expand/contract, безопасным бэкфиллом, совместимыми релизами, проверкой и откатом.

Изменения схемы без простоя с паттерном expand/contract

Почему изменения схемы вызывают сбои

Изменения схемы вызывают сбои, когда версии приложения, фоновые воркеры и база данных перестают согласованно определять допустимые структуры и значения. Сбой бывает очевидным, например когда каждый запрос возвращает ошибку, или постепенным, например когда растёт задержка запросов, не проходят записи, отстают реплики и накапливаются задачи, которые нужно повторно выполнить.

При развёртывании в продакшен редко все процессы меняются одновременно. При поэтапных релизах старые и новые экземпляры приложения работают вместе. Долгоживущие воркеры могут часами использовать старую сборку, мобильные клиенты остаются активными месяцами, а отчёты и интеграционные задачи могут обращаться к таблицам в обход основного приложения. Все они используют одну базу данных.

Частые причины сбоев:

  • Новый код записывает значение в столбец до завершения миграции, которая его создаёт.
  • Старый код читает таблицу или столбец, который более поздний релиз переименовал или удалил.
  • Перестроение таблицы, бэкфилл или создание индекса потребляют столько I/O и CPU, что замедляют обычный трафик.
  • Команда схемы ждёт блокировку, пока запросы выстраиваются за ней в очередь.
  • Новое ограничение отклоняет записи процесса, который ещё не обновился.

Чаще всего опасно не само время выполнения, а получение блокировки. Быстрый ALTER TABLE может ждать завершения долгой транзакции. Пока он ждёт, последующие запросы могут встать в очередь за ожидающей блокировкой схемы, и небольшая миграция остановит всё приложение.

Для работы без простоя каждое промежуточное состояние базы данных должно быть пригодно для любой версии приложения, которая ещё может работать. Сначала добавляйте совместимые структуры, затем контролируемо переводите трафик и данные, а старый путь удаляйте только после того, как исчезнет его последний потребитель.

Такой подход оправдан в системах с живым трафиком, поэтапными развёртываниями, строгими требованиями к доступности или дорогим восстановлением. Для небольшого внутреннего инструмента с тихой базой данных лучше подойдёт проверенное окно обслуживания. Решение должно учитывать цену сбоя и сложность миграции.

Expand/contract простыми словами

Паттерн expand/contract превращает одно несовместимое изменение в последовательность совместимых релизов. База данных временно поддерживает два представления, пока код и данные переходят со старого на новое.

Последовательность состоит из трёх частей:

  • Расширение: добавьте столбцы, таблицы, индексы или ограничения, не удаляя ничего, что нужно текущему коду.
  • Переход: разверните совместимый код, перенесите исторические данные и направьте чтение и запись на новое представление.
  • Сокращение: удалите старый код и объекты базы данных после того, как проверка подтвердит, что они больше не используются.

Предположим, в таблице PostgreSQL имя человека хранится в full_name, а приложению нужны отдельные поля first_name и last_name. На этапе расширения добавляются nullable-столбцы, а full_name сохраняется. Совместимый релиз записывает представления, нужные во время перехода. Бэкфилл разбирает существующие значения, заранее определяя правила для имён, которые нельзя надёжно разделить. Чтение переключают только после достаточного заполнения новых полей. Позже на этапе сокращения удаляют full_name.

Этот порядок подходит для поэтапных развёртываний, потому что старая сборка по-прежнему находит full_name, а новая видит все три столбца. Он также сохраняет возможность отката приложения. Если новый релиз работает неправильно, можно запустить предыдущую сборку, поскольку её зависимости от схемы ещё не удалены.

Откат базы данных отличается от отката приложения. Обратная миграция после преобразования данных может потерять информацию или восстановить устаревшее значение. Во время перехода лучше вернуть трафик приложения к проверенному представлению, оставив добавленные объекты базы данных на месте. Когда инцидент стабилизирован, исправьте прямую миграцию.

Паттерн не означает, что для каждого изменения нужна двойная запись. Для добавления необязательного столбца, который использует только новый код, может хватить одной добавочной миграции и одного развёртывания. Переименования, изменения представления, разделение таблиц и изменения обязательных полей обычно требуют больше фаз, поскольку иначе две версии приложения не смогут безопасно использовать одну схему.

Классифицируйте изменение до выбора шагов

План миграции должен соответствовать реальным рискам операции: блокировкам, перестроению, совместимости и преобразованию данных. Если считать все ALTER TABLE одинаковыми, релиз либо окажется перегруженным лишними процедурами, либо небезопасным.

Добавочные изменения обычно проще всего. Nullable-столбец, отдельную таблицу или индекс, созданный онлайн-методом, часто можно добавить до того, как код приложения начнёт ими пользоваться. Команде всё равно нужна блокировка, поэтому проверьте её поведение на таблице и транзакционной нагрузке, похожих на продакшен.

К разрушительным изменениям относятся удаление или переименование столбцов, сужение типов, замена таблиц и добавление более строгих ограничений. Они нарушают предположения существующего кода. Перенесите их на этап сокращения, когда ссылки в коде и внешние потребители уже удалены.

Операции, меняющие данные, требуют отдельной оценки. Преобразование временных меток, нормализация номеров телефонов, объединение записей или разбиение произвольного текста могут привести к потере информации. До запуска бэкфилла определите, как будут обрабатываться недопустимые и неоднозначные значения. Если преобразование нельзя обратить, сохраните исходные данные, пока результат не пройдёт проверки на уровне бизнеса.

Полезная предварительная проверка отвечает на пять вопросов:

  • Какую блокировку запрашивает каждая команда и как долго она может ждать или удерживать эту блокировку?
  • Перепишет ли операция таблицу, создаст ли большой объём WAL или увеличит отставание реплик?
  • Какие приложения, задачи, отчёты и потребители change data capture используют затронутые объекты?
  • Могут ли текущий и предлагаемый релизы работать с каждым переходным состоянием?
  • Какой сигнал остановит операцию и какое точное состояние останется после остановки?

Запускайте точную миграцию на данных с реалистичным объёмом и распределением. Тестовая таблица с тысячей аккуратных строк почти ничего не говорит о продакшен-таблице с сотнями миллионов строк, широкими кортежами, мёртвыми строками, перекошенными значениями и долгими транзакциями.

Безопасно расширяйте схему в PostgreSQL

Безопасное расширение PostgreSQL использует короткие изменения метаданных, ограниченное ожидание блокировок и отдельные онлайн-операции там, где этого требует база данных. Добавьте новую структуру до развёртывания кода, который от неё зависит.

Добавление nullable-столбца без значения по умолчанию обычно меняет только метаданные:

BEGIN;
SET LOCAL lock_timeout = '2s';
SET LOCAL statement_timeout = '30s';

ALTER TABLE customers
ADD COLUMN phone_e164 text;

COMMIT;

Тайм-аут не даёт релизу бесконечно ждать открытую транзакцию. Если быстро получить блокировку не удалось, пусть миграция завершится ошибкой, изучите блокирующую транзакцию и повторите попытку в более безопасный момент. Не повторяйте её автоматически в плотном цикле, так как постоянные запросы блокировки могут мешать продакшен-трафику.

Современные версии PostgreSQL могут добавить столбец с постоянным значением по умолчанию без немедленной записи этого значения в каждую существующую строку. Эта оптимизация не делает любое значение по умолчанию безопасным. Нестабильное выражение может потребовать перестроения, а ALTER TABLE всё равно на короткое время получает блокировку ACCESS EXCLUSIVE. Проверяйте поведение для используемой версии PostgreSQL и точного выражения, а не полагайтесь на общее правило.

Обычный CREATE INDEX может блокировать записи. Если таблица должна оставаться доступной для записи, создавайте индекс конкурентно:

CREATE INDEX CONCURRENTLY idx_customers_phone_e164
ON customers (phone_e164);

CREATE INDEX CONCURRENTLY нельзя запускать внутри блока транзакции. Операция выполняется дольше, делает дополнительную работу и может ждать старые транзакции, но обычные вставки, обновления и удаления продолжаются. Она всё равно потребляет CPU, I/O и WAL, поэтому следите за задержкой базы данных и репликами во время работы.

Неудачная конкурентная сборка может оставить недействительный индекс. Перед повторной попыткой проверьте состояние индекса, затем осознанно удалите или перестройте недействительный объект. Инструментам миграции, которые оборачивают каждый файл в транзакцию, нужен поддерживаемый нетранзакционный режим для конкурентных операций с индексами.

Новые таблицы часто проще вводить, чем выполнять преобразования на месте. Для отношения один-ко-многим или многие-ко-многим добавьте целевую таблицу и её индексы, сохранив исходный столбец. Не удаляйте источник, пока не перенесены новые записи, исторические данные, чтения и зависимые потребители.

Изменения типов требуют особой осторожности. Некоторые меняют только метаданные, другие переписывают каждую строку или слишком долго удерживают строгую блокировку. Для рискованного преобразования добавьте столбец целевого типа, заполните его пакетами, переключите доступ приложения и позднее удалите исходный. Так команда сможет фиксировать ошибки преобразования, вместо того чтобы один большой ALTER COLUMN TYPE целиком завершился успехом или ошибкой.

Развёртывайте код, который сохраняет совместимость

Совместимый код приложения спокойно относится к отсутствующим переходным значениям и не требует разрушительной миграции в рамках того же выката. Расширение базы данных должно завершиться до того, как первый экземпляр приложения начнёт использовать новый объект.

Двойная запись полезна, когда оба представления должны оставаться актуальными. Если возможно, выполняйте обе записи в одной транзакции базы данных. Асинхронная вторая запись может не выполниться после успешной первой и создать расхождение, которое проявится при чтении.

Логике двойной записи нужен единый источник истины. Если phone_e164 выводится из phone, определите, какое значение имеет приоритет, когда переданы оба, и применяйте одинаковую нормализацию в обработчиках API, воркерах, импортах и административных инструментах. Иначе два на вид корректных пути кода будут сохранять разные результаты.

Чтение следует переключать позже записи. Оставьте чтение на проверенном поле, пока новые записи заполняют обе формы, а бэкфилл обрабатывает исторические строки. После проверки разверните путь чтения, который предпочитает новое поле и использует старое значение лишь по чётко заданному правилу. Измеряйте использование резервного пути. Незаметный резервный путь может навсегда скрывать неполные данные.

Типичная последовательность релизов:

  • Релиз 1 добавляет новые объекты базы данных, не меняя поведение приложения.
  • Релиз 2 записывает переходные представления, продолжая читать проверенное представление.
  • Релиз 3 переключает чтение после успешного бэкфилла и проверок согласованности.
  • Релиз 4 перестаёт поддерживать старое представление после истечения условий отката.
  • Релиз 5 удаляет старые ссылки в коде, а затем выполняется очистка базы данных.

Отделяйте публичные контракты API от физических изменений схемы. Переименование столбца базы данных не требует немедленно переименовывать поле в ответах веба, мобильных клиентов или интеграций. Меняйте эти контракты по отдельной политике совместимости, особенно если клиентов нельзя обновить вместе с сервером.

Составьте список всех процессов записи. HTTP-обработчики только один из источников изменений. Потребители очередей, плановые задачи, скрипты импорта, инструменты исправления данных, триггеры базы данных и прямые административные операции могут продолжать создавать строки старой формы. Где возможно, помечайте подключения к базе именем приложения и логируйте использование переходных путей, чтобы неучтённый процесс стал заметен.

Долгоживущие процессы могут сохранять устаревшие предположения из-за подготовленных запросов, кэшированных метаданных или ORM. До сокращения проверьте поэтапные перезапуски и поведение пула подключений. Процесс, который давно не обслуживал трафик, всё равно может сломаться при первом запуске редкой задачи.

Выполняйте бэкфилл без перегрузки базы данных

Выполняйте бэкфилл небольшими пакетами
Создайте простую задачу для бэкфилла и настраивайте размер пакетов, не замедляя команду.

Безопасный бэкфилл обновляет небольшие пакеты, которые можно продолжить, и замедляется при ухудшении состояния продакшена. Его запускают только после того, как активные процессы записи умеют поддерживать новое представление.

Выбирайте размер пакетов по времени выполнения и влиянию на базу, а не по универсальному числу строк. Тысяча узких строк может обработаться за миллисекунды, а тысяча строк с большими значениями или дорогими преобразованиями создаст заметный I/O. Начинайте консервативно и стремитесь к транзакциям, которые завершаются за секунды. Фиксируйте изменения между пакетами, чтобы блокировки и старые версии строк не накапливались в одной транзакции.

PostgreSQL не поддерживает ORDER BY и LIMIT непосредственно в обычном UPDATE. Выберите пакет в общем табличном выражении, затем обновите эти строки:

WITH batch AS (
    SELECT id
    FROM my_table
    WHERE id > $1
      AND new_col IS NULL
    ORDER BY id
    LIMIT 1000
)
UPDATE my_table AS target
SET new_col = transform_expression(target.old_col)
FROM batch
WHERE target.id = batch.id
  AND target.new_col IS NULL
RETURNING target.id;

Приложение сохраняет наибольший завершённый id как курсор. Условное обновление делает повторные запуски идемпотентными, поэтому сбой после фиксации не повредит уже обработанные строки. Храните прогресс так, чтобы курсор не мог продвинуться дальше незафиксированного пакета.

Курсор с возрастающим id не заставляет постоянно сканировать начало таблицы, но не замечает поздние исправления и строки, вставленные ниже курсора. Завершите работу проходом по всем оставшимся значениям NULL. Если идентификаторы не упорядочены или строки могут менять состояние пригодности, используйте рабочую таблицу или другую явную контрольную точку, а не считайте один проход вперёд полным.

Несколько воркеров могут забирать строки с FOR UPDATE SKIP LOCKED, но параллелизм увеличивает давление на запись и усложняет отслеживание прогресса. Не сочетайте пропущенные строки с курсором, который навсегда проходит мимо них. Для параллельных воркеров безопаснее очередь занятых идентификаторов или повторное сканирование по условию пригодности.

Регулируйте скорость по метрикам продакшена: задержке запросов, числу активных подключений, ожиданиям блокировок, генерации WAL, задержке воспроизведения на репликах и росту мёртвых строк. При превышении порога приостановите задачу, затем продолжите с контрольной точки. Фиксированные паузы просты, но обратная связь от базы лучше реагирует на изменение трафика.

Не меняйте каждую строку, если работа нужна только некоторым. Фильтруйте по новому полю, состоянию источника или маркеру миграции. Если преобразование дорогое, вычисляйте его вне транзакции обновления, когда это не нарушает согласованность, и затем выполняйте короткую условную запись. Сохраняйте количество и выборку отклонённых значений, а не выдумывайте данные молча.

После каждого обновления нагрузку должны выдерживать autovacuum и реплики. Бэкфилл может успешно завершиться на первичной базе, пока реплики сильно отстают или разрастание таблицы ухудшает последующие запросы. Ограничения скорости должны учитывать эту отложенную цену, а не только время выполнения текущего пакета.

Проверяйте данные и продакшен-трафик

Миграция готова к сокращению только тогда, когда проверки данных, телеметрия приложения и сведения о зависимостях подтверждают, что новое представление стало главным. Счётчик завершённой задачи сам по себе не доказывает корректность.

Начните с полноты и согласованности. IS DISTINCT FROM в PostgreSQL сравнивает значения с явной обработкой NULL, в отличие от <>, который возвращает неизвестный результат, если одна из сторон NULL:

SELECT count(*)
FROM customers
WHERE normalize_phone(phone) IS DISTINCT FROM phone_e164;

Не запускайте многократно полный подсчёт по неиндексированной очень большой таблице под нагрузкой. Используйте разовую контролируемую проверку, ограниченные диапазоны идентификаторов, выборки или временный процесс проверки, который последовательно проходит таблицу. Подход зависит от цены ошибки и доступного запаса ресурсов базы.

Проверка должна охватывать:

  • В строках, где новое поле обязательно, не осталось неожиданных пропусков.
  • Новое значение соответствует согласованному преобразованию, включая некорректные и пустые входные данные.
  • Новые строки и обновления остаются согласованными после завершения обработки исторических строк.
  • Использование резервного чтения достигло запланированного порога, обычно нуля для трафика под контролем сервера.
  • Ошибки, задержка запросов, блокировки и отставание реплик остаются в рамках лимитов релиза.

Сравнивайте бизнес-результаты, а не только столбцы. Если миграция меняет цены, права доступа, состояние аккаунта или идентификаторы, проверяйте суммы и инварианты, от которых зависят пользователи. Два столбца могут механически совпадать, но оба содержать неверное бизнес-правило.

Перед очисткой дождитесь полного рабочего цикла. Нужный интервал определяется реальным поведением системы, а не фиксированным правилом в одну неделю. Возможно, нужно учесть обработку конца месяца, редкую задачу выставления счетов, отложенные повторные попытки очереди или максимальный срок жизни старого мобильного клиента. Зафиксируйте доказательства перехода каждого потребителя.

Если архитектура приложения позволяет, используйте канареечный релиз для переключения чтения. Направьте небольшую часть трафика на новый путь чтения, сравните результаты и постепенно расширяйте охват. Откат должен быть простым: верните чтение к проверенному представлению, не отменяя бэкфилл.

Добавляйте ограничения после подготовки данных

Ограничения должны становиться строгими только после того, как все процессы записи соблюдают правила, а существующие данные прошли проверку. Принудительное включение NOT NULL, проверки или внешнего ключа на этапе расширения может остановить трафик или отклонять записи старого процесса.

PostgreSQL позволяет добавить CHECK-ограничение как NOT VALID. Тогда правило применяется к новым и изменённым строкам без немедленного сканирования всей истории. Проверьте его отдельно после бэкфилла:

ALTER TABLE customers
ADD CONSTRAINT customers_phone_e164_present
CHECK (phone_e164 IS NOT NULL) NOT VALID;

ALTER TABLE customers
VALIDATE CONSTRAINT customers_phone_e164_present;

После успешной проверки поддерживаемые версии PostgreSQL могут использовать это доказательство при установке NOT NULL, избегая ещё одного полного сканирования таблицы. Финальное изменение всё равно требует строгой блокировки таблицы, поэтому используйте ограниченный тайм-аут блокировки и план повтора:

ALTER TABLE customers
ALTER COLUMN phone_e164 SET NOT NULL;

ALTER TABLE customers
DROP CONSTRAINT customers_phone_e164_present;

Временную проверку можно оставить, если она полезна, но одинаковые ограничения засоряют каталог, не меняя правила.

Для внешних ключей можно использовать похожую последовательность с NOT VALID и VALIDATE CONSTRAINT. После создания ограничения проверяются новые записи, а исторические данные валидируются позже. Осознанно добавляйте поддерживающий индекс, если поведение удаления или обновления в связанной таблице иначе приведёт к дорогим сканированиям.

Проверка в приложении должна предшествовать принудительному ограничению в базе, но не заменяет его. Код даёт пользователям более понятные ошибки, а база защищает данные, записанные любым путём. Во время релиза следите за нарушениями ограничений, чтобы найти процесс записи, пропущенный при аудите зависимостей.

Безопасно сократите старый путь

Планируйте безопасные изменения схемы
Превратите план expand/contract в конкретные задачи и контрольные точки до изменений в продакшене.

На этапе сокращения сначала удаляют зависимости приложения, а затем объекты базы данных. Когда телеметрия и проверка подтвердят, что новое представление стало главным, очистку можно выполнять отдельными релизами.

Сначала прекратите читать старое поле и удалите резервную логику. Затем отключите запись в него и достаточно долго наблюдайте за продакшеном, чтобы заметить редкие пути. Удалите флаги функций, триггеры, представления совместимости, скрипты исправления и плановые задачи, упоминающие старое представление. Ищите в экспортированном исходном коде и коде миграций, но также проверяйте отчёты, запросы интеграций и конфигурации change data capture вне основного репозитория.

Безопасный порядок очистки:

  • Удалите резервное чтение и убедитесь по телеметрии, что его больше нет.
  • Остановите старые записи и удалите код синхронизации.
  • Удалите ссылки приложения из всех развёртываемых версий.
  • Удалите устаревшие индексы и ограничения подходящим онлайн-методом.
  • Удалите старый столбец или таблицу в более позднем релизе базы данных.

Удаление столбца PostgreSQL в основном меняет каталог, но всё равно требует блокировки ACCESS EXCLUSIVE. Поэтому короткая команда может ждать долгую транзакцию и блокировать последующую работу. Задайте тайм-аут блокировки, заранее проверьте долгие транзакции и запланируйте попытку на период с меньшим риском.

Для устаревшего индекса используйте DROP INDEX CONCURRENTLY, если блокировка записей неприемлема. Как и конкурентное создание, эту команду нельзя запускать внутри блока транзакции, и у неё есть ограничения, которые должны учитывать инструменты миграции.

Не объединяйте очистку кода и физическое удаление в одном релизе. Разделение позволяет очищенному приложению работать с базой, где неиспользуемый объект ещё существует. Если появится проблема в приложении, откат останется возможным без повторного создания схемы или восстановления данных.

Перед удалением таблицы проверьте владение последовательностями, представлениями, функциями, правами, триггерами, публикациями репликации и внешними запросами. Не используйте CASCADE как быстрый путь в продакшен-миграции, потому что он может удалить зависимости, не входившие в запланированное изменение.

Обрабатывайте откат и неудачные шаги

План отката должен определять безопасное действие для каждой фазы, а не полагаться на одну общую обратную миграцию. Добавочные объекты, перенос данных, переключение чтения и удаление имеют разные свойства восстановления.

Если на этапе расширения не удалось получить блокировку, не меняйте приложение и повторите попытку после устранения блокирующей транзакции. Если конкурентное создание индекса завершилось ошибкой, проверьте, не остался ли недействительный индекс, и очистите именно этот объект до новой попытки.

Если бэкфилл создаёт нагрузку, приостановите его. Уже зафиксированные идемпотентные пакеты можно оставить. Уменьшите размер или скорость пакетов, устраните дорогую часть преобразования и продолжите с контрольной точки. Откат миллионов корректных обновлений обычно повышает риск и не помогает продакшену восстановиться.

Если новый путь чтения возвращает неверные результаты, верните чтение к старому представлению, сохранив новые данные для диагностики. Продолжайте двойную запись, только если известно, что она корректна. Если ошибка в самом процессе записи, отключите его или откатите приложение до исправления затронутых строк.

После сокращения для восстановления может потребоваться восстановить данные, а не просто развернуть старую сборку. Явно определите точку невозврата. Сделайте резервную копию или снимок, предусмотренные политикой восстановления системы, проверьте восстановление до релиза и, если позволяет стоимость хранения, храните старый объект согласованный срок.

Команды схемы могут быть транзакционными, но внешние эффекты не всегда входят в транзакцию. Конкурентные операции с индексами, сообщения очереди, изменения кэша и развёртывания приложений не разделяют одну атомарную транзакцию. В регламенте нужно описать наблюдаемое состояние после каждого частичного сбоя и команду, которая безопасно продолжит работу.

Избегайте распространённых ловушек миграций

Большинство неудачных миграций без простоя либо слишком рано принуждают систему к новому состоянию, либо забывают потребителя старого. Перед одобрением явно проверьте следующие ловушки:

  • Добавление NOT NULL, пока старый экземпляр приложения ещё может не передавать поле.
  • Выполнение большого бэкфилла в одной транзакции с долгим удержанием блокировок и версий строк.
  • Переименование столбца как будто это добавочное изменение, хотя старый код всё ещё использует исходное имя.
  • Переключение чтения до того, как все пути записи и исторические строки заполнили новое представление.
  • Считать успешное развёртывание доказательством совместимости отчётов, воркеров, реплик и интеграций.

Ещё одна незаметная проблема возникает при двусторонней синхронизации. Триггер копирует old_col в new_col, а код приложения копирует new_col обратно в old_col. Различия в нормализации или порядке триггеров могут создать циклы, перезаписать намеренные значения или сделать владение неясным. Выберите одно направление и задокументируйте, какое представление главное в каждом релизе.

Значения по умолчанию могут скрыть пропущенные обновления процессов записи. Если новый обязательный столбец получает пустое или общее значение по умолчанию, старый код кажется совместимым, но сохраняет семантически неверные данные. Используйте nullable-переход, когда отсутствие значения полезно для диагностики, а настоящее правило применяйте после того, как каждый процесс записи передаёт осмысленное значение.

Флаг функции сам по себе не делает несовместимую команду схемы безопасной. Отключенный путь кода всё равно может быть загружен, подготовлен или запущен старым процессом. Объект базы данных должен сохраняться, пока на него не ссылается ни одна развёртываемая или активная версия.

Важна и ответственность за миграцию. Назначьте человека или команду, которые ведут переход до сокращения, включая проверку и даты удаления. Иначе временные столбцы, флаги и задачи синхронизации могут оставаться месяцами, повышая стоимость каждого следующего изменения.

Замените столбец телефона без простоя

Используйте режим планирования для миграций
Спланируйте релизы, бэкфиллы и запросы для проверки в режиме планирования Koder.ai.

Замена customers.phone нормализованным customers.phone_e164 требует добавочного столбца, определённой политики преобразования, совместимого кода, ограниченного бэкфилла, переключения чтения и отложенной очистки. Политику преобразования нужно определить до SQL, потому что не каждое сохранённое значение можно нормализовать автоматически.

Начните с классификации существующих значений. Корректные номера можно преобразовать, если известен нужный контекст страны. Пустые значения могут стать NULL. Неоднозначные или некорректные номера должны попасть в отчёт об исключениях, а не обрабатываться догадками. Решите, обязан ли у каждого клиента быть номер телефона, поскольку от этого зависит уместность NOT NULL позднее.

Добавьте столбец с коротким тайм-аутом блокировки:

BEGIN;
SET LOCAL lock_timeout = '2s';

ALTER TABLE customers
ADD COLUMN phone_e164 text;

COMMIT;

Разверните код, который нормализует новые входные данные и записывает phone и phone_e164 в одной транзакции. Сначала оставьте чтение на phone. Обновите каждый процесс записи, включая импорты аккаунтов, инструменты поддержки, задачи воркеров и тесты, создающие фикстуры клиентов.

Выполняйте бэкфилл подходящих строк короткими транзакциями. Сохраняйте последний обработанный идентификатор, число преобразованных и пропущенных строк, а также причину для каждой категории ошибок. Ограничивайте скорость задачи по задержке продакшена и отставанию реплик. После завершения прямого прохода снова просканируйте подходящие значения NULL, чтобы найти параллельные вставки или строки, пропущенные после перезапуска.

Запустите проверки согласованности с теми же правилами нормализации, что и в приложении, затем вручную проверьте международные префиксы, добавочные номера, пустые значения, дубли контактов и старые импортированные данные. Число строк подтверждает охват, но не правильность телефонного номера.

Разверните путь чтения, который возвращает phone_e164, если оно есть, и использует phone только для залогированного исключения. Следите за резервными чтениями и ошибками нормализации. Устраните оставшиеся исключения, не превращая резервный путь в постоянное поведение.

Когда новое поле станет главным, удалите резервный путь и прекратите записывать phone. Наблюдайте за редкими задачами и интеграционным трафиком в течение подходящего рабочего цикла. Добавляйте проверенное ограничение, только если этого требует правило продукта.

Наконец, удалите ссылки на phone из кода. Отдельно удалите его индексы или ограничения, затем удалите столбец в поздней миграции с ограниченным ожиданием блокировки. Если переключение чтения не сработает до этого удаления, откатите поведение приложения, пока доступны оба столбца.

Этот пример показывает и проблему предметной области, которую не решает механика схемы: разбиение или нормализация введённых человеком данных не всегда проходит без потерь. План миграции должен сохранять исключения и давать ответственному человеку способ их решить.

Проверяйте каждый релиз перед выпуском

Контрольный список релиза должен подтверждать совместимость, ограничивать влияние на продакшен и называть действие восстановления для текущей фазы. Храните подтверждения вместе с изменением, чтобы оператору не пришлось восстанавливать замысел во время инцидента.

Перед развёртыванием подтвердите:

  • Версия приложения работает с состоянием базы данных до и после этого релиза.
  • Для команд схемы, которые могут ждать трафик, заданы тайм-ауты блокировки и выполнения.
  • У задачи бэкфилла или проверки есть управление прогрессом, паузой, продолжением и ограничением скорости.
  • Дашборды показывают ошибки, задержку, блокировки, нагрузку базы данных, WAL и отставание реплик.
  • Действие отката проверено и не зависит от уже удалённого объекта.

Зафиксируйте явные условия завершения. Например: ноль новых ошибок согласованности в течение полного цикла задачи, ноль резервных чтений от трафика под контролем сервера, обновлены все известные потребители и успешно выполнен контролируемый запрос проверки. Процент готовности полезен во время бэкфилла, но 100 процентов обработанных строк не означает 100 процентов корректных.

Проверяйте порядок миграции отдельно от ревью кода. Правильный набор SQL и изменений приложения всё равно может не сработать, если развёртывание выполнит их в неверной последовательности. Укажите, какой шаг можно начинать только после завершения другого.

Условия остановки по возможности должны выражаться числами. Определите приемлемые задержку запросов, ожидание блокировок, отставание реплик, долю ошибок и длительность пакета. При превышении порога оператор должен понимать, нужно ли приостановить задачу, отменить ожидающую команду или перенаправить чтение, не запрашивая новое одобрение во время инцидента.

Миграция завершена только тогда, когда новое представление обслуживает чтение и запись, исторические данные прошли проверку, старый объект удалён, а временные рабочие механизмы убраны.

Сделайте процесс повторяемым

Повторяемый регламент миграции превращает expand/contract в обычную работу над релизом с назначенными ответственными и измеримыми условиями перехода. Он должен быть достаточно коротким для живого развёртывания и достаточно конкретным, чтобы описывать состояния частичных сбоев.

Используйте в регламенте пять разделов:

  • Расширение: точные операции со схемой, ожидаемые блокировки, тайм-ауты и требования к транзакциям.
  • Совместимость: затронутый код, процессы записи и чтения, флаги, клиенты и порядок развёртывания.
  • Бэкфилл: политика преобразования, пакетная обработка, контрольные точки, регулирование скорости и работа с исключениями.
  • Проверка: SQL-проверки, бизнес-инварианты, телеметрия и пороги завершения.
  • Сокращение: удаление зависимостей, период наблюдения, физическая очистка и ограничения восстановления.

Назначьте ответственного и ожидаемую дату завершения для каждого переходного объекта. Отслеживайте столбцы, индексы, флаги, триггеры и задачи в одном месте. Очистка входит в миграцию, а не считается необязательным обслуживанием.

Команды, которые разрабатывают с Koder.ai, могут использовать режим планирования, чтобы заранее описать эти фазы и контрольные точки до изменений в продакшене. Экспорт исходного кода также позволяет проверять SQL миграций и логику совместимости так же, как другой код приложения. Koder.ai поддерживает развёртывание, хостинг, снимки и откат, но не следует считать, что откат приложения отменит уже зафиксированное преобразование данных. Сохраняйте совместимость схемы, пока план восстановления базы данных зависит от старого представления.

По возможности планируйте работы с интенсивной записью на время меньшего трафика, но не считайте выбор времени единственной мерой безопасности. Ограниченные транзакции, регулирование скорости по обратной связи, наблюдаемый прогресс и проверенное действие паузы помогают держать онлайн-миграцию под контролем, когда трафик или данные ведут себя не так, как ожидалось.

FAQ

Почему изменение схемы может вызвать сбой?

Изменения схемы нарушают работу продакшена, когда старые и новые версии приложения ожидают разную структуру базы данных. При поэтапном развёртывании обе версии могут работать одновременно, поэтому слишком раннее удаление или переименование столбца вызывает ошибки чтения и записи.

Что такое паттерн миграции expand/contract?

Expand/contract делит несовместимое изменение на безопасные этапы. Сначала добавляют новую структуру, затем переводят на неё код и данные, а старую структуру удаляют, когда ею перестали пользоваться все потребители.

Как переименовать или заменить столбец базы данных без простоя?

Сначала добавьте новый столбец и оставьте старый. Разверните код, который умеет работать с обоими полями, перенесите существующие строки небольшими пакетами, переключите чтение после проверки и удалите старый столбец в следующем релизе.

Можно ли добавить столбец PostgreSQL, не блокируя трафик?

Как правило, да. Добавление nullable-столбца без значения по умолчанию в PostgreSQL часто сводится к короткому изменению метаданных, но всё равно требует блокировки таблицы. Установите короткий тайм-аут ожидания блокировки, чтобы миграция завершилась ошибкой, а не ждала долгую транзакцию.

Как создать индекс, не блокируя записи?

Если таблица должна оставаться доступной для записи, используйте CREATE INDEX CONCURRENTLY. Операция займёт больше времени и создаст нагрузку на базу, а внутри блока транзакции её запускать нельзя. Во время работы следите за задержкой, WAL и отставанием реплик.

Когда приложению нужна двойная запись в старое и новое поля?

Записывайте оба значения в одной транзакции базы данных, когда оба представления должны оставаться актуальными. Определите, какое поле имеет приоритет при расхождении, и используйте одинаковые правила нормализации в API, воркерах, импортах и инструментах поддержки.

Как безопасно выполнить бэкфилл большой таблицы PostgreSQL?

Обрабатывайте короткие пакеты, которые можно продолжить после остановки, и фиксируйте каждый из них. Храните контрольную точку, обновляйте только строки, которые ещё требуют обработки, и замедляйте или останавливайте задачу при росте задержки запросов, ожидания блокировок, объёма WAL или отставания реплик.

Как понять, что бэкфилл завершён и выполнен правильно?

Не переключайте чтение лишь потому, что бэкфилл закончился. Убедитесь, что обязательные значения заполнены, сравните старое и новое представления, следите за резервными чтениями и проверьте, что новые записи остаются согласованными после обработки исторических данных.

Когда добавлять NOT NULL, CHECK-ограничения или внешние ключи?

Добавляйте строгие ограничения после проверки существующих данных и после того, как все активные процессы записи начали передавать корректные значения. PostgreSQL позволяет добавить некоторые ограничения как NOT VALID, применять их к новым строкам и отдельно проверить исторические строки.

Когда можно безопасно удалить старый путь в схеме?

Сначала уберите резервное чтение, затем прекратите старые записи и понаблюдайте за системой в течение полного рабочего цикла. Когда ни приложения, ни задачи, ни отчёты, ни интеграции, ни клиенты больше не ссылаются на старый объект, удалите связанный код и в одном из следующих релизов удалите столбец или таблицу.

Похожие статьи