PostgreSQL 大版本升级

PostgreSQL 大版本升级全流程指南。涵盖升级前兼容性评估、pg_upgrade 的链接与拷贝模式、逻辑复制的零停机滚动升级、升级后 ANALYZE 与统计信息重建、回滚方案设计,以及各版本间的行为差异与扩展兼容性检查。

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 常见的大版本行为变更

版本关键变更影响
12CTE 可内联、增量排序计划变化,多数变快
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 是必要的。

零停机升级的停机窗口

严格说是「接近零」——切换瞬间需要短暂停止写入(通常秒级),用于等待复制追平、同步序列、切换连接串。对于允许短暂只读的系统,这已经足够;对要求绝对不停写的系统,需要用双写或更复杂的方案。


相关阅读

延伸阅读


完整示例(一键复制)

# ========== 方式一: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;

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 时序数据工作负载
  2. PostgreSQL 数据类型深入
  3. PostgreSQL PostGIS 地理空间