Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
🧱
Данные
Опубликовано:
29.07.2026
Обновлено:
03.08.2026

Полное руководство по zero‑downtime миграциям в PostgreSQL

Илья Новиков

PostgreSQL - мощная база, но стоит запустить ALTER TABLE на таблице с живым трафиком, и можно получить ступор всего сервиса. Даже безобидное добавление столбца на старых версиях превращалось в долгую блокировку. В этом руководстве разберем, как менять схему, не роняя прод: от физики блокировок до боевых паттернов и инструментов.

Почему обычные миграции опасны

Как PostgreSQL блокирует таблицы при DDL

Любая команда, меняющая структуру объекта, навешивает блокировку. Большинство DDL-операций требуют наивысшего уровня - ACCESS EXCLUSIVE. Эта блокировка запрещает кому‑либо ещё читать или писать в таблицу, пока ALTER TABLE не завершится. То есть приложение встаёт колом.

Проблема не в том, что блокировка есть - она нужна для целостности. Проблема в том, что операция может длиться минутами или часами (например, при перезаписи всей таблицы), и всё это время таблица недоступна.

Что такое ACCESS EXCLUSIVE и почему чтение тоже встаёт

Уровни блокировок в PostgreSQL выстроены по строгости. ACCESS EXCLUSIVE - самый сильный. Он конфликтует с любыми другими блокировками: и с ACCESS SHARE (которую берёт обычный SELECT), и с ROW EXCLUSIVE (для INSERT, UPDATE, DELETE). Поэтому даже чтение не проходит, пока миграция держит лок.

Реальные сценарии: когда ALTER TABLE вешает прод

Представь: ты выполнил ALTER TABLE orders ADD COLUMN metadata jsonb DEFAULT '{}'::jsonb на десятках миллионов строк. До PostgreSQL 11 это переписывало всю таблицу на диск, попутно удерживая ACCESS EXCLUSIVE. Приложение не может оформить заказ, отобразить историю - всё висит. А если в этот момент крутилась репликация - реплика тоже встанет, как только дойдёт до команды.

Какие операции можно делать без простоя

Добавление столбца без DEFAULT и NOT NULL

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

Создание индекса с CONCURRENTLY

Обычный CREATE INDEX навешивает такой же ACCESS EXCLUSIVE до конца построения. Команда CREATE INDEX CONCURRENTLY работает в два сканирования с минимальными блокировками: она позволяет и чтение, и запись во время сборки. Плата - индекс строится дольше и потребляет больше ресурсов, но прод продолжает работать.

Безопасные изменения после PostgreSQL 11 (ADD COLUMN DEFAULT)

Начиная с PostgreSQL 11, механизм хранения изменился: ALTER TABLE ... ADD COLUMN ... DEFAULT больше не перезаписывает всю таблицу. База просто запоминает дефолтное значение в каталоге, и новые строки его получают, а старым оно подставляется при чтении. Время эксклюзивной блокировки сокращается до миллисекунд, что позволяет добавлять колонки с DEFAULT без даунтайма (с оговоркой, что NOT NULL вместе с DEFAULT тоже стала быстрой при условии отсутствия NULL в существующих данных или если даётся константное значение).

Ограничения: что всё ещё требует полной блокировки

Остаётся множество операций, которые вызывают перезапись всей таблицы:

  • изменение типа столбца (даже varchar(255)text в общем случае);
  • изменение значения по умолчанию у существующей колонки без добавления новой;
  • добавление DEFAULT с пересчётом на старых версиях (<11);
  • переименование столбцов и таблиц с последующей ломкой кода приложения (сама по себе операция быстрая, но ломает читающие запросы).

Вывод: «онлайн‑изменение схемы» в PostgreSQL не является встроенной функцией для всех случаев, и за пределами простых действий нужны обходные стратегии.

Паттерн Expand‑Migrate‑Contract

Суть подхода: расширить, мигрировать, сузить

