После pg_upgrade приложение стартует, а запрос с двумя коррелированными условиями получает оценку 187 строк вместо 19 333 до обновления: каталог перенесён, а часть статистики планировщика потеряна. Обновление мажорной версии складывается из трёх разных вещей: проверка до остановки, перенос данных (копирование, жёсткие ссылки или логическая репликация) и работа с планами после. У каждой своя цена в минутах простоя и свой момент, после которого назад дороги нет.
Для общей картины реплик сначала прочитай логическую репликацию и декодирование WAL, а про то, откуда берутся оценки планировщика, — статистику ANALYZE. Здесь измерены оба пути обновления 16 → 18 на одной версии образа.
Масштаб: в карточке этой темы стоит база 50 ГБ, на этой машине такой нет. pg_upgrade измерен на базе 840 МБ (каталог 1,1 ГБ вместе с WAL), логическая репликация — на базе 307 МБ под нагрузкой 200 транзакций в секунду. Времена ниже относятся к этим объёмам, один запуск каждого режима, кэш страниц тёплый; пересчитывать их на 50 ГБ нельзя.
Два пути обновления 16 → 18 и длина окна без записи на каждом шаге
Стенд
Контейнер postgres:18 и старая версия из PGDG внутри него; команды ниже выполняются от пользователя postgres:
docker run -d --name dba-upgrade-one --memory 2g -e POSTGRES_PASSWORD=pg postgres:18
docker exec dba-upgrade-one bash -c 'apt-get update -qq; apt-get install -y -qq postgresql-16'
B=/usr/lib/postgresql; W=/var/lib/postgresql/w
$B/16/bin/initdb -D $W/o16 --data-checksums -A trust
$B/16/bin/pg_ctl -D $W/o16 -o "-p 5433 -k /tmp" -w start
$B/16/bin/pgbench -h /tmp -p 5433 -U postgres -i -s 50 postgres
Флаг --data-checksums нужен потому, что initdb 18 включает контрольные суммы по умолчанию, а pg_upgrade требует одинаковых настроек у обоих кластеров (примечания к выпуску 18). К pgbench добавлена таблица с коррелированными колонками и расширенной статистикой:
create table orders as select g as id,
case when random() < 0.5 then 1 else (random()*1000)::int end as status,
g % 100 as a, g % 100 as b from generate_series(1, 2000000) g;
create statistics orders_ab (dependencies) on a, b from orders;
analyze orders;
Состояние перед обновлением: версия, размер базы, число строк в pg_stats схемы public, число строк в pg_statistic_ext_data и оценки двух запросов (status = 1 и a = 5 and b = 5). По построению таблицы условию a = 5 and b = 5 отвечают 20 000 строк.
Вывод:
PostgreSQL 16.15 (Debian 16.15-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
840 MB
15
1
Seq Scan on orders (cost=0.00..35816.00 rows=996667 width=16)
Gather (cost=1000.00..26249.30 rows=19333 width=16)
—check: что проверяется до остановки
Новый кластер создаётся пустым через initdb, затем pg_upgrade запускается с --check. Режим ничего не меняет в кластерах:
pg_upgrade -b $B/16/bin -B $B/18/bin -d $W/a16 -D $W/na -o "-k /tmp" -O "-k /tmp" --check
Вывод:
Checking cluster versions ok
Checking database connection settings ok
Checking database user is the install user ok
Checking for prepared transactions ok
Checking for contrib/isn with bigint-passing mismatch ok
Checking data type usage ok
Checking for not-null constraint inconsistencies ok
Checking for presence of required libraries ok
Checking database user is the install user ok
Checking for prepared transactions ok
Checking for new cluster tablespace directories ok
*Clusters are compatible*
[время: 0,573 с]
Порядок проверок задаёт check_and_dump_old_cluster() в src/bin/pg_upgrade/check.c: сначала старый кластер стартует, потом идут проверки пользователя, подготовленных транзакций и контрибного isn, после них check_for_data_types_usage() (REL_18_6, строки 616–618):
check_is_install_user(&old_cluster);
check_for_prepared_transactions(&old_cluster);
check_for_isn_and_int8_passing_mismatch(&old_cluster);
Те же имена видны в выводе выше как строки «Checking…». Расширения проверяет check_loadable_libraries() уже на новом кластере: в check_new_cluster() она вызывается до проверок пользователя и подготовленных транзакций нового кластера. Чтобы увидеть, чем это грозит, в отдельный кластер 16 поставлено расширение vector, которого нет в пакетах 18, и снова запущен --check.
Вывод:
vector|0.8.7
Checking for prepared transactions ok
Checking for contrib/isn with bigint-passing mismatch ok
Checking data type usage ok
Checking for not-null constraint inconsistencies ok
Checking for presence of required libraries fatal
Your installation references loadable libraries that are missing from the
new installation. You can add these libraries to the new installation,
or remove the functions using them from the old installation. A list of
problem libraries is in the file:
/var/lib/postgresql/w/nx/pg_upgrade_output.d/20261028T120252.110/loadable_libraries.txt
Failure, exiting
[время: 0,538 с]
could not load library "$libdir/vector": ERROR: could not access file "$libdir/vector": No such file or directory
In database: postgres
--check не прошёл за 0,538 с и назвал библиотеку и базу, где она нужна. Правило: каждое расширение из pg_extension старого кластера ставится пакетом для версии 18 до обновления.
Этапы и режимы переноса
Тот же запуск без --check проходит три группы этапов: проверки с дампом схемы старого кластера, подготовку нового кластера (в том числе перенос счётчиков транзакций и мультитранзакций) и перенос файлов. Режим по умолчанию — копирование; --link создаёт жёсткие ссылки. Отличие зафиксировано в main() (src/bin/pg_upgrade/pg_upgrade.c, REL_18_6, строки 174–183):
/*
* Most failures happen in create_new_objects(), which has completed at
* this point. We do this here because it is just before file transfer,
* which for --link will make it unsafe to start the old cluster once the
* new cluster is started, and for --swap will make it unsafe to start the
* old cluster at all.
*/
if (user_opts.transfer_mode == TRANSFER_MODE_LINK ||
user_opts.transfer_mode == TRANSFER_MODE_SWAP)
disable_old_cluster(user_opts.transfer_mode);
Перед переносом файлов в режимах --link и --swap старый кластер отключается: его нельзя запускать, когда файлы станут общими. Фрагмент лога режима --link, вывод целиком из одного запуска:
Restoring global objects in the new cluster ok
Restoring database schemas in the new cluster ok
Adding ".old" suffix to old "global/pg_control" ok
If you want to start the old cluster, you will need to remove
the ".old" suffix from "/var/lib/postgresql/w/b16/global/pg_control.old".
Because "link" mode was used, the old cluster cannot be safely
started once the new cluster has been started.
Linking user relation files ok
Setting next OID for new cluster ok
Sync data directory to disk ok
Строка «Adding “.old” suffix» относится к global/pg_control старого кластера. Что осталось после запуска:
[время: 2,264 с]
--- ссылки и размер
base/5/16396
2 ссылок /var/lib/postgresql/w/b16/base/5/16396
2 ссылок /var/lib/postgresql/w/nb/base/5/16396
1.1G /var/lib/postgresql/w/b16
40M /var/lib/postgresql/w/nb
1.1G /var/lib/postgresql/w/o16
У файла таблицы pgbench_accounts две ссылки: одна в старом каталоге, вторая в новом. du считает общий файл один раз, поэтому новый каталог добавил к старому всего 40 МБ. Попытка запустить старый кластер после --link:
waiting for server to start.... stopped waiting
pg_ctl: could not start server
Examine the log output.
postgres: could not find the database system
Expected to find it in the directory "/var/lib/postgresql/w/b16",
but could not open file "/var/lib/postgresql/w/b16/global/pg_control": No such file or directory
pg_control.old
Времена на идентичных копиях данных: --check 0,573 с, копирование (режим по умолчанию) 3,055 с, --link 2,264 с. Разница на 840 МБ при тёплом кэше — 0,8 с: копирование читает и записывает каждый байт, а --link создаёт ссылки; этапы проверки, дампа и восстановления схемы у режимов общие. Как разница растёт на холодном диске и на базе в сотни гигабайт, на этой машине не измерено — повтори запуск на своём железе.
Отсюда правило отката. В выводе режима copy строки про суффикс .old нет: старый кластер остаётся запускаемым. В режиме --link до старта нового сервера старый возвращается переименованием pg_control.old обратно, а после старта нового pg_upgrade прямо предупреждает: «the old cluster cannot be safely started once the new cluster has been started». Любая запись на новом сервере в старый кластер уже не попадёт, и возврат означает восстановление из резервной копии.
Статистика после pg_upgrade
В PostgreSQL 18 pg_upgrade сохраняет статистику оптимизатора, но расширенную не сохраняет (примечания к выпуску 18; отключается ключом --no-statistics). Утилита сама напоминает об этом в конце вывода режима --link:
Some statistics are not transferred by pg_upgrade.
Once you start the new server, consider running these two commands:
/usr/lib/postgresql/18/bin/vacuumdb --all --analyze-in-stages --missing-stats-only
/usr/lib/postgresql/18/bin/vacuumdb --all --analyze-only
На кластере после копирования:
Вывод:
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
15
0
Seq Scan on orders (cost=0.00..35816.00 rows=996667 width=16)
Gather (cost=1000.00..24334.70 rows=187 width=16)
orders|2000000|10816
pgbench_accounts|5000000|81968
orders|0||
Строки по порядку: версия, число строк pg_stats (было 15), число строк pg_statistic_ext_data (было 1), оценка для status = 1 (996 667 строк, как и до обновления), оценка для a = 5 and b = 5, затем reltuples и relpages, затем счётчики pg_stat_user_tables. Оценка по коррелированной паре упала с 19 333 до 187 при истинных 20 000: строки pg_stats одиночных колонок переехали (15 из 15), а зависимость между колонками исчезла, и оценка стала близка к произведению селективностей, 2 000 000 / 100 / 100 = 200. Счётчики n_live_tup и last_analyze пусты: кумулятивная статистика не переносится.
vacuumdb --analyze-in-stages --missing-stats-only добирает только недостающее:
vacuumdb: processing database "postgres": Generating minimal optimizer statistics (1 target)
vacuumdb: processing database "template1": Generating minimal optimizer statistics (1 target)
vacuumdb: processing database "postgres": Generating medium optimizer statistics (10 targets)
vacuumdb: processing database "template1": Generating medium optimizer statistics (10 targets)
vacuumdb: processing database "postgres": Generating default (full) optimizer statistics
vacuumdb: processing database "template1": Generating default (full) optimizer statistics
[время: 0,153 с]
1
Gather (cost=1000.00..26482.70 rows=21667 width=16)
Три стадии — это default_statistics_target 1, 10 и штатный: сначала быстро появляется грубая статистика, потом уточняется. Расширенная статистика вернулась (1 строка в pg_statistic_ext_data), оценка стала 21 667, все три стадии заняли 0,153 с. Без --missing-stats-only команда переанализирует всё: на кластере после --link вызов с одним --analyze-in-stages занял 1,191 с. По документации vacuumdb --missing-stats-only ещё и не даёт временно заменять существующую статистику более грубой, пока идут стадии, — после обновления на 18 нужна именно эта форма.
Через логическую репликацию
Два контейнера в одной сети, у старого wal_level=logical:
docker network create dba-upgrade-net
docker run -d --name dba-upgrade-v16 --network dba-upgrade-net --memory 2g -e POSTGRES_PASSWORD=pg postgres:16 -c wal_level=logical -c max_wal_size=512MB
docker run -d --name dba-upgrade-v18 --network dba-upgrade-net --memory 2g -e POSTGRES_PASSWORD=pg postgres:18 -c max_wal_size=512MB
База app на 16: pgbench -i -s 20 и таблица events(id bigserial primary key, note text). На 18 схема переносится pg_dump -s, потому что DDL не реплицируется (подробнее в статье про логическую репликацию). Дальше:
create publication app_pub for all tables; -- на 16
create subscription app_sub connection 'host=dba-upgrade-v16 dbname=app user=postgres password=pg'
publication app_pub; -- на 18
Нагрузка — pgbench -R 200 -c 4: UPDATE pgbench_accounts и INSERT INTO events в каждой транзакции. Переключение выполняет один скрипт: закрыть запись на 16 (default_transaction_read_only и pg_terminate_backend клиентов pgbench), взять pg_current_wal_lsn(), ждать, пока confirmed_flush_lsn слота дойдёт до него, выставить последовательность через setval, отключить подписку, сделать первую запись на 18.
Вывод:
PostgreSQL 16.15 (Debian 16.15-1.pgdg13+
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2)
done in 1.39 s (drop tables 0.00 s, create tables 0.01 s, client-side generate 0.87 s, vacuum 0.12 s, primary keys 0.39 s).
307 MB
NOTICE: created replication slot "app_sub" on publisher
начальная копия: 2556 мс
events|r
pgbench_accounts|r
pgbench_branches|r
pgbench_history|r
pgbench_tellers|r
### лаг перед переключением (на старом сервере)
00:00:00.000203|00:00:00.159179|00:00:00.000203
### последовательность на новом сервере до переключения
1|f
2220
### ПЕРЕКЛЮЧЕНИЕ
4
2224
2225
Начальная копия 307 МБ заняла 2556 мс при идущей записи; к моменту переключения лаг подписки по pg_stat_replication — write_lag 0,2 мс, flush_lag 159 мс. Последовательность events_id_seq на новом сервере показывает 1|f, хотя в таблице уже 2220 строк: документация прямо говорит, что данные последовательностей не реплицируются. Без setval следующий nextval на 18 вернул бы 1, а такой id в таблице уже есть; после setval первая запись получила 2225 (последний id на старом сервере — 2224).
Окно без записи по шагам и сверка:
запись закрыта -> LSN снят: 62 мс
ожидание догонки подписки: 580 мс
setval последовательности: 24 мс
отключение подписки: 22 мс
первая запись на новом сервере: 23 мс
ОКНО без записи, итого: 713 мс
### сверка
16: 2224|-125077|0
18: 2225|-125077|0
Окно 713 мс; из них 580 мс — догонка подписки. На 16 к моменту закрытия записи было 2224 вставки, на 18 стало 2225 (плюс первая запись на новом сервере), сумма abalance совпала: -125077.
Что из этого следует
- Простой.
pg_upgrade --linkна 840 МБ занял 2,264 с, весь запуск без записи, плюс время остановки и запуска сервисов, которое не измерялось. Логическая репликация держит окно 713 мс, но платит начальной копией (2556 мс на 307 МБ при записи) и ручной подготовкой схемы и последовательностей. - Планы. После
pg_upgrade18 данные расширенной статистики пусты, и коррелированные условия оцениваются на два порядка ниже факта (187 против 20 000);vacuumdb --analyze-in-stages --missing-stats-onlyвозвращает их за 0,153 с на этой базе. - Откат. Пока новый сервер не принимал записи, старый остаётся источником данных в обоих путях. После первой записи на 18 возврат без обратной подписки теряет эту запись; в опыте обратной подписки нет.
Чек-лист
- Поставь на новый сервер пакеты всех расширений из
pg_extensionи прогониpg_upgrade --checkна копии каталога. - Выбери режим по допустимому простою:
--linkдля минимального времени без записи, копирование там, где нужен целый старый кластер после старта нового. - Сразу после запуска нового сервера выполни
vacuumdb --all --analyze-in-stages --missing-stats-only, пока приложение не пошло на полную нагрузку, затемvacuumdb --all --analyze-only: по документации pg_upgrade он обновляет кумулятивную статистику, по которой autovacuum решает, когда чистить и анализировать (в опытеn_live_tupиlast_analyzeпосле обновления пусты). - Для пути через логическую репликацию подготовь схему, первичные ключи и
setvalдля каждой последовательности: сами они не переезжают. - Удали контейнеры и их тома:
docker rm -f -v dba-upgrade-one dba-upgrade-v16 dba-upgrade-v18, затемdocker network rm dba-upgrade-net.