Миграции PostgreSQL без простоя: безопасный план из четырёх этапов
В PostgreSQL миграция может заблокировать запросы не только во время долгого обновления данных: даже короткий ALTER TABLE способен встать в очередь за другой транзакцией. Разбираем план для PostgreSQL 16, который позволяет менять схему поэтапно и проверять каждый переход.

В PostgreSQL 16 команда ALTER TABLE может выполниться быстро, но всё равно остановить запись: для изменения схемы ей часто нужен строгий уровень блокировки, а ожидание блокировки затрагивает и запросы, вставшие следом в очередь. Чтобы менять таблицу без заметного простоя, разделите миграцию на совместимые шаги и не смешивайте изменение схемы с массовым обновлением строк.
Почему одного быстрого ALTER TABLE недостаточно
PostgreSQL блокирует таблицы, чтобы параллельная транзакция не увидела противоречивое состояние. У разных операций разные уровни блокировки; многие команды ALTER TABLE запрашивают ACCESS EXCLUSIVE — режим, несовместимый даже с обычным чтением. Само изменение может быть мгновенным, но команда будет ждать завершения длинной транзакции. Пока она ждёт, новые запросы к таблице тоже могут задерживаться.
Значит, задача не в том, чтобы найти волшебную команду «без блокировки». Нужно сократить время, когда требуется сильная блокировка, и оставить приложению рабочий путь как до миграции, так и после неё. Ниже — пример: добавить к users колонку email_search, заполнить её значением lower(email) и постепенно перевести приложение на чтение новой колонки. Предполагается, что в таблице есть стабильный уникальный ключ id.
Сноска. Expand-contract — схема изменения, в которой сначала добавляют новое поле, не удаляя старое, затем переводят приложение на новую структуру и только после этого убирают устаревшую. Так старый и новый код могут некоторое время работать с одной базой.
Три подхода и их цена
| Подход | Когда уместен | Риск и цена |
|---|---|---|
| Изменить и заполнить одним шагом | Небольшая таблица, короткое окно обслуживания | Долгое обновление удерживает блокировки, создаёт нагрузку на диск и журнал транзакций; приложение может ждать. |
| Expand-contract | Большая таблица и приложение, которое нужно обновлять без остановки | Несколько выкладок и временная поддержка двух форматов данных. |
| Создать новую таблицу и переключить чтение | Радикальная смена модели данных или структуры, которую трудно совместить со старой | Нужно синхронизировать изменения, сверять данные и отдельно организовать переключение. Это дороже и сложнее в эксплуатации. |
Для добавления производного поля обычно выбирают expand-contract. Новая таблица — не более безопасная версия того же приёма: если копирование и синхронизация не продуманы, расхождение данных станет отдельным инцидентом.
Без простоя — не значит без блокировок. Это значит, что блокировки короткие, их ожидание ограничено, а старый и новый код не требуют переключиться одновременно.
Практический план: от новой колонки до удаления старой логики
1. Добавьте nullable-колонку
Не ставьте NOT NULL и не запускайте массовое заполнение в той же операции. Добавьте колонку без значения по умолчанию, затем проверьте, что команда прошла, а приложение продолжает работать со старой колонкой.
ALTER TABLE users ADD COLUMN email_search text;Это всё ещё DDL, которому нужна блокировка. Выполняйте его отдельно от долгих транзакций, задайте небольшой lock_timeout на соединении миграции и при тайм-ауте повторите попытку позже. Тайм-аут ограничивает ожидание блокировки, но не время выполнения всей миграции.
2. Выпустите совместимый код
Сначала обновите приложение так, чтобы новые записи сразу получали email_search, а при чтении код пока умел работать и с пустой колонкой. Во время выкладки могут одновременно работать старые и новые экземпляры: не начинайте заполнение, пока старые экземпляры ещё способны записывать строки без нового значения. Если обновить их все разом нельзя, нужен отдельный механизм совместимости, например триггер; его поведение следует проверить на вашей версии и схеме до выкладки.
Проверка этапа: после завершения выкладки новые строки получают email_search, а обычные операции приложения не требуют, чтобы старая колонка была удалена. Не полагайтесь только на то, что новая версия кода уже запущена: проверьте фоновые задачи и другие сервисы, которые пишут в ту же таблицу.
3. Заполняйте данные небольшими пакетами
Массовый UPDATE одной транзакцией может надолго удерживать блокировки строк, увеличить объём WAL и вызвать отставание реплик. Обновляйте ограниченное число строк за запуск и повторяйте запрос, пока он не перестанет изменять записи. В примере один пакет — до 1000 строк; размер подбирайте по нагрузке, это стартовое значение, а не универсальная норма.
WITH batch AS (
SELECT id
FROM users
WHERE email_search IS NULL
ORDER BY id
LIMIT 1000
FOR UPDATE SKIP LOCKED
)
UPDATE users AS u
SET email_search = lower(u.email)
FROM batch
WHERE u.id = batch.id
AND u.email_search IS NULL;Запускайте пакет снова после завершения предыдущего. Условие IS NULL делает повтор безопасным для уже обработанных строк. SKIP LOCKED полезен, если пакетами занимаются несколько работников: занятые строки можно оставить следующему проходу. При одном работнике запрос также выполняется, но усложнять схему параллелизмом без нужды не стоит.
Проверяйте число изменённых строк, длительность пакета, загрузку диска и отставание реплик. Если нагрузка растёт, уменьшите пакет или увеличьте паузу между запусками. Одна итерация без обновлённых строк ещё не доказывает завершение, если другие работники могут удерживать подходящие строки; дождитесь их завершения и выполните контрольную проверку.
4. Закрепите инвариант и переключите чтение
Когда приложение заполняет поле при каждой новой записи, а старые строки обработаны, запретите появление новых пустых значений. NOT VALID позволяет добавить ограничение, не проверяя всю таблицу в момент добавления; новые и изменяемые строки при этом должны ему соответствовать.
ALTER TABLE users
ADD CONSTRAINT users_email_search_present
CHECK (email_search IS NOT NULL) NOT VALID;
ALTER TABLE users
VALIDATE CONSTRAINT users_email_search_present;Проверка существующих строк выполняется отдельно и обычно менее disruptive, чем сканирование в момент создания обычного ограничения, но всё равно потребляет ресурсы. Следите за временем запроса и нагрузкой. После успешной проверки переведите чтение на email_search, сравните результаты поиска со старой логикой и лишь после стабилизации удаляйте старые ветки кода.
Если приложению важно именно свойство NOT NULL в метаданных, в PostgreSQL 16 проверенное ограничение CHECK на отсутствие NULL позволяет выполнить ALTER COLUMN SET NOT NULL без повторного сканирования таблицы. Команде всё ещё требуется блокировка на изменение схемы, поэтому запускайте её отдельно и не считайте отсутствие сканирования гарантией мгновенного выполнения. Если CHECK подходит приложению, можно оставить его как проверку целостности.
Что ломается чаще всего
- Долгая транзакция держит блокировку. Миграция стоит в ожидании, хотя таблица не велика. Проверьте активные транзакции и повторите попытку в спокойный момент; не убирайте ограничение ожидания просто ради успешного запуска.
- Старый экземпляр пишет после заполнения. В колонке снова появляются NULL. Дождитесь обновления всех писателей или обеспечьте совместимость до backfill, а затем повторно проверьте данные.
- Пакет слишком большой. Растут задержки запросов, WAL и отставание реплики. Уменьшите размер пакета, добавьте паузы и остановите процесс, если показатели продолжают ухудшаться.
- В одной миграции смешаны DDL и весь backfill. Транзакция становится долгой, откат дорогим, блокировки живут дольше. Разнесите изменение схемы и обновление данных на отдельные этапы.
- Удалили старую колонку до обновления всех потребителей. Ошибка может проявиться не в веб-приложении, а в отчёте или ночной задаче. Перед удалением проверьте все сервисы, запросы и интеграции, которые обращаются к таблице.
Для индекса на большой таблице используйте CREATE INDEX CONCURRENTLY, если он подходит под запросы приложения. Команда позволяет не блокировать обычные записи на всё время построения, но выполняется дольше и не должна запускаться внутри обычного транзакционного блока. Уточните поведение вашего миграционного инструмента: некоторые запускают каждую миграцию в транзакции автоматически. Если конкурентное построение завершилось ошибкой, проверьте состояние индекса и удалите некорректный объект, прежде чем повторять команду.
Когда какой подход выбирать
Если таблица небольшая, операция не сканирует и не переписывает данные, а остановка допустима, прямое изменение может быть проще — но сначала проверьте требования к блокировкам в документации именно для выбранной команды. Если таблица большая, приложение нельзя выключить, а старую и новую схемы можно временно поддерживать одновременно, выбирайте expand-contract. Если меняется смысл данных или старую структуру невозможно обслуживать параллельно с новой, оценивайте перенос в новую таблицу с отдельным планом синхронизации и переключения.
PostgreSQL 16 умеет оптимизировать некоторые изменения схемы, но это не отменяет ожидания блокировок, расхода диска и нагрузки на репликацию. Не переносите предположения об одной операции на другую: сверяйте ограничения конкретной команды в документации PostgreSQL 16.
Что делать дальше
Перед запуском на рабочей базе прогоните каждый шаг на копии с сопоставимым объёмом данных. Зафиксируйте порядок отката приложения, критерии остановки backfill и ответственного за решение о повторном запуске. Во время миграции смотрите на ожидание блокировок, длительность запросов, нагрузку на диск и отставание реплик; после переключения сравните результаты старого и нового пути чтения. Удаление старой схемы — отдельный релиз, а не финальная строка того же скрипта.
Антиблок-аналитик 115-ФЗ: Анализ выписок, поиск рисков блокировки счёта, инструкции по безопасным транзакциям.
Спросить за 41 ₽Источники: PostgreSQL 16: ALTER TABLE, PostgreSQL 16: CREATE INDEX
Частые вопросы
Можно ли гарантировать миграцию PostgreSQL совсем без простоя?
Можно ли добавить NOT NULL сразу при создании колонки?
Что делать, если backfill обновил ноль строк, но не все данные готовы?
Нужно ли откатывать миграцию, если новая версия приложения не заработала?
Работает ли этот план в других версиях PostgreSQL?
Как понять, что можно удалять старую колонку?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.