Вместо одномоментной замены колонки или таблицы ты эволюционируешь схему в три шага:

  1. Expand (расширение): добавить новые структуры, ничего не удаляя. Приложение продолжает писать по‑старому, но уже может работать с новыми полями.
  2. Migrate (миграция): перегнать данные из старого формата в новый (скриптами или в фоне), адаптировать код приложения на чтение и запись в новые структуры.
  3. Contract (сужение): после того, как старый формат больше не используется, удалить устаревшие колонки/таблицы.

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

Пример: замена колонки без даунтайма

Допустим, нужно разделить поле full_name на first_name и last_name. Бездумный ALTER TABLE ... DROP COLUMN full_name, ADD COLUMN first_name text, ADD COLUMN last_name text сломает приложение. Правильный план:

  • Добавляем nullable-колонки first_name и last_name (расширение).
  • Настраиваем триггер или фоновый воркер, который заполняет новые колонки при вставке/обновлении, а также обрабатывает существующие строки. Код приложения начинает читать из новых колонок, если они заполнены, и писать в них.
  • Когда все строки смигрированы и код больше не обращается к full_name, удаляем full_name (сужение).

Как синхронизировать приложение и схему

Главный помощник - обратная совместимость. Новый код должен уметь читать как старую, так и новую схему. Часто помогает деплой двухфазного релиза:

  • Версия 1: схема расширена, код читает из нового и пишет в старое.
  • Версия 2: код полностью переведён на новые поля, запускается clean-up миграция.

Всё это время никаких rename для используемых сущностей - только добавление новых и удаление неиспользуемых.

Практический чек‑лист безопасной миграции

Категоризация изменений по риску

Перед каждым ALTER задай вопросы:

  • Требует ли операция перезаписи таблицы? (смена типа, добавление DEFAULT на старых версиях)
  • Держит ли она ACCESS EXCLUSIVE дольше нескольких миллисекунд?
  • Может ли сломать читающие запросы приложения? (переименование, удаление) На основе этого разбей миграции на безопасные, умеренные и опасные. Для опасных применяй expand‑contract.

Обязательный lock_timeout в скриптах

Никогда не запускай DDL на боевой базе без SET lock_timeout. Пример:

BEGIN;
SET LOCAL lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN tracking_id text;
COMMIT;

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

Контроль лага реплик и долгих транзакций

До запуска миграции убедись, что репликация не отстаёт. Долгая транзакция на мастере может задержать применение WAL на реплике, и если миграция ещё и заблокирует чтение, реплика отстанет сильнее. Проверь pg_stat_replication и заверши зависшие транзакции (pg_stat_activity) с помощью pg_terminate_backend, если они мешают.

План отката: как написать и протестировать

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

Инструменты для zero‑downtime миграций

pgroll (xataio) - декларативные миграции с версионированием схемы

pgroll - опенсорсный инструмент, который позволяет описывать миграции в декларативном JSON-формате, а он сам генерирует необходимые шаги expand‑contract и управляет версиями схемы. Приложение может узнавать текущую версию и выбирать соответствующие колонки. Это снижает ручную работу и риск ошибок, особенно в больших проектах.

Другие подходы: ручные скрипты, strong_migrations

Если pgroll не внедрён, работают старые добрые ручные сценарии, организованные по принципам выше. В экосистеме Ruby on Rails популярен гем strong_migrations - он блокирует запуск опасных операций без явного подтверждения и напоминает о CONCURRENTLY и lock_timeout. Аналогичные практики можно реализовать в любом фреймворке, просто включив дисциплину.

pg_upgrade - миграция мажорной версии без простоя

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

Мониторинг и отладка

Какие параметры логов включить

В postgresql.conf обязательно активируй:

  • log_lock_waits = on - фиксирует в логах ситуации, когда транзакция ждёт блокировку дольше deadlock_timeout. Помогает выявить проблемные миграции.
  • log_min_duration_statement = 200 (в миллисекундах) - записывает все запросы, выполнявшиеся дольше порога. Показывает, какой DDL вызвал длительную блокировку.
  • log_statement = 'ddl' (опционально, на время отладки) - логирует все DDL-команды.

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

