PostgreSQL 的大版本之间不保证数据目录格式兼容,从 16 升到 17 不是换个二进制重启就完事。官方给的主要路径有两条,机制完全不同,选错的代价在升级当晚才显现。

路径一:pg_upgrade

pg_upgrade 的做法是让新旧两个版本的服务器实例同时存在于机器上,把旧集群的数据文件搬到新集群的目录里,然后重写系统目录(catalog)。用户表的数据文件本身不做转换,因为大版本之间表文件的物理格式通常是稳定的,变的是系统表的结构。

它要求机器上同时装着两个版本的 binary:

pg_upgrade --check \
  --old-datadir=/var/lib/postgresql/16/main \
  --new-datadir=/var/lib/postgresql/17/main \
  --old-bindir=/usr/lib/postgresql/16/bin \
  --new-bindir=/usr/lib/postgresql/17/bin

--check 只做校验不动数据,必须在真正升级之前跑,不是等进了升级窗口才跑。它会检查扩展兼容性、数据类型是否还存在、reg* 类型的列,以及 collation 版本问题。

真正升级时有一个关键选项是数据文件怎么搬:

  • --copy(默认):完整复制一份。需要大约等于数据库大小的额外磁盘空间,但升级失败或者升级后发现问题,旧集群目录原封不动,可以直接回退。
  • --link:用硬链接。几乎不占额外空间,速度快得多,但新旧集群共享同一批数据文件——一旦新版本启动并写入,旧集群就废了,回退只能靠备份。

--link 的不可回退性最容易被低估。硬链接不是快照,两个目录名指向同一份 inode,新集群第一次 checkpoint 就把旧集群的数据改了。

升级完 pg_upgrade 会生成两个脚本:analyze_new_cluster.sh 更新统计信息,要跑;delete_old_cluster.sh 删旧集群,等确认新版本稳定之后再跑,别当天就删。

路径二:pg_dump + pg_restore

这条路径完全走逻辑层:从旧版本导出 SQL 或者归档格式,在新版本上重建。

pg_dump -Fc -j 4 -f app.dump appdb
# 起新版本实例,建库,然后
pg_restore -j 4 -d appdb app.dump

-Fc 是自定义归档格式,支持 -j 并行和选择性恢复;-Fp(纯 SQL 文本)不能并行,好处是能直接用编辑器看和改。

它的成本是时间:所有数据都要过一遍 SQL 层的编解码和索引重建。好处是完全不受物理格式约束——跨大版本、跨平台、跨架构(x86 到 ARM),甚至跨不同的初始化参数(block size、locale/encoding 组合)都能走通。

顺带能做的事不少:清理表膨胀、删掉不再用的索引、去掉废弃的列、重新规划表空间。这些在 pg_upgrade 路径下都没机会做,因为它搬的是原样的文件。

四个维度的对比

停机窗口。 --copy 的时间主要花在复制数据文件和重写系统目录上,和数据库大小成正比;--link 把复制换成建硬链接,快得多;dump/restore 还要重建所有索引,通常最慢。具体时长强依赖磁盘吞吐、核数和索引总量,我不给数字——同一份数据在 NVMe 和网络存储上的差别可能是几倍。

磁盘余量。 --copy 要额外一份数据库大小的空间;--link 几乎不要;dump/restore 要一份 dump 文件的空间,同机并行跑两个实例的话还得算上 WAL 和临时空间。

回退能力。 --copy 最强,旧目录没被动过。--link 最弱,旧集群在新集群第一次写入后就不可用。dump/restore 居中——旧实例可以原地保留不启动,只要它的磁盘空间没被新实例占用。

能否顺带整理。 只有 dump/restore 能。这是它唯一无法被替代的优势。

第三条路:逻辑复制

停机窗口几乎为零时上面两条都不合适。做法是在新版本上建空库,旧库作 publisher、新库作 subscriber,同步追平后切流量,停机只剩切换那一瞬。

代价是:表要有主键或 REPLICA IDENTITY;大对象不在逻辑复制范围内;序列值要手动对齐;DDL 不同步,所以窗口内旧库不能改 schema。复杂度和前两条不是一个量级,只有窗口真挤不出来时才值得。

一个容易漏掉的前置问题:collation

不管走哪条路,都要注意 glibc 变化导致的 collation 版本不一致。索引顺序依赖 collation 规则,规则变了而索引没重建,查询会静默返回错的行。

pg_upgrade --check 会报告这类问题。dump/restore 路径天然规避了它,因为索引是在新环境里从头建的。

如果走 pg_upgrade 且被报了 collation 问题,处理办法是对受影响的索引重建:

REINDEX DATABASE CONCURRENTLY appdb;

CONCURRENTLY 不阻塞写入,但耗时长、失败时可能留下 INVALID 索引。这一步安排在升级完成之后、删旧集群之前。

我的选择

几十 GB 量级、有维护窗口、不打算顺带改 schema,我用 pg_upgrade --copy。多花的磁盘换来"出问题能退回去",这笔交易在夜里三点的升级窗口里很值。

数据量大到 --copy 的磁盘开销受不了,我才考虑 --link,前提是有完整可用的备份,且恢复时间实际量过而不是估的。

需要顺带清理膨胀、重建索引策略、或者要跨架构迁移,走 dump/restore。慢一点,但一次做完两件事。

停机窗口挤不出来,才上逻辑复制。

分情况的地方是扩展。用了 PostGIS、pgvector、TimescaleDB 这类带 C 代码的扩展时,pg_upgrade 要求新版本上有对应的扩展二进制且版本匹配;dump/restore 则要求扩展能在新版本上从头装好并重建数据。两条路都可能被扩展卡住,位置还不一样。升级前先在测试环境确认,这一步省不掉。

还有一件我不确定的事:pg_upgrade 对某些特殊列类型和自定义类型的处理,在不同版本组合下有过 bug 记录。我说不出具体是哪几个版本,所以我的做法是把 --check 的输出完整存档逐行看,不只看最后那句成功或失败。