☰
PostgreSQL 13到15升级实战:RHEL上pg_upgrade原理与避坑指南
2026/10/19 5:18:37 网站建设 项目流程

生产环境里的PostgreSQL从13升到15,这件事我在RHEL上反复做过多次,最终沉淀下来的方案就是pg_upgrade。最早我也试过逻辑备份恢复,1.2TB的库估算停机六个小时以上,团队根本没法接受;后来换用pg_upgrade配合link模式,整个切换窗口压缩到半小时以内,业务侧只感知到一次常规重启。

这篇文章不会只扔命令,而是把我在RHEL上使用pg_upgrade做版本升级的完整过程、底层原理和踩坑点一次性写清楚。适合正在规划PostgreSQL大版本升级、尤其是存量数据比较大的单机实例,也适合第一次接触pg_upgrade、想知道它凭什么能比pg_dump快一个数量级的同学。

1. 升级前必须想清楚的事:为什么pg_upgrade才是RHEL存量实例的正道

1.1 三种主流升级方案的对比

PostgreSQL大版本升级有常规的三条路:逻辑备份恢复、流复制切换、pg_upgrade物理升级。三选一之前,先看它们各自的时间和空间成本。

方案原理停机时间额外空间复杂度适合场景
pg_dump/restore导出SQL再导入,纯逻辑拷贝与数据量成正比,TB级可达数小时需要与数据量相当的导出文件空间低小库、需要顺便整理数据的场景
流复制切换新版本搭建备库,追平日志后切换通常几分钟,最快可秒级需要完整拷贝一份数据高有冗余硬件、追求最小停机的生产环境
pg_upgrade物理文件复用,元数据重写分钟级,与对象数量相关而非数据量link模式几乎为零中RHEL单机实例的主流选择

pg_upgrade最大的优势在于它不搬运数据本身。数据库实例的大部分体积来自用户表堆文件和索引文件,这些物理文件的格式在相邻大版本之间基本稳定,完全可以复用。需要处理的其实是系统目录(pg_catalog)里的元数据版本差异,这部分体量很小,所以它才能做到分钟级完成。

1.2 什么场景不适合pg_upgrade

pg_upgrade不是万能药,碰到这几种情况建议绕道走:

  • 版本跨度过大。官方支持从两个大版本之前直接升级,比如PG 16支持从14和15升上来。如果还在PG 12甚至更早,就得先逐级升到可用路径附近,再走pg_upgrade。
  • 想顺便物理重组数据。如果你的真实需求是清理膨胀、重新分布大表、修改表空间路径,pg_upgrade并不能替你完成,它默认保持文件的物理布局。
  • 平台架构发生变化。比如从32位切到64位,不同字节序之间不能直接复用文件,必须做逻辑迁移。
  • 需要同步升级操作系统大版本。虽然很多人在RHEL 7升RHEL 8时也这么干过,但操作系统层面的数据目录路径、SELinux上下文、systemd服务单元都可能受影响,建议先独立演练。
  • 磁盘空间紧张且无法保证备份。即便使用link模式,正式升级前依然应该有可靠备份兜底,这一点后面单独展开。

一句话总结:凡是"能接受数据文件原样保留、目标是换一个更新的数据库引擎"的场景,pg_upgrade就是性价比天花板。

2. 环境准备:让新旧版本在RHEL上和平共处

2.1 安装与目录结构

RHEL自带的软件源里PostgreSQL版本通常偏旧,所以我会优先配置PostgreSQL官方的RHEL仓库。在RHEL 8或9上,安装新版本和旧版本互不干扰,这是物理升级的底气所在。

dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-$(rpm -E %rhel)-x86_64/pgdg-redhat-repo-latest.noarch.rpm dnf -qy module disable postgresql dnf install -y postgresql16-server

这里有个容易忽略的细节:RHEL 8开始默认启用了postgresql的AppStream模块,如果不先module disable,dnf解析依赖时可能被系统自带的旧版本干扰。我在一次演练环境中就遇到过dnf把postgresql-server解析到AppStream自带版本的情况,排查了半天才意识到是模块流冲突。

安装完成后,目录结构如下:

  • 二进制目录:/usr/pgsql-15/bin和/usr/pgsql-16/bin并存
  • 数据目录:/var/lib/pgsql/15/data和/var/lib/pgsql/16/data
  • 服务单元:postgresql-15.service和postgresql-16.service

