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

Почему обычный pg_upgrade не способен обеспечить переход без простоя

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

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

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

# Старый способ: схема отдельно, данные переливаются заново
pg_dump --schema-only -h pg14-host -d mydb -f schema.sql
psql -h pg16-host -d mydb -f schema.sql

# На источнике
CREATE PUBLICATION upgrade_pub FOR ALL TABLES;

# На приёмнике — здесь и начинается многочасовое ожидание COPY
CREATE SUBSCRIPTION upgrade_sub
  CONNECTION 'host=pg14-host dbname=mydb user=logical_migrator password=secret'
  PUBLICATION upgrade_pub;

Этот путь работал, но требовал терпения и крепких нервов. Для базы на 500 ГБ инженеры закладывали ночное окно и караулили прогресс копирования, понимая, что любой сбой посреди процесса означает начинать сначала.

Что именно делает pg_createsubscriber и почему он пропускает копирование

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

По сути это переключение режима репликации, а не перенос строк. Для базы на 500 ГБ разница колоссальная. Физический резерв поднимается через pg_basebackup или разворачивается из снимка диска, синхронизируется потоково в обычном режиме, а затем одной командой меняет свою природу. Утилита автоматически формирует для каждой указанной базы пару объектов: публикацию вида FOR ALL TABLES на исходном сервере и соответствующую подписку на целевом. Под капотом она ещё и меняет системный идентификатор целевого сервера через pg_resetwal, чтобы тот случайно не подхватил журнальные файлы источника и не запутался в своей родословной.

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

SELECT slot_name, slot_type, restart_lsn
FROM pg_replication_slots
ORDER BY 1;

--          slot_name           | slot_type | restart_lsn
-- -----------------------------+-----------+-------------
--  node2                       | physical  | 0/40382F0
--  pg_createsubscriber_5_80f3c | logical   | 0/40382B8

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

Какие параметры нужно выставить заранее, чтобы запуск не сорвался

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

На практике подготовка источника выглядит так.

# postgresql.conf на источнике
wal_level = logical
max_replication_slots = 10
max_wal_senders = 10

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

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

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

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

# Попытка запуска на работающем резерве закончится ошибкой
pg_createsubscriber --database=mydb \
  --pgdata=/var/lib/postgresql/17/main \
  --subscriber-port=5432 \
  --publisher-server='user=replicator password=secret host=10.0.0.10' \
  --dry-run

# pg_createsubscriber: error: standby server is running
# pg_createsubscriber: hint: Stop the standby server and try again.

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

# Сначала проверка без изменений
pg_createsubscriber --database=mydb \
  --pgdata=/var/lib/postgresql/17/main \
  --subscriber-port=5432 \
  --publisher-server='user=replicator password=secret host=10.0.0.10' \
  --publication=upgrade_pub \
  --subscription=upgrade_sub \
  --dry-run

# Если всё зелёное — тот же вызов без --dry-run
pg_createsubscriber --database=mydb \
  --pgdata=/var/lib/postgresql/17/main \
  --subscriber-port=5432 \
  --publisher-server='user=replicator password=secret host=10.0.0.10' \
  --publication=upgrade_pub \
  --subscription=upgrade_sub

Если кратко собрать весь путь воедино, реальная процедура для базы на 500 ГБ укладывается в несколько последовательных шагов:

  1. поднять физический потоковый резерв и дать ему синхронизироваться с источником;
  2. остановить резерв чисто, дождавшись завершения всех процессов;
  3. прогнать pg_createsubscriber с флагом dry-run и убедиться, что все проверки прошли;
  4. выполнить реальное преобразование резерва в логического подписчика;
  5. запустить pg_upgrade на подписчике, переводя его на новую мажорную версию;
  6. синхронизировать последовательности и переключить трафик приложения.

Почему последовательности приходится переносить руками после переключения

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

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

-- Выполняется на источнике: генерирует SET-команды для приёмника
SELECT 'SELECT setval(' || quote_literal(quote_ident(schemaname) || '.' || quote_ident(sequencename))
  || ', ' || last_value || ');'
FROM pg_sequences
WHERE last_value IS NOT NULL;

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

Как отслеживать отставание и поймать правильный момент для переключения

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

-- На приёмнике: насколько подписка отстаёт от издателя
SELECT subname,
       pg_wal_lsn_diff(pg_current_wal_lsn(), latest_end_lsn) AS lag_bytes
FROM pg_stat_subscription;

-- На источнике: контроль лага по слоту
SELECT slot_name,
       pg_wal_lsn_diff(pg_current_wal_lsn(), confirmed_flush_lsn) AS lag
FROM pg_replication_slots
WHERE slot_name LIKE 'pg_createsubscriber%';

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

Где эта схема даёт сбой и что стоит проверить дважды

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

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

# Подписчик уже преобразован, теперь поднимаем его версию
pg_upgrade \
  --old-datadir=/var/lib/postgresql/17/main \
  --new-datadir=/var/lib/postgresql/18/main \
  --old-bindir=/usr/lib/postgresql/17/bin \
  --new-bindir=/usr/lib/postgresql/18/bin

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

Что в итоге даёт этот подход для базы на полтерабайта

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

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