土法炼钢兴趣小组的算法知识备份

【PG 内核】大版本升级与迁移实战:pg_upgrade --link 为什么快以及为什么没有回滚

文章导航

分类入口
databasekernelops
标签入口
#postgresql#pg-kernel#pg-upgrade#migration#hard-link#logical-replication#pg-dump#vacuumdb#disaster-recovery#inode#copy-on-write

目录

大版本升级与迁移实战: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 字说清楚。


原教旨升级 vs pg_upgrade

最早的 PG 大版本升级方法只有一种:pg_dump 导出老版本的逻辑数据,再 pg_restore 导入新版本。这个方案有几个硬伤:

  1. :全量数据走 SQL 层再写回磁盘,大库动辄数小时。
  2. 膨胀:pg_restore 本质上是重建表和索引——数据文件和索引文件在新集群里物理布局完全不同,相当于做了一次全量 VACUUM FULL。
  3. 统计信息清空:pg_dump 导出的是数据,不包括 pg_statistic 的内容。新集群里的优化器统计一片空白,必须全量 ANALYZE。

pg_upgrade 的设计目标是就地升级——不搬数据,只在元数据层面完成版本迁移。它操作的对象是文件系统上的数据文件目录(PGDATA),而不是走 SQL 连接。这意味着它不需要把数据读出来、不需要重建索引、不需要生成 WAL。

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 扩展提供的辅助函数,然后逐项检查:

第二阶段:实际执行中,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 运行之前必须满足:

  1. 目标版本的二进制已经安装,老版本和新版本的 pg_upgrade 二进制都必须可用。实际上只需要用新版本的 pg_upgrade,它会通过 -b 参数指定老版本 bindir 来调用老版本的 postgres(single-user mode)。

  2. 所有扩展在目标版本中必须可用且版本兼容。在 shared_preload_libraries 中列出的扩展,新版本安装中必须有对应的 .so.control 文件。

  3. pg_upgrade_support 函数必须可用。这是一个特殊的扩展,在 PG 源码树的 src/include/catalog/pg_upgrade_support.h 中定义。它的作用是在升级过程中暴露内部 catalog 信息,让 pg_upgrade 能读取老集群的系统表结构。PG 官方打包中该扩展默认包含,但如果使用的是从源码编译的精简安装,需要确认 pg_upgrade_support 是否在 $libdir 中。

  4. 老集群必须被干净地关闭pg_ctl stop -m fastsmart),不能是 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


六、关键要点

  1. pg_upgrade --link 通过硬链接实现零数据拷贝——用户表的数据文件不被物理复制,新老集群指向相同的 inode。升级时间几乎全部花在系统表迁移上,与用户数据总量无关。

  2. --link 不可回滚的根本原因是硬链接语义——升级后新集群一旦启动并写入,老集群的数据文件同时被污染。回滚的唯一安全策略是提前做全量备份。

  3. pg_upgrade --check 是必须完整的检查点——它在 single-user mode 下逐个验证 catalog schema、扩展版本、数据类型兼容性、locale 一致性。必须等到 --check 输出 “Clusters are compatible” 才能继续。

  4. analyze_new_cluster.sh 不能省——pg_upgrade 不迁移统计信息,没有它新集群的查询优化器等于在黑暗中摸索。

  5. 逻辑复制迁移的三个盲区:序列不在复制范围内(需要手动 setval())、large object 不在复制范围内、DDL 在 PG 16 及之前版本不复制。

  6. 迁移方案选型就是停机时间 vs 复杂度的权衡:pg_dump 最简单但最慢,pg_upgrade --link 最快但不可回滚,逻辑复制复制最灵活但最复杂。

上一章:数据恢复与损坏应对 下一章:配置陷阱与生产最佳实践,拆解 11 个最容易被误解的 GUC 参数,每个都从内核行为讲清楚”配错后会有什么症状、怎么从视图和日志里确认”。


参考资料

源码(PG 17)

官方文档

实验工具

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】数据恢复与损坏应对:PITR、pg_resetwal 和页面损坏的边界

拆解 PostgreSQL 数据恢复路径的内部机制与操作边界:PITR 的三个关键窗口与 timeline fork 原理、pg_checksums 的校验粒度与盲区、pg_resetwal 的 hint bit 代价与 VACUUM FULL 陷进、pg_dump 并行调度的内部策略。重点在于每种操作做什么、不做什么、哪些后果不可逆。

2026-06-16 · database / kernel

【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 就好了',而是'通过什么视图和日志确认当前设置有问题'。

2026-06-16 · database / kernel

【PG 内核】PostgreSQL 内核机制深度拆解

从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。

2026-06-16 · database / kernel

【PG 内核】逻辑复制与逻辑解码:冲突处理与延迟放大

拆解 PostgreSQL 逻辑复制的完整内核路径:LogicalDecodingContext 从 WAL 解码出逻辑变更的内部流程、Reorder Buffer 按 COMMIT 顺序重排事务与 snapshot 重建机制、pgoutput 输出插件的二进制协议与行过滤变换、Publication/Subscription 模型的内核实现。重点剖析四种冲突类型的根因与修复边界——update_missing/delete_missing 为什么静默跳过而 duplicate_key 直接停摆、subscription 被 disable 后的数据追平策略、序列不在逻辑复制范围内的自增主键冲突陷阱、大事务在 reorder buffer 中的延迟放大效应。


By .