注意/var/lib/pgsql本身可能是个软链接。RHEL的官方RPM在首次初始化时,会通过postgresql-setup --initdb创建指向具体版本目录的软链。手动升级时,这个链接的切换时机很关键,后面核心操作链路会专门演示。

2.2 initdb的参数和坑

新数据目录不会自动出现,必须先用新版本的initdb手动初始化。这一步的坑在于参数要和旧实例一致,否则pg_upgrade会在检查阶段直接报错。

sudo -u postgres /usr/pgsql-16/bin/initdb -D /var/lib/pgsql/16/data --encoding=UTF8 --locale=en_US.UTF-8

怎么确认旧实例的编码和locale?别凭记忆,直接查:

sudo -u postgres psql -p 5432 -c "SHOW server_encoding;" sudo -u postgres psql -p 5432 -c "SHOW lc_collate;" sudo -u postgres psql -p 5432 -c "SHOW lc_ctype;"

initdb时这几个参数只要有一个和旧实例不一致,pg_upgrade检查阶段就会抓住,不会让你稀里糊涂升级。所以与其在报错后返工,不如一开始就查清楚。另外,新数据目录的所有者和属组必须是postgres,否则以postgres用户运行pg_upgrade时会碰到权限拒绝。

2.3 磁盘空间和权限

虽然link模式本身不复制数据,但还是建议预留一定空间。原因有两层:一是pg_upgrade的检查阶段需要写入临时文件,二是万一遇到硬件或文件系统层面的意外,可能退回copy模式。保守做法是至少预留与单个最大表相当的空闲空间,如果是紧急情况,最少也要保证几百MB的余量。

RHEL上SELinux也是一个隐性变量。标准路径/var/lib/pgsql下的数据目录会自动匹配postgresql的SELinux上下文,但如果你把新版本数据目录放到自定义挂载点,比如/data/postgresql/16,就需要手动处理上下文或临时关闭SELinux进行排除。我在非标准路径上踩过一次,现象是初始化正常、启动也正常,但pg_upgrade一操作就有诡异的权限日志。后来用restorecon -Rv恢复上下文才解决。

3. 工作机制拆解:link模式为什么能做到分钟级完成

3.1 硬链接:不搬书,只换索引

pg_upgrade有两种模式:默认的copy模式和--link指定的硬链接模式。理解link模式前,先理解文件系统层面的硬链接。

普通文件在磁盘上有一份数据区块,目录项是指向这份数据区块的引用。硬链接本质上是给同一份数据区块再增加一个目录项引用,两个路径名指向同一个inode。通过任一路径修改文件内容,另一个路径看到的也是同一份内容,因为本来就是同一份物理数据。

pg_upgrade的link模式做了这样一件事:新数据目录里,凡是与旧目录中文件内容一致的数据文件,不做复制,而是创建硬链接,让新旧两个目录项指向同一份物理数据。这个操作是常数级的,与文件大小无关,所以几百GB的库也能在几秒内完成文件映射。

这就像图书馆搬馆:书脊上的标签(元数据)需要按新规则重贴,但书本身不需要重新抄写一遍,直接在新馆的柜台上登记一个指向旧书架的引用就行。

3.2 pg_upgrade到底"升级"了什么

很多人以为pg_upgrade把数据文件全部重写一遍,这是误解。它真正做的事情可以拆成三层:

第一层,新数据目录由initdb生成,天然带有新版本的系统目录、内置对象和默认配置骨架。

第二层,pg_upgrade遍历旧数据目录,把用户数据文件、索引文件、CLOG文件等通过link或copy方式挂到新目录下。

第三层,也是最核心的一层,pg_upgrade根据新旧版本之间的系统目录结构差异,重写新实例中的系统目录条目,使其能正确理解旧版本落盘的用户数据格式。这个过程依赖工具内置的映射规则,专门处理系统表的字段变化、内置函数oid变化、类型长度调整等问题。

用户表堆文件在相邻大版本之间通常不需要重写,但有一类对象例外:少数索引类型在不同大版本间物理格式不兼容,比如某些版本的hash索引需要重建。pg_upgrade的检查阶段会识别这类对象并给出警告或错误,这也是为什么check必须跑通才能继续。

