PostgreSQL 每年发布一个大版本,社区为每个大版本提供 5 年支持。当旧版本接近 EOL,或者新版本带来关键特性(如并行查询改进、增量排序、逻辑复制增强)时,团队就必须面对一次大版本升级。大版本升级与打补丁不同——它涉及物理存储格式变更,不能用简单的二进制替换。可选路径有三条:停机时间最短但要求同机同架构的 pg_upgrade、停机时间几乎为零但需双库并行的逻辑复制、以及最稳妥但最慢的 dump/restore。
核心认知:升级的难点不在执行,而在验证。计划、回滚与兼容性检查决定了升级是十分钟的例行操作,还是整夜的故障排查。
一、升级路径与准备工作
1.1 三条路径的对比
| 路径 | 停机时间 | 磁盘要求 | 跨版本跳跃 | 跨架构 | 复杂度 |
|---|---|---|---|---|---|
| pg_upgrade(拷贝) | 分钟级 | 需双份空间 | 支持(≥9.2) | 否 | 中 |
| pg_upgrade(链接) | 秒级 | 几乎不需 | 支持 | 否 | 中 |
| 逻辑复制 | 秒级切换 | 需双份 | 任意 | 是 | 高 |
| dump/restore | 小时级 | 需双份 | 任意 | 是 | 低 |
1.2 升级前必做的检查
-- 1. 记录当前版本与扩展
SELECT version();
SELECT extname, extversion FROM pg_extension ORDER BY extname;
-- 2. 找出使用中的数据类型与操作符(是否有废弃项)
SELECT DISTINCT typname FROM pg_type t
JOIN pg_attribute a ON a.atttypid = t.oid
JOIN pg_class c ON c.oid = a.attrelid
WHERE c.relkind = 'r' AND a.attnum > 0;
-- 3. 检查是否有未提交的长事务
SELECT pid, now() - xact_start AS dur, state
FROM pg_stat_activity WHERE xact_start IS NOT NULL
ORDER BY dur DESC;
-- 4. 确认备份可用
SELECT pg_current_wal_lsn();
1.3 兼容性检查清单
- 扩展是否有目标大版本的可编译版本(PostGIS、pg_partman 等)
- 是否使用了已废弃的配置参数(如 pre-12 的某些 GUC)
- 是否使用了会在大版本变更行为的语法(如 NOT IN 的 NULL 处理)
- 字符集、排序规则(collation)是否一致
- 应用驱动的协议兼容性(如 SCRAM 认证)
- 是否依赖未公开的内部函数
1.4 升级窗口规划
T-14d 确定目标版本,读 release notes 与 deprecation 清单
T-7d 在预发环境完整演练一次,记录耗时
T-3d 准备回滚脚本,验证备份可恢复
T-0 正式升级:pg_upgrade 或逻辑复制切换
T+1d 观察慢查询、连接错误、扩展行为
T+7d 清理旧版本数据目录与备份
二、pg_upgrade 就地升级
2.1 链接模式 vs 拷贝模式
--link 用硬链接复用旧数据文件,速度快(秒到分钟),但旧集群不可再用
--copy 完整复制数据文件,慢,但升级后旧集群仍可启动(可回滚)
如果磁盘空间充足且重视可回滚性,优先 --copy;如果追求最短停机且已有可靠备份,用 --link。
2.2 安装新版本二进制
# Debian/Ubuntu:新旧版本二进制可以共存
sudo apt install postgresql-16
# 确认两个版本的 bin 目录
ls /usr/lib/postgresql/15/bin/
ls /usr/lib/postgresql/16/bin/
2.3 执行 pg_upgrade
# 1. 停旧集群(或先建好新集群)
sudo pg_ctlcluster 15 main stop
# 2. 初始化新版本数据目录
sudo -u postgres /usr/lib/postgresql/16/bin/initdb -D /var/lib/postgresql/16/main
# 3. 干跑检查(不实际升级)
sudo -u postgres /usr/lib/postgresql/16/bin/pg_upgrade \
--old-datadir=/var/lib/postgresql/15/main \
--new-datadir=/var/lib/postgresql/16/main \
--old-bindir=/usr/lib/postgresql/15/bin \
--new-bindir=/usr/lib/postgresql/16/bin \
--check
# 4. 正式升级(链接模式)
sudo -u postgres /usr/lib/postgresql/16/bin/pg_upgrade \
--old-datadir=/var/lib/postgresql/15/main \
--new-datadir=/var/lib/postgresql/16/main \
--old-bindir=/usr/lib/postgresql/15/bin \
--new-bindir=/usr/lib/postgresql/16/bin \
--link
2.4 升级后的收尾
# 1. 启动新集群
sudo pg_ctlcluster 16 main start
# 2. 重建统计信息(关键!旧统计信息格式可能不兼容)
# 用 pg_upgrade 生成的脚本
./analyze_new_cluster.sh
# 或手动:vacuumdb --all --analyze-in-stages
# 3. 删除旧集群数据目录(确认无误后)
./delete_old_cluster.sh
-- 检查是否有无效索引
SELECT indexrelid::regclass, indrelid::regclass
FROM pg_index WHERE NOT indisvalid;
-- 重建(如有)
REINDEX DATABASE CONCURRENTLY myapp;
2.5 pg_upgrade 的坑
- 新旧集群的 locale / collation 必须兼容,否则索引可能损坏
- 扩展必须先装好新版本,pg_upgrade --check 会检测
- --link 模式下旧集群被破坏,务必先有备份
- 大版本跳跃(如 11 → 16)虽支持,但建议逐级验证
三、逻辑复制的零停机升级
3.1 原理
逻辑复制把逻辑变更(INSERT/UPDATE/DELETE)从旧库实时同步到新库。新库用不同版本、不同架构运行,同步追平后短暂切换写入即可,停机时间接近零。
旧库 (PG15) ──逻辑复制──▶ 新库 (PG16)
▲ ▲
│ 应用读写 │ 只读验证
└──切换瞬间改指向──────────┘
3.2 前置条件
-- 旧库:设置 WAL 级别为 logical
ALTER SYSTEM SET wal_level = 'logical';
-- 需要重启
-- 检查
SHOW wal_level; -- 应为 logical
-- 逻辑复制需要足够多的 WAL sender 槽位
SHOW max_replication_slots; -- 建议 >= 4
SHOW max_wal_senders; -- 建议 >= 10
3.3 建立发布与订阅
-- 在旧库(发布端)为所有表创建 publication
CREATE PUBLICATION upgrade_pub FOR ALL TABLES;
-- 在新库(订阅端)创建订阅
CREATE SUBSCRIPTION upgrade_sub
CONNECTION 'host=old-db port=5432 dbname=myapp user=repl password=secret'
PUBLICATION upgrade_pub
WITH (copy_data = true, create_slot = true);
3.4 需要处理的例外
逻辑复制不复制这些对象,必须在切换前手动同步:
- 序列(sequence)的当前值 → 用 pg_dump -s 或 setval 同步
- DDL 变更(新表、新列、索引)→ 需手动在订阅端执行
- 大对象(large object)→ 不复制
- TRUNCATE → 需在发布端显式订阅(PG11+ 支持)
-- 同步序列:在切换前把新库序列值调到旧库当前值
SELECT setval('orders_id_seq', (SELECT last_value FROM orders_id_seq), true);
3.5 监控复制延迟
-- 旧库(发布端):查看槽位延迟
SELECT slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS lag
FROM pg_replication_slots;
-- 新库(订阅端):查看订阅状态
SELECT subname, received_lsn, latest_end_lsn,
now() - last_msg_receipt_time AS idle
FROM pg_stat_subscription;
3.6 切换步骤
1. 停止应用写入(或设为只读)
2. 等待订阅端 LSN 追平(lag = 0)
3. 同步序列值
4. 在新库上创建索引、约束、外键(若之前为提速而延迟创建)
5. 执行 ANALYZE
6. 修改应用连接串指向新库
7. 恢复写入
8. 删除订阅与槽位
-- 追平判断
SELECT pg_current_wal_lsn() AS old_lsn; -- 旧库
-- 在新库比较:
SELECT pg_last_wal_replay_lsn();
-- 切换前先只读,避免新写入
ALTER DATABASE myapp SET default_transaction_read_only = on;
四、升级后验证与回滚
4.1 验证清单
-- 1. 行数核对(抽样表)
SELECT 'orders' AS tbl, count(*) FROM orders
UNION ALL SELECT 'customers', count(*) FROM customers;
-- 2. 扩展可用
SELECT extname, extversion FROM pg_extension;
-- 3. 索引有效性
SELECT c.relname AS index_name, i.indisvalid, i.indisready
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid OR NOT i.indisready;
-- 4. 序列值
SELECT sequencename, last_value FROM pg_sequences;
-- 5. 慢查询对比
SELECT query, calls, mean_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC LIMIT 20;
4.2 回滚方案
pg_upgrade --copy → 保留旧数据目录,回滚 = 停新启旧
逻辑复制 → 切换前旧库保持可写,回滚 = 连接串改回
dump/restore → 旧库不动,回滚 = 直接继续用旧库
关键原则:在切换后的一段时间内,不要删除旧库。保留一个观察窗口(通常 3~7 天),确认新库稳定后再清理。
4.3 应用层兼容性回归
-- 检查是否存在依赖旧行为的查询
-- 例:pre-12 的 CTE 强制物化,升级后可能计划变化
EXPLAIN (ANALYZE, BUFFERS) <关键查询>;
-- 检查排序规则是否一致
SELECT datcollate, datctype FROM pg_database WHERE datname = current_database();
五、版本间行为差异
5.1 常见的大版本行为变更
| 版本 | 关键变更 | 影响 |
|---|---|---|
| 12 | CTE 可内联、增量排序 | 计划变化,多数变快 |
| 13 | 增量排序改进、B-tree 去重 | 索引变小 |
| 14 | 并行 VACUUM、管道查询 | 维护更快 |
| 15 | 排序性能提升、MERGE 语句 | 新语法可用 |
| 16 | 逻辑复制并行应用、psql 改进 | 复制更快 |
5.2 排序规则的陷阱
-- 不同 glibc 版本的 collation 可能不同,导致索引顺序不一致
SELECT datcollate, datctype FROM pg_database WHERE datname = current_database();
如果升级同时跨越了 glibc 版本,必须重建所有文本索引,否则索引可能返回错误结果:
-- 重建索引以匹配新 collation
REINDEX DATABASE CONCURRENTLY myapp;
5.3 废弃参数与语法
-- 检查无效的配置项
SELECT name, setting FROM pg_settings WHERE name LIKE '%deprecated%';
-- 检查是否有使用废弃语法(如带空格的旧操作符)
SELECT count(*) FROM pg_proc WHERE proname LIKE 'pg_%';
5.4 扩展版本对齐
-- 升级扩展本身
ALTER EXTENSION postgis UPDATE;
ALTER EXTENSION pg_stat_statements UPDATE;
-- 查看可更新的扩展
SELECT name, default_version, installed_version
FROM pg_available_extensions
WHERE installed_version IS NOT NULL AND installed_version <> default_version;
六、性能验证与踩坑
6.1 升级后必做的维护
-- 1. 分阶段 ANALYZE(避免一次性 IO 打满)
-- vacuumdb --all --analyze-in-stages
-- 2. 检查 autovacuum 是否跟上
SELECT relname, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC LIMIT 20;
-- 3. 检查膨胀
SELECT relname, n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / greatest(n_live_tup, 1), 2) AS dead_pct
FROM pg_stat_user_tables
ORDER BY dead_pct DESC LIMIT 20;
6.2 性能回归排查
-- 用 pg_stat_statements 对比升级前后
SELECT queryid, query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 30;
如果某查询变慢,优先检查:统计信息是否重建、计划是否变化(EXPLAIN 对比)、索引是否需要 REINDEX。
6.3 常见踩坑清单
1. 忘记 ANALYZE → 优化器用默认估计,计划全面劣化
2. collation 变化未重建索引 → 索引顺序错误,查询结果异常
3. 扩展未装新版本 → pg_upgrade --check 直接失败
4. 逻辑复制漏同步序列 → 新库插入主键冲突
5. --link 模式无备份 → 升级失败无法回滚
6. 切换后立即删除旧库 → 发现问题无路可退
7. 未验证应用驱动认证方式 → 连接全部失败
6.4 灰度与观察
-- 升级后开启更详细的慢查询日志
ALTER SYSTEM SET log_min_duration_statement = '500ms';
SELECT pg_reload_conf();
-- 监控连接错误与回滚
SELECT datname, xact_rollback, blks_read, blks_hit
FROM pg_stat_database WHERE datname = current_database();
常见问题(FAQ)
pg_upgrade 支持的跨版本范围
官方支持从 9.2 起任意版本跳跃到当前版本,只要新旧二进制都能运行。但跨度越大,兼容性风险越高,建议先在预发环境完整演练,并逐项核对 release notes。
逻辑复制升级时的 DDL 变更处理
逻辑复制不复制 DDL。升级期间应尽量冻结 schema 变更;如果必须变更,需在发布端和订阅端同步手动执行,并确保顺序一致。切换前务必用 pg_dump -s 对比两边 schema。
升级后查询变慢的常见原因
统计信息未重建是最常见原因。旧版本的统计信息格式可能不兼容,升级后优化器只能靠默认估计,导致计划劣化。执行 vacuumdb --all --analyze-in-stages 后绝大多数回归会消失。
是否需要重建所有索引
如果升级跨越了 glibc / collation 版本,是的——文本列的 B-tree 索引依赖 collation,必须 REINDEX。如果 collation 未变,通常不需要,但检查 indisvalid 是必要的。
零停机升级的停机窗口
严格说是「接近零」——切换瞬间需要短暂停止写入(通常秒级),用于等待复制追平、同步序列、切换连接串。对于允许短暂只读的系统,这已经足够;对要求绝对不停写的系统,需要用双写或更复杂的方案。
相关阅读
- PostgreSQL 迁移指南 — 跨数据库迁移与数据搬运
- PostgreSQL 逻辑复制 — 发布订阅、槽位与冲突处理
- PostgreSQL 高可用方案 — Patroni 与滚动升级
- PostgreSQL 备份与恢复 — 升级前的备份策略
- PostgreSQL 监控与诊断体系 — 升级后性能回归排查
- PostgreSQL 专题导航
延伸阅读
- PostgreSQL 统计信息与 ANALYZE — 升级后统计信息重建
- PostgreSQL Docker 部署与初始化 — 容器环境下的版本切换
完整示例(一键复制)
# ========== 方式一:pg_upgrade 就地升级 ==========
# 1. 安装新版本二进制(新旧共存)
sudo apt install postgresql-16
# 2. 停旧集群
sudo pg_ctlcluster 15 main stop
# 3. 初始化新数据目录
sudo -u postgres /usr/lib/postgresql/16/bin/initdb -D /var/lib/postgresql/16/main
# 4. 干跑检查
sudo -u postgres /usr/lib/postgresql/16/bin/pg_upgrade \
--old-datadir=/var/lib/postgresql/15/main \
--new-datadir=/var/lib/postgresql/16/main \
--old-bindir=/usr/lib/postgresql/15/bin \
--new-bindir=/usr/lib/postgresql/16/bin \
--check
# 5. 正式升级(链接模式,快;拷贝模式可回滚)
sudo -u postgres /usr/lib/postgresql/16/bin/pg_upgrade \
--old-datadir=/var/lib/postgresql/15/main \
--new-datadir=/var/lib/postgresql/16/main \
--old-bindir=/usr/lib/postgresql/15/bin \
--new-bindir=/usr/lib/postgresql/16/bin \
--link
# 6. 启动新集群并重建统计信息
sudo pg_ctlcluster 16 main start
vacuumdb --all --analyze-in-stages
-- ========== 方式二:逻辑复制零停机升级 ==========
-- 旧库(发布端):开启逻辑 WAL 后重启
ALTER SYSTEM SET wal_level = 'logical';
ALTER SYSTEM SET max_replication_slots = 8;
ALTER SYSTEM SET max_wal_senders = 10;
-- 旧库:创建发布
CREATE PUBLICATION upgrade_pub FOR ALL TABLES;
-- 新库:创建订阅(自动拷贝存量数据)
CREATE SUBSCRIPTION upgrade_sub
CONNECTION 'host=old-db port=5432 dbname=myapp user=repl password=secret'
PUBLICATION upgrade_pub
WITH (copy_data = true, create_slot = true);
-- 监控延迟(旧库)
SELECT slot_name, active,
pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS lag
FROM pg_replication_slots;
-- 监控订阅状态(新库)
SELECT subname, received_lsn, latest_end_lsn,
now() - last_msg_receipt_time AS idle
FROM pg_stat_subscription;
-- 切换前:同步序列值
SELECT setval('orders_id_seq', (SELECT last_value FROM orders_id_seq), true);
-- 切换前:只读冻结旧库写入
ALTER DATABASE myapp SET default_transaction_read_only = on;
-- 切换后:清理
DROP SUBSCRIPTION upgrade_sub; -- 在新库执行
DROP PUBLICATION upgrade_pub; -- 在旧库执行
-- ========== 升级后验证 ==========
-- 索引有效性
SELECT c.relname AS index_name, i.indisvalid, i.indisready
FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid OR NOT i.indisready;
-- 扩展版本对齐
SELECT name, default_version, installed_version
FROM pg_available_extensions
WHERE installed_version IS NOT NULL AND installed_version <> default_version;
-- 统计信息新鲜度
SELECT relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
ORDER BY last_analyze NULLS FIRST LIMIT 20;
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。