Запрос к pg_stat_activity с фильтром по wait_event_type = 'Lock' покажет, кто и какой блокировки ждёт. Запросы в состоянии idle in transaction с длительным xact_start - кандидаты на принудительное завершение. Если миграция всё же повисла, нахождение блокирующего PID и вызов pg_cancel_backend или pg_terminate_backend могут спасти ситуацию, вернув приложение к жизни.

Распространённые ошибки и как их избежать

  • Переименовывать колонку или таблицу «в лоб». Код старых релизов немедленно начнёт получать ошибки. Вместо этого - создай новую колонку и удали старую после перехода.
  • Создавать индекс без CONCURRENTLY на горячей таблице. Даже на средних объёмах это кладёт прод. Всегда добавляй CONCURRENTLY, если только таблица не заблокирована полностью на время обслуживания.
  • Гнать миграцию при большом лаге реплик. После завершения DDL на мастере реплике придётся нагнать, а если она отстаёт - приложение, переключённое на реплику, увидит устаревшие данные. Проверяй лаг заранее.
  • Забывать про lock_timeout. Самая частая причина ночных инцидентов. Пропиши таймаут в теле каждой DDL-миграции.
  • Надеяться, что «на стейдже всё прошло быстро». Продакшен отличается количеством конкурентных запросов и длительностью блокировок. Имей запас по таймаутам и следи за логами.

FAQ

Можно ли изменить тип колонки без простоя?
Напрямую - нет. Любое изменение типа требует ACCESS EXCLUSIVE и полного переписывания таблицы. Единственный способ без даунтайма - применить expand‑contract: создать новую колонку с нужным типом, постепенно заполнить её данными, перевести приложение, а затем удалить старую.

Как добавить столбец с DEFAULT и не уронить прод?
На PostgreSQL 11 и новее добавление колонки с константным значением по умолчанию выполняется быстро, без перезаписи строк. Если версия старше - придётся либо обновить базу, либо использовать обходной манёвр: добавить column без DEFAULT, а затем отдельными батчами проставить значение (что тоже может дать нагрузку, но без долгой блокировки).

Обязательно ли использовать pgroll?
Нет. pgroll удобен для крупных проектов с частыми изменениями схемы и множеством окружений, но ручные сценарии на основе expand‑contract с lock_timeout и CONCURRENTLY отлично работают. Выбор зависит от сложности системы и готовности команды внедрять новый инструмент.

Что делать, если миграция всё же заблокировала таблицу?
Если миграция уже выполняется и держит лок, найди её PID через pg_stat_activity, оцени, сколько она ещё будет работать. Если время неприемлемо - прерывай командой pg_cancel_backend (мягко) или pg_terminate_backend (жёстко). Затем проанализируй, почему сработала блокировка: возможно, забыли CONCURRENTLY или lock_timeout, и повтори с учётом исправлений.

Как проверить, что индекс создаётся конкурентно?
В тексте команды должно быть слово CONCURRENTLY. В pg_stat_activity такой запрос будет висеть в состоянии active, при этом другие операции с таблицей не блокируются. Мониторь количество сканирований (index_scan / seq_scan), но точных таймингов гарантировать нельзя - длительность зависит от загрузки и объёма данных.

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

Вывод

Zero‑downtime миграции в PostgreSQL - это не отсутствие блокировок как факт, а грамотное управление ими. Ключевые правила: знай, какой уровень блокировки вызывает твоя команда, используй lock_timeout, строй индексы через CONCURRENTLY, обновляйся до актуальных версий и осваивай паттерн expand‑migrate‑contract. Тогда даже серьёзные изменения схемы проходят незаметно для пользователей, а прод остаётся стабильным.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также