3.3 为什么check阶段如此重要

pg_upgrade的--check模式不修改任何数据,只做全量兼容性体检。它会遍历所有数据库,校验以下项目:

  • 新旧版本路径、数据目录是否存在、用户权限
  • locale、编码、数据库模板是否一致
  • 所有已安装扩展的版本兼容性
  • 大对象、孤儿文件、无效索引
  • 表空间路径和权限
  • 是否有未提交的预备事务、未清理的孤儿事务

check报错的常见原因集中在扩展缺失和locale不一致。扩展问题有个特点:check阶段有时只给警告,不会阻塞升级,但升级后业务查询到相关功能就会报错。所以check完成后,不要只看exit code,还要逐行阅读输出,尤其关注"WARNING"字样。

正式升级时建议加-j参数并行执行检查,多核机器上速度提升非常明显。我在一个两万多个表的实例上试过,-j 8比单线程快三倍以上。

4. 核心操作链路:从一致性检查到目录切换的完整流程

4.1 停旧库,创建新实例

升级窗口开始后的第一件事是干净停库。不要用kill -9,要让PostgreSQL完成checkpoint并释放所有文件句柄。

sudo systemctl stop postgresql-15

确认旧库确实停了,再初始化新数据目录。这里有一个顺序问题:initdb必须在新目录为空的状态下执行,而且务必用新版本的initdb。

sudo -u postgres /usr/pgsql-16/bin/initdb -D /var/lib/pgsql/16/data --encoding=UTF8 --locale=en_US.UTF-8

4.2 第一次check:提前暴露问题

正式动数据之前,永远先跑check。命令保持和正式升级一致,只是末尾加--check。

sudo -u postgres /usr/pgsql-16/bin/pg_upgrade \ --old-bindir=/usr/pgsql-15/bin \ --new-bindir=/usr/pgsql-16/bin \ --old-datadir=/var/lib/pgsql/15/data \ --new-datadir=/var/lib/pgsql/16/data \ --username=postgres \ --check

参数含义逐个讲:

  • --old-bindir/--new-bindir:新旧版本的bin目录,pg_upgrade需要调用两个版本的二进制来比对和取数。
  • --old-datadir/--new-datadir:新旧数据目录路径。
  • --username:以哪个用户执行分析操作,生产环境一律postgres。
  • --check:只做兼容性检查,不修改任何数据。

我第一次跑check时遇到两个典型错误:一是新数据目录的locale没设对,报"database locale is incompatible";二是旧库装了postgis但新环境没装,报extension相关的兼容warning。前者必须修正initdb参数后重新初始化,后者需要先去装对应软件包再回来check。

check失败不可怕,可怕的是不读输出硬着头皮继续。pg_upgrade已经尽力把风险都放在check阶段暴露,这个阶段花五分钟,后面省两小时。

4.3 正式升级:link模式接管文件

check通过后,去掉--check,加上--link和-j,执行正式升级:

sudo -u postgres /usr/pgsql-16/bin/pg_upgrade \ --old-bindir=/usr/pgsql-15/bin \ --new-bindir=/usr/pgsql-16/bin \ --old-datadir=/var/lib/pgsql/15/data \ --new-datadir=/var/lib/pgsql/16/data \ --username=postgres \ --link \ -j 4

这里有一个物理前提:link模式要求新旧数据目录必须在同一个文件系统上,因为硬链接无法跨文件系统创建。RHEL的默认安装路径下通常没问题,但如果你把新数据目录放到了独立挂载的数据盘上,就要三思。

执行过程中会看到类似下面的输出,每一行表示一个正在处理的阶段:

Creating a symbolic link for files... Performing upgrade... Analyzing all rows in the new cluster...

升级结束后,pg_upgrade会在新数据目录下生成两个脚本:analyze_new_cluster.sh和delete_old_cluster.sh。前者用于更新统计信息,后者用于清理旧集群,两个脚本执行时机天差地别。

4.4 目录切换与服务启动

pg_upgrade只是把文件映射到了新的数据目录,系统还不知道该用哪个版本。此时需要处理/var/lib/pgsql这个软链接的指向。

先备份旧链接,再指向新版本:

