大版本升级与迁移实战:pg_upgrade –link 为什么快以及为什么没有回滚
PG 大版本升级是一个让 DBA 失眠的话题。大版本升级(major version upgrade)指的是从 PG 15 到 PG 16、PG 16 到 PG 17 这种跨越——数据文件格式、系统表结构、WAL 格式都可能变化,不能像小版本升级(16.2 到 16.4)那样直接替换二进制然后重启。本章拆解三种迁移方案的核心机制、致命边界和决策依据。
如果你只有 5 秒钟:小库(<10GB)用
pg_dump/pg_restore,中型库用 pg_upgrade
--link,大库(>500GB)且不能长时间停机用逻辑复制迁移。但”为什么不能混着用”和”每种方案出事之后还能怎么办”需要花
5000 字说清楚。
一、pg_upgrade 的内部机制:为什么 –link 这么快
原教旨升级 vs pg_upgrade
最早的 PG 大版本升级方法只有一种:pg_dump 导出老版本的逻辑数据,再 pg_restore 导入新版本。这个方案有几个硬伤:
- 慢:全量数据走 SQL 层再写回磁盘,大库动辄数小时。
- 膨胀:pg_restore 本质上是重建表和索引——数据文件和索引文件在新集群里物理布局完全不同,相当于做了一次全量 VACUUM FULL。
- 统计信息清空:pg_dump
导出的是数据,不包括
pg_statistic的内容。新集群里的优化器统计一片空白,必须全量 ANALYZE。
pg_upgrade
的设计目标是就地升级——不搬数据,只在元数据层面完成版本迁移。它操作的对象是文件系统上的数据文件目录(PGDATA),而不是走
SQL
连接。这意味着它不需要把数据读出来、不需要重建索引、不需要生成
WAL。
–link 模式:0 数据拷贝的秘密
pg_upgrade 有三种文件处理模式:
| 模式 | 对数据文件的操作 | 额外磁盘空间 | 升级时长(数据部分) | 升级后旧集群是否可用 |
|---|---|---|---|---|
--link |
link() 创建硬链接 |
只需复制 pg_catalog 的空间 | 只取决于 pg_catalog 大小 | 否——新旧共享 inode |
--clone |
ioctl(FICLONE) 创建 CoW 快照 |
同 --link(CoW 写入时分配新块) |
同 --link |
可当作独立副本 |
| 默认(无参数) | 逐文件 copy() |
整个数据目录大小 | 取决于数据文件总大小 | 是——物理隔离 |
--link
快的根本原因是它不拷贝数据文件。对用户表的数据文件,pg_upgrade
调用
link(old_file, new_file)——创建一个新的目录项,指向同一个
inode:
# --link 之前
/data/old/pgdata/base/16384/49159 → inode #1048577 (data, 8GB)
# --link 之后
/data/old/pgdata/base/16384/49159 → inode #1048577 (data, 8GB)
/data/new/pgdata/base/16384/49159 → inode #1048577 (same inode!)
```text
`link()` 是一个纯粹的文件系统元数据操作——不分配新数据块,不触发磁盘 I/O。无论这个用户表的数据文件是 1GB 还是 500GB,`link()` 调用的耗时都是常数级别的(通常在几十微秒以内)。这解释了为什么一个 500GB 的数据库,pg_upgrade `--link` 的实际执行时间可能只有几分钟——时间几乎全部花在复制系统表(pg_catalog)上,而系统表通常只有几十到几百 MB。
### 不可回滚的根本原因
`--link` 这么快,代价是**升级后没有回滚机制**。原因不在 pg_upgrade 的代码逻辑里,而在文件系统的硬链接语义里:
1. 硬链接意味着"多个目录项指向同一个 inode"。对任何一个目录项写入,修改的是同一个 inode 对应的数据块。
2. pg_upgrade 执行期间,新集群对用户表的数据文件不做任何修改——它只是创建了指向已有 inode 的硬链接。此时新老集群共享相同的物理数据块,数据在磁盘上只存了一份。
3. **但升级后,一旦新集群启动,Backend 进程开始对用户表写入**(哪怕只是 hint bit 更新),新写入的数据就会直接反映到老集群的数据文件中——因为它们是同一个文件。
4. 此时老集群如果启动,看到的数据文件已经是新版本格式下被修改过的状态,加上 pg_catalog 的结构已经被替换,老版本二进制根本无法正确解析——数据已经不可逆地污染了。
这不是 pg_upgrade 的 bug,而是硬链接的语义决定的操作边界。使用 `--link` 之前必须接受"升级后不能回退到老集群"的代价。
防御策略不是"想好回滚方案",而是**升级后做充分的验证窗口**。常用做法是:
1. 升级后不立即放生产流量,先跑 `analyze_new_cluster.sh`,再跑一批 regression test。
2. 对关键业务表执行 `SELECT count(*)`、随机采样校验、检查索引有效性。
3. 保留老版本的安装目录(二进制),但数据目录已经不能用了——所以备份不是可选项,是必须项。
### --clone 模式:CoW 的中间地带
`--clone` 模式是 PG 10 引入的。它在支持 reflink 的文件系统上(Btrfs、XFS、OCFS2,ZFS 目前不支持)使用 `ioctl(FICLONE)` 系统调用创建文件的写时复制(Copy-on-Write)快照。
和 `--link` 的区别在于:`FICLONE` 创建的副本共享相同的物理数据块,但只要任一副本发生写入,文件系统会自动为新写入的数据分配新块——两个副本开始分叉,互不影响。这就是写时复制。
`--clone` 的优势:创建时和 `--link` 一样快(元数据级别操作),但升级后旧集群理论上仍然可用——只要你在旧集群目录下不主动做任何修改,且新集群的修改不会在老副本上出现。如果你升级后发现问题需要回滚,可以保留旧集群目录原地回退。
代价:需要文件系统支持 reflink(ext4 不支持),且随着新集群不断写入,分配的额外磁盘空间会逐渐增长。
### pg_upgrade 执行流程
`pg_upgrade` 的完整执行分三个阶段:
```mermaid
sequenceDiagram
participant User
participant pg_upgrade as pg_upgrade 进程
participant Old as 老集群 (PGDATA)
participant New as 新集群 (PGDATA)
participant FS as 文件系统
Note over User,FS: 第一阶段:pg_upgrade --check(预检)
User->>pg_upgrade: pg_upgrade --check -b old_bindir -B new_bindir -d old_data -D new_data
pg_upgrade->>Old: 启动老集群(single-user mode)
pg_upgrade->>Old: 加载 pg_upgrade_support 函数
pg_upgrade->>Old: 检查 catalog schema 兼容性
pg_upgrade->>Old: 检查所有扩展版本兼容性
pg_upgrade->>Old: 检查数据类型二进制兼容性
pg_upgrade->>Old: 检查 locale / encoding / lc_collate
pg_upgrade->>Old: 关闭老集群
pg_upgrade-->>User: "Clusters are compatible"
Note over User,FS: 第二阶段:pg_upgrade 执行升级
User->>pg_upgrade: pg_upgrade --link -b old_bindir -B new_bindir -d old_data -D new_data
pg_upgrade->>Old: 启动老集群(single-user mode)
pg_upgrade->>New: 使用 initdb 初始化新 PGDATA(空集群)
pg_upgrade->>FS: 复制 pg_catalog 系统表文件(唯一的数据拷贝)
pg_upgrade->>FS: link() 用户表数据文件 → 新 PGDATA
pg_upgrade->>FS: 复制 CLOG / WAL / 2PC 等事务性文件
pg_upgrade->>New: 更新 pg_control(版本信息、checkpoint 位置)
pg_upgrade->>New: 重建 free space map (FSM) 和 visibility map (VM)
pg_upgrade-->>User: "Upgrade Complete"
Note over User,FS: 第三阶段:analyze_new_cluster
User->>New: 启动新集群(正常模式)
User->>pg_upgrade: ./analyze_new_cluster.sh
pg_upgrade->>New: vacuumdb --all --analyze-only
pg_upgrade-->>User: 统计信息生成完毕
第一阶段:pg_upgrade --check
是整个流程中最关键的安全检查。它启动老集群的 single-user
mode(不经过 Postmaster),加载
pg_upgrade_support
扩展提供的辅助函数,然后逐项检查:
- Catalog schema 兼容性:pg_upgrade
内置了每个版本的系统表结构定义(在
src/bin/pg_upgrade/check.c中)。它查询pg_catalog中的每个系统表,对比老集群的行类型和期望的行类型。如果某个列的类型或顺序不兼容(例如 PG 15→16 中pg_statistic_ext_data的结构变化),--check会报错并列出不兼容的条目。 - 扩展版本兼容性:遍历
pg_extension中每个扩展,检查新版本的$libdir下是否有对应扩展的.so文件,且扩展的 SQL 定义在老集群和新集群中是否兼容。很多 pg_upgrade 失败就是因为老版本装了一个扩展,新版本没有装或者版本不兼容。 - 数据类型二进制兼容性:检查老集群中所有复合类型、枚举类型的 OID 和布局是否在新版本系统中保持一致。如果某个类型在系统初始化时分配的 OID 不一致,pg_upgrade 会拒绝升级——因为它将直接复用老集群的数据文件,类型布局不一致会导致数据解析错误。
- locale / encoding /
lc_collate:这是最容易被忽略但最先检查的事。新集群
initdb时指定的 locale 必须和老集群一致,否则字符串排序和比较的行为就不一致——这会导致索引损坏(B-tree 的排序顺序变了)。--check会对比老集群pg_control中记录的 locale 和新集群initdb时使用的 locale,不匹配直接报错。 - 磁盘空间:pg_upgrade 估算升级需要的额外空间——至少等于所有系统表的空间之和(因为 pg_catalog 会被拷贝)。
第二阶段:实际执行中,pg_upgrade 的处理逻辑按文件类型分为三类:
| 文件类型 | 处理方式 | 原因 |
|---|---|---|
系统表(pg_catalog 中的 relation) |
物理拷贝(copy_file()) |
系统表的结构可能在大版本间变化,必须由新版本 initdb 重新创建,然后 pg_upgrade 把老数据迁移进去 |
| 用户表数据文件 | 硬链接/CoW/物理拷贝(取决于模式) | 用户表的数据文件格式在大版本间保持兼容——tuple header 的布局没变,xmin/xmax/xid 位数没变(都是 32-bit),页面布局保持不变 |
| 事务文件(CLOG、WAL、2PC) | 特殊处理 | CLOG 的 SLRU 页面需要转换、WAL 段不迁移(新集群从干净的 WAL 日志开始)、2PC 状态文件需要转移 |
第三阶段:analyze_new_cluster.sh
本质上是在新集群上执行
vacuumdb --all --analyze-only。因为
pg_upgrade 只迁移了数据文件,新集群的
pg_statistic
系统表是空的——优化器没有统计信息,查询计划的质量无从保证。analyze_new_cluster.sh
是必须执行的步骤,不能省略。
前置条件
pg_upgrade 运行之前必须满足:
目标版本的二进制已经安装,老版本和新版本的
pg_upgrade二进制都必须可用。实际上只需要用新版本的pg_upgrade,它会通过-b参数指定老版本 bindir 来调用老版本的postgres(single-user mode)。所有扩展在目标版本中必须可用且版本兼容。在
shared_preload_libraries中列出的扩展,新版本安装中必须有对应的.so和.control文件。pg_upgrade_support函数必须可用。这是一个特殊的扩展,在 PG 源码树的src/include/catalog/pg_upgrade_support.h中定义。它的作用是在升级过程中暴露内部 catalog 信息,让 pg_upgrade 能读取老集群的系统表结构。PG 官方打包中该扩展默认包含,但如果使用的是从源码编译的精简安装,需要确认pg_upgrade_support是否在$libdir中。老集群必须被干净地关闭(
pg_ctl stop -m fast或smart),不能是 immediate shutdown 或者 crash 后的状态——否则 WAL 恢复不完全,数据文件可能处于不一致状态。
二、逻辑复制跨版本迁移
对于无法接受 pg_upgrade 停机时间(哪怕只有几分钟)的场景,逻辑复制是唯一的零停机或低停机跨版本迁移方案。
低停机迁移流程
flowchart TD
A["老版本 PG 16 Primary<br/>(承载生产流量)"] --> B["搭建 PG 17 Standby<br/>(空数据库)"]
B --> C["在 PG 17 上创建 Subscription<br/>逻辑订阅所有表"]
C --> D["初始数据同步<br/>(COPY 快照)"]
D --> E["增量同步追平<br/>(逻辑解码 + apply)"]
E --> F{"复制延迟 < 1s?"}
F -->|否| E
F -->|是| G["暂停应用写入<br/>(短暂停机窗口)"]
G --> H["等待最后一批 WAL apply"]
H --> I["切换应用连接到 PG 17"]
I --> J["PG 17 承载生产流量"]
J --> K["验证稳定后删除老集群"]
```text
核心思路是:用逻辑复制的 Publication/Subscription 机制在 PG 16(老版本,作为发布端 publisher)和 PG 17(新版本,作为订阅端 subscriber)之间建立订阅关系。逻辑复制的跨版本支持规则是:**订阅端的 PG 版本必须大于或等于发布端**——所以 PG 16 发布端到 PG 17 订阅端是官方明确支持的标准路径。老集群持续写入,新集群通过逻辑解码消费 WAL 并 apply。
逻辑复制的优势:
- **可回滚**:如果新集群出现问题,直接把应用连接切回老集群即可。因为老集群的写入从未停止,数据是完整的。回滚只是 DNS 或连接串的一次切换。
- **停机时间可控**:唯一需要停机的是最后的切换窗口——暂停写入、等最后一批增量 apply、切换流量。这个窗口通常可以控制在秒到分钟级。
- **可做灰度验证**:在正式切换之前,新集群可以接收一段时间的真实读流量进行验证。
### 盲区与陷阱
逻辑复制不是"整库复制"——它有几个明确的盲区:
**序列(Sequence):不在逻辑复制的范围内。** 序列的当前值(`last_value`)不会通过逻辑复制同步。切换到新集群后,如果应用依赖 `nextval()` 生成主键,新集群的序列从初始值开始——立刻产生主键冲突。解决方案:切换前手动在新集群上执行 `SELECT setval('seq_name', (SELECT max(id) FROM table_name))` 对所有序列进行同步。
**Large Object:不在逻辑复制的范围内。** `pg_largeobject` 系统表存储的是 BLOB 的大对象数据块(每块 `LOBLKSIZE` 字节),这些数据不会通过逻辑复制传输。如果应用使用 `lo_import()` / `lo_export()` 接口存储大对象,需要在迁移前用其他方式(pg_dump 仅导出 large object、或应用层同步)处理。
**DDL 复制**:PG 16 及之前版本的逻辑复制不复制 DDL。在迁移窗口期间对老集群执行的 `ALTER TABLE`、`CREATE INDEX` 等操作不会自动同步到新集群。PG 17 引入了 DDL 逻辑复制的初步支持(`CREATE TABLE`、`ALTER TABLE` 的基础操作可通过 subscription 的 `copy_data` 机制配合),但覆盖面有限。迁移期间建议锁定 DDL,或者手动在新集群执行相同的 DDL。
**大事务的延迟放大效应**:逻辑复制的 reorder buffer 必须等待事务 COMMIT 才释放解码后的变更。如果一个事务修改了 1000 万行然后 `COMMIT`,这 1000 万行变更会在 COMMIT 瞬间全部涌入 apply worker——产生一个延迟尖峰。如果在迁移期间有批量 DML 操作,新集群的延迟可能突然变得很大。
---
## 三、常见坑与应对
### 坑 1:pg_upgrade 后 shared_preload_libraries 配置丢失
pg_upgrade 不迁移 `postgresql.conf`——它要求你手动把老集群的配置迁移到新集群。但最常见的问题是:`shared_preload_libraries` 中列出的扩展(如 `pg_stat_statements`、`auto_explain`、`pg_cron`、`auth_delay` 等),老集群里有,新集群的 `postgresql.conf` 里忘了配。
后果:`pg_stat_statements` 不加载 → 没有查询统计 → Grafana 面板变空白。`auto_explain` 不加载 → 慢查询日志只记录执行时长但没有执行计划。`pg_cron` 不加载 → 定时任务静默不执行。
应对:升级前用 `psql -c "SHOW shared_preload_libraries"` 导出老集群配置,对比新集群 `postgresql.conf` 中的值。同时检查每个扩展在新版本中是否可用(`pg_config --sharedir`/`extension/` 下的 `.control` 文件)。
### 坑 2:pg_dump + pg_restore 迁移后统计信息消失
`pg_dump` 导出的是逻辑数据——表和索引的定义和数据行,不包括 `pg_statistic` 系统表的内容。这是设计决策,不是 bug:统计信息是采样生成的,导入到一个数据布局完全不同的新集群中不适用(新集群的页面填充率、索引深度都可能不同)。
后果:pg_restore 完成后的数据库,查询优化器对每个表都没有任何统计信息。PG 会使用硬编码的默认值做选择率估算——意味着复杂的 Join 查询可能选择灾难性的执行计划。
应对:pg_restore 完成后,**必须立即执行 `vacuumdb --all --analyze-only`**(或逐个表的 `ANALYZE`)。在 `ANALYZE` 完成之前,新集群的查询性能是不可预期的。
### 坑 3:pg_upgrade 后 vacuumdb --analyze-only 不能省
这个坑和坑 2 根因不同——坑 2 是 pg_dump 没有导出统计信息,坑 3 是 pg_upgrade 创建的新集群是一个全新的 initdb 产物,系统表 `pg_statistic` 中没有任何内容。虽然数据文件通过硬链接保留了物理布局,但 `pg_upgrade` 没有跨集群迁移 `pg_statistic` 的行——老集群的统计信息在 pg_dump 阶段没有被导出。
后果:同坑 2——优化器没有统计信息,查询计划质量退化。`analyze_new_cluster.sh` 脚本的存在就是为了解决这个问题,但它不会自动执行,需要手动调用。
应对:升级后执行 `./analyze_new_cluster.sh` 或等效的 `vacuumdb --all --analyze-only`。对于几百 GB 以上的大表,`ANALYZE` 可能耗时数分钟到数十分钟——采样需要扫描一定比例的数据页。这个时间要算在升级的"验证窗口"内,不能省略。
### 坑 4:locale / encoding / lc_collate 不兼容
pg_upgrade `--check` 第一个检查的就是 locale 兼容性。locale 决定了字符串的排序规则——而 B-tree 索引的键顺序完全依赖这个排序规则。
如果老集群使用 `en_US.UTF-8` 但新集群 `initdb` 时用了 `C` locale(或者反过来),`--check` 会直接失败。应对方法是在 `initdb` 新集群时显式指定 locale:
```bash
initdb --locale=en_US.UTF-8 --lc-collate=en_US.UTF-8 --lc-ctype=en_US.UTF-8 \
-D /data/new/pgdata
但注意——如果老集群使用 C locale,换到非
C locale 后,索引维护和查询结果可能不一致(因为
C locale 是字节序,非 C locale
是语言学序)。pg_upgrade --check
要求完全一致是有原因的。
encoding 不兼容的情况更少见(因为 PG 中 encoding
一旦设定就不可能更改),但如果老集群使用 EUC_JP
而新集群 initdb 时用了
UTF8,--check 直接拒绝。
四、迁移方案决策树
三种方案各有利弊和适用边界。下面的决策树按数据库规模和停机时间容忍度给出推荐路径:
flowchart TD
START["需要大版本升级"] --> Q_SIZE{"数据总大小?"}
Q_SIZE -->|"< 10GB"| DUMP["A: pg_dump + pg_restore"]
Q_SIZE -->|"10GB ~ 500GB"| Q_DT{"可接受停机?"}
Q_SIZE -->|"> 500GB"| Q_LOW_DT{"可接受停机?"}
Q_DT -->|"是(几分钟到几十分钟)"| LINK["B: pg_upgrade --link"]
Q_DT -->|"否"| LOGREPL["C: 逻辑复制迁移"]
Q_LOW_DT -->|"是"| LINK2["B: pg_upgrade --link<br/>(停机时间仅取决于 pg_catalog 大小)"]
Q_LOW_DT -->|"否"| LOGREPL2["C: 逻辑复制迁移"]
DUMP --> CHECK1["优点:最简单、可回滚、跨架构<br/>缺点:慢、膨胀、丢统计信息"]
LINK --> CHECK2["优点:快、原地升级、无膨胀<br/>缺点:不可回滚、需要 --check 通过"]
LOGREPL --> CHECK3["优点:低停机、可回滚、可灰度<br/>缺点:序列/large object 需手动同步、DDL 需手动复制"]
LINK2 --> CHECK2
LOGREPL2 --> CHECK3
style DUMP fill:#3fb950,color:#2d333b
style LINK fill:#388bfd,color:#2d333b
style LINK2 fill:#388bfd,color:#2d333b
style LOGREPL fill:#f0883e,color:#2d333b
style LOGREPL2 fill:#f0883e,color:#2d333b
```bash
### 方案 A:pg_dump + pg_restore(<10GB 或特殊需求)
适用场景:
- 数据库很小,dump/restore 的总时长在可接受范围内
- 需要跨架构迁移(如 x86 到 ARM)
- 需要变更 encoding 或 locale
- 需要做数据清理(去膨胀、改表结构)
- 想要一个"干净的"新数据库(无历史膨胀)
注意:这个方案的时间复杂度是 O(数据大小),对 10GB 以下的库可能只需几分钟;对 500GB 的库可能需要数小时甚至更长。
### 方案 B:pg_upgrade --link(10GB ~ 500GB,可接受停机)
适用场景:
- 数据量大,dump/restore 时间不可接受
- pg_upgrade `--check` 通过
- 接受了"不可回滚"的代价,并且做好了备份
- 停机时间可以覆盖 pg_upgrade 执行(主要是 pg_catalog 复制)和 `analyze_new_cluster.sh` 执行的总和
注意:即使数据文件是 0 拷贝,`analyze_new_cluster.sh` 的执行时间取决于表的数量和大小——对于有几千个表和几百 GB 数据的库,这一阶段可能耗时数十分钟。
### 方案 C:逻辑复制迁移(>500GB,不可接受长时间停机)
适用场景:
- 超大数据库,pg_upgrade 的 `analyze_new_cluster.sh` 阶段耗时太长
- 不允许长时间停机
- 可以接受迁移窗口内的运维复杂度(手动同步序列、手动复制 DDL)
- 应用可以承受短暂写入暂停(切流量窗口)
注意:这个方案的复杂度最高——需要管理 Publication/Subscription、监控复制延迟、手动处理序列和 DDL。复杂度换来的就是停机时间最短。
---
## 五、实验:pg_upgrade --link 从 PG 16 到 PG 17
### 环境准备
```bash
# 假设 PG 16 和 PG 17 都安装在不同前缀下
PG16_HOME=/usr/local/pgsql-16
PG17_HOME=/usr/local/pgsql-17
# 创建一个 PG 16 测试集群
$PG16_HOME/bin/initdb -D /tmp/pg16-data
echo "shared_preload_libraries = 'pg_stat_statements'" >> /tmp/pg16-data/postgresql.conf
$PG16_HOME/bin/pg_ctl -D /tmp/pg16-data -l /tmp/pg16.log start
# 创建测试数据
$PG16_HOME/bin/psql -d postgres -c "
CREATE EXTENSION pg_stat_statements;
CREATE TABLE test_data (id SERIAL PRIMARY KEY, val TEXT);
INSERT INTO test_data (val) SELECT 'row_' || generate_series(1, 1000000);
CREATE INDEX idx_test_val ON test_data(val);
"
$PG16_HOME/bin/pg_ctl -D /tmp/pg16-data stop -m fast
执行 pg_upgrade –check
$PG17_HOME/bin/pg_upgrade --check \
-b $PG16_HOME/bin \
-B $PG17_HOME/bin \
-d /tmp/pg16-data \
-D /tmp/pg17-data
# 预期输出:
# Performing Consistency Checks on Old Live Server
# -------------------------------------------------
# Checking cluster versions ok
# Checking database user is the install user ok
# Checking database connection settings ok
# Checking for prepared transactions ok
# Checking for system-defined composite types in user tables ok
# Checking for reg* data types in user tables ok
# Checking for contrib/isn with bigint-passing mismatch ok
# Checking for user-defined encoding conversions ok
# Checking for user-defined postfix operators 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*
```text
如果 `--check` 报错,不要忽略错误继续执行——每个错误都代表一个升级后会导致数据不可访问或不一致的问题。
### 执行升级
```bash
$PG17_HOME/bin/pg_upgrade --link \
-b $PG16_HOME/bin \
-B $PG17_HOME/bin \
-d /tmp/pg16-data \
-D /tmp/pg17-data
# 预期输出:
# Performing Upgrade
# ------------------
# Adding ".old" suffix to old global/pg_control ok
# Analyzing all rows in the new cluster ok
# Freezing all rows in the new cluster ok
# Deleting files from new pg_xact ok
# ...
# Upgrade Complete
# ----------------
# Optimizer statistics are not transferred by pg_upgrade.
# Once you start the new server, consider running:
# ./analyze_new_cluster.sh
#
# Running this script will delete the old cluster's data files:
# ./delete_old_cluster.sh验证硬链接
# 比较老集群和新集群中同一个用户表文件的 inode
OLD_INODE=$(stat -c '%i' /tmp/pg16-data/base/*/49159 2>/dev/null || echo "0")
NEW_INODE=$(stat -c '%i' /tmp/pg17-data/base/*/49159 2>/dev/null || echo "0")
if [ "$OLD_INODE" = "$NEW_INODE" ] && [ "$OLD_INODE" != "0" ]; then
echo "相同 inode: $OLD_INODE — 确认硬链接"
echo "硬链接计数: $(stat -c '%h' /tmp/pg17-data/base/*/49159)"
fi
# 注意此时 ls -lh 显示的 "文件大小" 是相同的,但磁盘只占了一份空间
# 可以使用 du -sh 验证:
du -sh /tmp/pg16-data /tmp/pg17-data
# 老集群和新集群的 du 报告大小可能不同——因为只有用户表数据文件共享 inode,
# 而系统表是独立拷贝的,FSM/VM 等辅助文件也不共享
```bash
### 启动新集群并生成统计信息
```bash
$PG17_HOME/bin/pg_ctl -D /tmp/pg17-data -l /tmp/pg17.log start
./analyze_new_cluster.sh
# 等价于:
$PG17_HOME/bin/vacuumdb --all --analyze-only -h /tmp -p 5432
# 验证统计信息
$PG17_HOME/bin/psql -d postgres -c "
SELECT schemaname, relname, n_live_tup, n_dead_tup,
last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'test_data';
"确认后可删除老集群
bash # 确认新集群正常工作后 ./delete_old_cluster.shtext
六、关键要点
pg_upgrade
--link通过硬链接实现零数据拷贝——用户表的数据文件不被物理复制,新老集群指向相同的 inode。升级时间几乎全部花在系统表迁移上,与用户数据总量无关。--link不可回滚的根本原因是硬链接语义——升级后新集群一旦启动并写入,老集群的数据文件同时被污染。回滚的唯一安全策略是提前做全量备份。pg_upgrade --check是必须完整的检查点——它在 single-user mode 下逐个验证 catalog schema、扩展版本、数据类型兼容性、locale 一致性。必须等到--check输出 “Clusters are compatible” 才能继续。analyze_new_cluster.sh不能省——pg_upgrade 不迁移统计信息,没有它新集群的查询优化器等于在黑暗中摸索。逻辑复制迁移的三个盲区:序列不在复制范围内(需要手动
setval())、large object 不在复制范围内、DDL 在 PG 16 及之前版本不复制。迁移方案选型就是停机时间 vs 复杂度的权衡:pg_dump 最简单但最慢,pg_upgrade
--link最快但不可回滚,逻辑复制复制最灵活但最复杂。
上一章:数据恢复与损坏应对 下一章:配置陷阱与生产最佳实践,拆解 11 个最容易被误解的 GUC 参数,每个都从内核行为讲清楚”配错后会有什么症状、怎么从视图和日志里确认”。
参考资料
源码(PG 17)
src/bin/pg_upgrade/pg_upgrade.c:main() 入口,解析参数,分派 check/upgradesrc/bin/pg_upgrade/check.c:check_old_cluster()/check_new_cluster(),预检逻辑的完整实现——catalog schema、扩展、类型、locale 等所有检查项src/bin/pg_upgrade/file.c:rewrite_data_file()/link_file()/clone_file(),三种文件迁移模式的具体实现src/bin/pg_upgrade/info.c:gen_db_file_maps(),收集老集群和新集群中每个数据库、每个 relation 的文件路径映射src/bin/pg_upgrade/relfilenode.c:处理 relfilenode 的映射和转换src/bin/pg_upgrade/server.c:start_postmaster()/stop_postmaster(),升级期间启动和关闭集群(single-user mode 和正常 mode)src/bin/pg_upgrade/parallel.c:PG 17 中 pg_upgrade 的并行升级支持src/bin/pg_upgrade/pg_upgrade.h:全局数据结构定义(ClusterInfo、DbInfo、RelInfo等)
官方文档
- PostgreSQL Documentation, pg_upgrade — 完整的使用说明和选项参考
- PostgreSQL Documentation, Chapter 30: Logical Replication — 逻辑复制的触发条件、Publication/Subscription DDL、冲突检测
- PostgreSQL Documentation, Chapter 26: Backup and Restore — 备份和恢复的官方指南
- PostgreSQL Documentation, Appendix E: Release Notes — 每个大版本的升级注意事项(特别是 migration to version X 部分)
实验工具
stat:验证硬链接(inode 编号、硬链接计数)du -sh:验证磁盘空间占用(区分共享 inode 和独立拷贝)pg_stat_user_tables:验证升级后统计信息的存在性和完整性pg_upgrade --check:预检工具,应当在任何升级操作前首先运行
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】数据恢复与损坏应对:PITR、pg_resetwal 和页面损坏的边界
拆解 PostgreSQL 数据恢复路径的内部机制与操作边界:PITR 的三个关键窗口与 timeline fork 原理、pg_checksums 的校验粒度与盲区、pg_resetwal 的 hint bit 代价与 VACUUM FULL 陷进、pg_dump 并行调度的内部策略。重点在于每种操作做什么、不做什么、哪些后果不可逆。
【PG 内核】配置陷阱与生产最佳实践:11 个最危险的 GUC 和它们的正确设置
逐一拆解 11 个最容易被误解和配错的 PostgreSQL GUC 参数:shared_buffers 的 double buffering 反噬、work_mem 作为'每个操作'而非'每个查询'的内存炸弹、effective_cache_size 和 random_page_cost 如何误导优化器走向灾难计划、fsync=off 和 synchronous_commit=off 的数据丢失边界、huge_pages 在容器中的静默退化、maintenance_work_mem 不足导致 VACUUM 瘫痪、idle_in_transaction_session_timeout 为什么必须设、log_lock_waits 与 deadlock_timeout 的联动、以及 log_min_duration_statement 与 auto_explain 的日志洪水叠加。每条配查验 SQL 和 shell 命令——不是'设成 X 就好了',而是'通过什么视图和日志确认当前设置有问题'。
【PG 内核】PostgreSQL 内核机制深度拆解
从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。
【PG 内核】逻辑复制与逻辑解码:冲突处理与延迟放大
拆解 PostgreSQL 逻辑复制的完整内核路径:LogicalDecodingContext 从 WAL 解码出逻辑变更的内部流程、Reorder Buffer 按 COMMIT 顺序重排事务与 snapshot 重建机制、pgoutput 输出插件的二进制协议与行过滤变换、Publication/Subscription 模型的内核实现。重点剖析四种冲突类型的根因与修复边界——update_missing/delete_missing 为什么静默跳过而 duplicate_key 直接停摆、subscription 被 disable 后的数据追平策略、序列不在逻辑复制范围内的自增主键冲突陷阱、大事务在 reorder buffer 中的延迟放大效应。