sudo mv /var/lib/pgsql /var/lib/pgsql.bak sudo ln -s /var/lib/pgsql/16 /var/lib/pgsql

然后启动新版本服务:

sudo systemctl enable --now postgresql-16

启动后立刻检查进程和端口:

sudo -u postgres /usr/pgsql-16/bin/psql -p 5432 -c "SELECT version();"

看到输出是新版本号,说明物理层迁移成功。但先别急着宣布胜利,还有一堆收尾工作等着。

4.5 analyze_new_cluster.sh:统计信息重建

升级完成后,优化器看到的统计信息还停留在旧版本,甚至可能是空的。不更新统计信息,轻则执行计划偏差,重则查询慢得令人怀疑索引失效。pg_upgrade生成的脚本就是干这个的:

sudo -u postgres /var/lib/pgsql/16/data/analyze_new_cluster.sh

这个脚本会对所有数据库执行ANALYZE。数据量大时它会跑一段时间,但这一步不能跳过。我在一次测试中升级完直接跑业务,发现几个核心查询从毫秒级退化到秒级,一度怀疑数据损坏,最后定位到就是统计信息缺失导致的执行计划跳变。

5. 升级后的验证、配置迁移与删除旧集群的时机

5.1 验证清单:不止是能启动

实例能启动只代表进程活着,业务层是否完整要靠一张清单逐项过。我把验证拆成四个层级:

第一层,基础连通性。确认能连接、能执行简单查询,检查日志里有没有ERROR级别的异常。

sudo -u postgres /usr/pgsql-16/bin/psql -p 5432 -U postgres -d postgres -c "SELECT count(*) FROM pg_database;"

第二层,对象完整性。用\dx查看所有扩展,用\du查看角色,重点确认扩展列表是否和旧库一致。既然pg_upgrade不迁移配置,扩展清单也很可能不完整。

第三层,业务数据抽样。挑几张核心业务表做count和字段抽样,再跑几个典型的join查询,确认数据没有错位。

第四层,应用侧联调。让应用连接新实例跑一遍关键链路,观察应用日志有没有权限、认证、序列或外键相关的报错。很多问题只有真实业务流量才能触发。

5.2 配置迁移:pg_upgrade不会帮你做的两件事

pg_upgrade最容易被忽略的一个事实是:新的数据目录里维护着initdb生成的默认配置,旧库postgresql.conf和pg_hba.conf里的个性化设置不会自动搬过来。

我的做法是先做一次新旧配置文件对比:

diff /var/lib/pgsql/15/data/postgresql.conf /var/lib/pgsql/16/data/postgresql.conf

旧库上启用的非默认参数,比如shared_buffers、work_mem、max_connections、effective_cache_size等,逐项搬到新配置里。有一点要小心:参数名在不同版本间会有调整,直接从旧文件复制内容到新目录时,建议先运行一次pg_ctl configtest验证配置语法。

sudo -u postgres /usr/pgsql-16/bin/pg_ctl -D /var/lib/pgsql/16/data configtest

pg_hba.conf同理,升级前记得把认证规则补齐,否则应用连接会被默认的trust或scram规则卡住,白白增加排查时间。

5.3 扩展迁移:最容易漏掉的一环

扩展的二进制库是一个.so文件,与特定PostgreSQL大版本强绑定。旧库装了postgis或pg_trgm,新实例即使文件映射成功,对应的扩展函数库也不存在。check阶段通常会提示warning,但未必会阻断。

处理顺序是先软件后元数据:

dnf install -y postgresql16-contrib dnf install -y postgis32_16

软件包装好后,再进入数据库确认扩展状态:

sudo -u postgres /usr/pgsql-16/bin/psql -p 5432 -c "\dx"

如果某个扩展显示missing或版本异常,先DROP EXTENSION再CREATE EXTENSION是不推荐的,因为会造成数据丢失。更稳妥的是检查扩展的control文件是否就位,然后通过ALTER EXTENSION ... UPDATE或直接升级软件包解决。总之,扩展问题必须在业务验证前处理干净。

5.4 delete_old_cluster.sh什么时候跑

pg_upgrade生成的delete_old_cluster.sh不会自动执行,这很好。我的建议是至少观察一到两周,确认业务稳定后再清理。清理时先停掉所有依赖旧目录的进程,然后执行:

sudo -u postgres /var/lib/pgsql/16/data/delete_old_cluster.sh

或者手动删除旧数据目录和旧版本软件包。清理完成后,/usr/pgsql-15目录和旧数据目录占用的空间才会真正释放。

6. link模式下的回滚边界与实战避坑记录

6.1 回滚的真实边界:link模式并不等于可随时回退

很多人误以为pg_upgrade只是换了软链接,切回去就行。遇到link模式,这种想法非常危险。

因为新旧目录共享同一个inode,一旦新实例启动并写入任何数据,旧数据目录里的文件内容也会跟着变化。此时把软链接切回去启动旧版本,旧二进制很可能无法理解被新版本写过的新格式数据,结果不是启动失败就是数据损坏。

所以回滚有严格的时间窗口:

  • 升级完成、新实例尚未启动时,可以安全回滚。此时新旧目录指向同一份未变动的文件,只要删掉新目录、恢复软链接、启动旧实例即可。
  • 新实例启动后,只要还没有产生任何写入,理论上可以回滚,但pg_upgrade官方文档不建议这么做。
  • 一旦有写入,回滚基本等于自杀。

正因如此,升级前必须要有一个独立于数据目录之外的备份。我的习惯是升级前做一次完整的pg_dumpall或虚拟机快照,放在绝不共享inode的位置。link模式省的是升级时间,不是备份责任。

6.2 实测踩坑记录:我在RHEL上遇到的六个问题

这些问题在官方文档里不会集中出现,但真实环境里几乎都会碰到其中几个。

第一个是SELinux上下文问题。前面提过,非标准数据目录路径需要restorecon -Rv /var/lib/pgsql/16或者用chcon手动设置。现象是pg_upgrade能执行但文件句柄操作被拒,日志里出现模糊的权限报错。

第二个是initdb时忘了关闭默认的--data-checksums选项?实际上这不是问题,PG 16默认开启校验和,如果旧库没开启,pg_upgrade会警告并且拒绝升级。必须要保证新旧集群的checksum设置一致,通常initdb默认两者都开启就可以。

第三个是扩展包版本冲突。在check阶段报了postgis不兼容,但为了赶时间我忽略了warning直接升级,结果业务跑地理查询时报函数不存在。这个教训值半天工时,希望你不要重蹈覆辙。

第四个是参数迁移遗漏导致性能异常。升级后忘记迁移effective_cache_size,原本配置为24GB,新实例用的默认值4GB,优化器把大量查询改成了索引扫描甚至全表扫描,业务反馈明显变慢。所以配置对比要做细,不要只盯内存参数。

第五个是服务单元名不一致。有的RHEL系统上服务叫postgresql-16,有的环境因为安装了多个版本的RPM,systemctl enable时容易enable错。建议每次都显式执行systemctl status postgresql-16确认实际运行的是新服务。

第六个是清理旧软件包时连带删掉了新库的依赖。因为postgresql16和postgresql15的server包都依赖postgresql16-libs?不一定,但dnf自动解决依赖时有可能因为版本匹配问题把新库的公共组件降级。清理旧包时务必先检查依赖树,最好只删server包,留着bin包和libs包。

6.3 升级前的演练建议

生产环境操作前,先在staging环境完整跑一遍同样的流程,能有80%的概率提前踩掉上面这些坑。我的演练路线是:

  • 用生产库最近一次备份恢复出测试实例
  • 在测试实例上走一遍check,修正所有报错和warning
  • 正式升级前再跑一遍check,确认环境没有漂移
  • 操作窗口选择业务低谷,预留至少两倍于预计升级时长的缓冲

最后分享一个实际操作中特别有用的做法:把完整操作命令加上时间戳写成一个操作脚本,每执行一步手动确认输出。这样即使中途有事打断,也能准确知道自己进行到哪一步,避免重复操作或跳过关键环节。

我在RHEL上使用pg_upgrade完成了多次PostgreSQL大版本升级,从13到15、从14到16都走过这条路。核心套路就是今天写的这些:明白它为什么快、准备好环境、跑透check、谨慎处理硬链接边界、升级后耐心验证。只要这几步都做到,剩下的事情比想象中平稳得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询