数据恢复与损坏应对:PITR、pg_resetwal 和页面损坏的边界
当你面对一个损坏的 PG 集群时,手上能用的工具有限:PITR
回退到过去某个时刻、pg_resetwal 清空 WAL
强行启动、zero_damaged_pages 跳过损坏页面、或者
pg_dump
抢救还能读的数据。这四种路径的操作成本和数据损失风险完全不同,选错的代价可能是数据永久丢失。
本文拆解每种恢复路径的内部机制——不是操作步骤清单,而是让你理解每一步在内核层面做了什么、哪些代价是不可逆的,以及在什么情况下绝对不该碰某个工具。
一、PITR 恢复流程:WAL archive 如何回放
PITR(Point-in-Time Recovery)的核心思想是:从一个旧的 base backup 出发,逐条回放 WAL 日志,把数据库状态推进到指定的恢复目标时刻。它不是”撤销”操作——它是”从更早的快照重新走到现在(或走到过去某个点)“。
恢复的起点:pg_control 与 checkpoint record
pg_backup_start() 创建的 base backup
本身不是一致的——备份期间数据文件仍在被修改。启动 PITR
恢复时,PG 首先读取 pg_control 文件(或 base
backup 中的 backup_label)找到 checkpoint
record 的位置,然后从这个 REDO point 开始,沿 WAL 逐条回放
record。
恢复入口在 src/backend/access/transam/xlog.c
的 StartupXLOG():
// src/backend/access/transam/xlog.c, StartupXLOG() 的简化流程
// 1. 读取 pg_control,获取 checkpoint 位置
// 2. 如果存在 backup_label,以它的 START WAL LOCATION 为准
// 3. 设置 REDO 起点 (minRecoveryPoint)
// 4. 循环调用 XLogReadRecord() 读取 WAL record
// 5. 对每条 record 调用对应的 RMGR (Resource Manager) redo 函数
// 6. 达到 recovery_target 时停止
```bash
### restore_command 的调用时机
`restore_command` 不是恢复开始时调用一次——它是在 Startup 进程需要读取下一个 WAL 段但 `pg_wal` 目录中找不到时被调用的。调用模式如下:
```text
Startup 进程:
need_segment = 00000001000000000000002A
检查 pg_wal/00000001000000000000002A → 不存在
执行 restore_command 'cp /archive/%f %p'
%f = "00000001000000000000002A"
%p = "pg_wal/RECOVERYXLOG"
如果 restore_command 返回 0 → WAL 段已恢复到 pg_wal
如果返回非 0 → 重试(等待 wal_retrieve_retry_interval 毫秒)
重试次数、间隔不可配——如果 archive 中确实没有这个文件,恢复会一直等这意味着 restore_command
的错误处理直接影响恢复成败。一个常见陷阱:restore_command
用的是 shell 返回值,如果脚本写
cp /archive/%f %p || exit 1,一旦 archive
中某个 WAL 段不存在,恢复就会卡住而不是报错退出。你看到的是
Startup 进程在无限重试,而不是明确的”WAL 段缺失”错误。
recovery_target 的精度陷阱
recovery_target_time
的语义是”回放到这个时间戳为止的最后一个完成提交的事务”。但它的精度受限于
WAL record 的时间戳粒度——WAL record 的时间戳来自
GetCurrentTimestamp(),精度是微秒级,但 WAL
中记录的是事务提交时刻的时间戳,不包含语句级别的时间。
这意味着:如果你说
recovery_target_time = '2026-06-16 10:00:00',PG
会在 10:00:00 之后第一个提交的事务处停止。如果某个长事务在
09:59:59 提交了一大半,但 COMMIT 在
10:00:01,这个事务的修改不会出现在恢复后的数据库中。
recovery_target_xid 的陷阱更隐蔽:事务 ID 是
32 位计数器,wraparound 之后会重复。如果你用 xid
做恢复目标,而系统已经经历过 wraparound,同一个 xid
可能对应不同时期的两个事务。
更精确的控制方式是配合
recovery_target_inclusive:设为
true
时恢复包含目标点的事务;设为
false
时恢复停在目标点之前。默认值是
true,如果你要恢复到误操作之前,应该设为
false。
PITR 恢复中 restore_command 的调用时机与 WAL
回放流程:
sequenceDiagram
participant S as Startup 进程
participant Archive as WAL Archive (/archive/)
participant pg_wal as pg_wal/
participant Data as 数据文件
S->>S: 读取 pg_control + backup_label
S->>S: 确定 REDO 起点: LSN 0/2A000000
loop 对每个需要的 WAL 段
S->>pg_wal: 检查 WAL 段是否存在
alt WAL 段已在 pg_wal
pg_wal-->>S: 存在,直接读取
else WAL 段不在 pg_wal
S->>S: 执行 restore_command
S->>Archive: cp /archive/%f pg_wal/RECOVERYXLOG
Archive-->>S: 成功 (exit 0) 或失败 (exit != 0)
alt restore 失败
S->>S: sleep(wal_retrieve_retry_interval)
Note over S: 一直重试直到成功或手动停止
end
end
S->>S: XLogReadRecord() 读取下一条 record
S->>Data: RMGR redo 应用修改
end
S->>S: 达到 recovery_target → 停止回放
S->>Data: 写 timeline history 文件
S->>Data: 更新 pg_control → DB_SHUTDOWNED_IN_RECOVERY
```text
`recovery_target_action` 控制恢复停止后的行为:`pause`(暂停等待,允许你连接检查数据后再决定是否 promote)、`promote`(直接提升为正常模式)、`shutdown`(干净关闭)。生产环境建议用 `pause`——先确认数据状态再 promote。
---
## 二、PITR 的三个操作窗口
PITR 能成功的基础是:base backup 之后的**所有** WAL 记录都可以回放。这拆成三个必须同时满足的条件——缺任何一个,恢复就不完整。
### 窗口 1:最后一次 base backup
没有 base backup,PITR 就没有起点。但 base backup 不是简单的 `cp -r`——`pg_basebackup` 内部通过复制协议读取数据文件,并在备份开始和结束时分别写 `backup_label` 和 `tablespace_map`。`backup_label` 的内容决定了恢复的 REDO 起点:
```text
# backup_label 文件内容示例
START WAL LOCATION: 0/2A000028 (file 00000001000000000000002A)
CHECKPOINT LOCATION: 0/2A000060
BACKUP METHOD: streamed
BACKUP FROM: primary
START TIME: 2026-06-16 09:00:00 CST
LABEL: pg_basebackup base backup
START WAL LOCATION 是备份开始时 WAL
的位置。恢复时从这个 LSN 开始回放——这意味着备份期间产生的
WAL 也必须存在。如果备份开始后立刻就 crash 了,WAL archive
中如果有 START WAL LOCATION 之后的 WAL
段丢失,这个 base backup 无法用于 PITR。
一个 base backup 的”有效期”取决于 WAL archive 的保留时长。如果你保留 WAL archive 30 天,那 30 天前的 base backup 也无法恢复到 30 天前的任何时刻。
窗口 2:WAL archive 的完整性
这是最常见的故障点。archive_command 是一个
shell 命令,它可能因为网络分区、NFS
挂载超时、磁盘满等原因失败。PG 对失败的
archive_command 的处理是重试——但这意味着如果
archive 目标不可用,WAL 段会堆积在 pg_wal 中的
archive_status/ 下并标记为
.ready,等待 archive。
如果 archive_command
持续失败,pg_wal 目录会持续增长(受
max_wal_size 限制的 WAL
回收会被阻塞),直到磁盘满。此时:
- 最新的 WAL 段还在 pg_wal 中(尚未 archive)
- 更早的 WAL 段可能已经被回收(被新 WAL 段覆盖)
- WAL archive 中可能有一个时间段的空洞——缺失的那些 WAL 段对应的修改在 PITR 恢复时会丢失
如何检查 WAL archive 的完整性?pg_waldump
可以逐一检查 WAL 段的连续性:
# 列出 archive 中所有 WAL 段的 timeline + LSN 范围
for f in /archive/*; do
pg_waldump --quiet -r Transaction $f 2>/dev/null | head -1
done
# 检查是否有 gap——如果上一个文件的最后 LSN 不等于下一个文件的起始 LSN,就是空洞
```text
WAL 段的连续性由 `pg_waldump` 输出的 `prev` 指针验证——每条 WAL record 包含前一条 record 的 LSN,形成一条不可间断的链。如果链断了,从断点开始的所有 WAL 都无法回放。
### 窗口 3:pg_wal 中尚未 archive 的 WAL 段
这是恢复时最容易忽略的窗口。如果 primary crash 时 `pg_wal` 中还有尚未被 `archive_command` 传走的 WAL 段,这些段包含的是最近几秒到几分钟的已提交事务——而 PITR 恢复到 crash 前最后一刻的**唯一**数据来源就是这些段。
如果 primary 的磁盘可以访问(哪怕 PG 进程起不来),`pg_wal` 中的 WAL 段就是救命稻草。在启动恢复前,把 `pg_wal` 目录复制到恢复目标环境可以补上最后一块拼图。但如果 primary 的磁盘完全损坏(比如 EBS 卷的物理损坏),这部分数据就永久丢失了——任何 PITR 恢复都只能恢复到最后一个成功 archive 的 WAL 段结束位置。
---
## 三、Timeline fork:为什么恢复后的 primary 不能再回切
PITR 恢复完成后,PG 执行 `promote`(或 `recovery_target_action=promote` 自动提升),数据库进入正常读写模式。promote 操作中,PG 会做一件事:**fork 一条新的 timeline**。
### Timeline 的物理实现
Timeline 是一个单调递增的整数,记录在 `pg_control` 中,也反映在 WAL 文件名中(`00000002 00000000 0000002B` 的前 8 位即 timeline ID)。每次 promote,timeline ID 加 1。同时 PG 在 `pg_wal` 目录下写一个 `00000002.history` 文件:
```text
# 00000002.history 内容
1 0/2B0000A0 after PITR recovery to 2026-06-16 10:00:00这个文件记录了新 timeline 的”切换点”——在 timeline 1 的
LSN 0/2B0000A0 处发生了 timeline
fork,之后的所有 WAL 属于 timeline 2。
为什么不能回切
Timeline fork 不是”分支”——它是”分叉”。一旦 promote
之后写入了任何数据(哪怕一条 INSERT),这条新的
WAL record 就写在了 timeline 2 上。原 primary(timeline
1)在 crash 之后也可能有它自己的新
WAL(如果它被恢复了的话)——这两个分叉之后的 WAL
分别对应不同的数据库状态,逻辑上无法合并。
具体来说: - 恢复后的集群(timeline 2)在 promote
之后产生的新 WAL 不会出现在原始 archive 路径中(如果 WAL
archive 路径没改,新的 WAL 段会覆盖 timeline 1 的段) - 原
primary 的 pg_wal 中可能有尚未 archive 的
WAL(timeline 1),这些 WAL 在 timeline 2
的视角下是”不同现实”——如果强行回放,会导致数据文件状态与 WAL
记录不匹配 - 如果 timeline 1 的 primary
被重新启动并接收写入,那么同一个 relation page 在 timeline 1
和 timeline 2
上各自有不同的修改历史,没有工具能自动合并两个分叉
这就是 pg_rewind
存在的意义:它不是合并两个分叉,而是把其中一个分叉的数据文件回退到分叉点,然后让它从分叉点开始重放另一个
fork 的 WAL。
四、pg_rewind:回退的唯一路径及其边界
pg_rewind --source-pgdata=/path/to/source
的工作方式不是”复制 source 的数据文件到 target 然后重放
WAL”——那样等于重新做一个 base backup。它的实际机制是:
- 扫描 target(需要回退的那个集群)的 WAL,找到与 source 的共同祖先——也就是 timeline fork 的那个 LSN。
- 扫描 source 的数据目录,对每个 relation fork(main、fsm、vm)比较每个数据块的 LSN:如果目标数据块的 LSN 大于共同祖先的 LSN(即该块在 timeline fork 后被修改过),把 source 的对应块复制过来。
- 对 fork 之后 target 上新建或删除的文件做相应处理。
这意味着 pg_rewind
有一个硬性边界:source 和 target
都必须能读取到从共同祖先到各自当前状态之间的全部
WAL。如果 target 的 WAL 不完整(比如某些 WAL
段已被回收),pg_rewind 无法确定哪些数据块在
fork 后被修改过,只能报错退出。
此外,pg_rewind 的
--source-pgdata
指向的必须是干净的关闭状态(clean
shutdown)或者正在运行的 source——如果
source 处于 crash 状态,pg_rewind
无法保证块级别的一致性。
关键边界: - pg_rewind
不处理逻辑复制——它只处理物理数据文件和 WAL。如果 source
上有逻辑复制 slot,rewind 之后 slot 状态可能不一致。 -
pg_rewind 需要 wal_log_hints=on
或在 source 上开启
checksum——否则无法精确判断数据块修改时间。 -
pg_rewind
不保证回退后的数据库能直接启动——回退后需要配置
restore_command 从 source 拉取缺失的
WAL,完成恢复。
五、页面校验:pg_checksums 检查什么、不检查什么
pg_checksums 工具操作的是数据文件(relation
fork)中的页面校验和——它是 pg_control 中
data_checksums
机制的命令行接口。理解它”检查什么”和”不检查什么”是正确使用的前提。
校验粒度
每个 8KB 数据页面(heap page、index page 等)在
PageHeaderData 中有一个
pd_checksum 字段(uint16,位于页面
offset 4-5)。校验和的计算逻辑在
src/include/storage/checksum_impl.h 的
pg_checksum_page():
// 校验和 = 页面内容的 Fletcher-16 校验和
// 计算时 pd_checksum 本身被置为 0
// 校验范围:整个 8KB 页面中非 pd_checksum 字段的部分
```text
`pg_checksums --check` 扫描集群中所有数据文件的每个页面,重新计算校验和并与存储的值比对。不匹配的页面即标记为损坏。
### pg_checksums 不检查什么
这是最容易误解的地方:
1. **不检查逻辑一致性**:校验和匹配只说明页面在写入后没有被静默比特翻转——不说明页面中的 tuple header 字段(xmin/xmax/t_ctid)是否合理、索引页面指针是否仍然有效、FSM 和 VM 是否与实际数据一致。
2. **不检查 WAL 文件**:`pg_checksums` 只扫描数据文件,不碰 `pg_wal` 目录。WAL 损坏不能用它检测。
3. **不检查 TOAST 表与主表的对应关系**:TOAST 表是一个独立的 relation(`pg_toast_<oid>`),`pg_checksums` 分别检查主表和 TOAST 表页面,但不验证它们之间的外存指针(`va_valueid`)是否仍然有效。
4. **不检查 pg_catalog 的语义一致性**:系统表(`pg_class`、`pg_attribute` 等)的页面校验和可能完全正确,但里面的数据可能因为 bug 或人为操作而错乱——这是逻辑损坏,不是物理损坏。
5. **不保证写入路径上没有隐式损坏**:校验和是在 shared_buffers 写脏页时计算的。如果内存中的页面在被写入磁盘前已被某个 bug 破坏(但校验和尚未在写入时重新计算),写入的校验和会是"损坏内容的校验和"——完全匹配,但数据是错的。
### 什么时候用 pg_checksums
它最有效的场景是检测存储层静默损坏(bit rot):磁盘扇区损坏、存储控制器内存错误、文件系统 bug 导致的写入错误。定期 `pg_checksums --check` 结合 `pg_verifybackup` 可以构成存储完整性验证的基线。
启用方式:
```bash
# 初始化集群时启用(此后所有数据页面都会携带校验和)
initdb --data-checksums
# 对已有集群启用(必须在干净关闭状态下执行,会校验所有页面)
pg_checksums --enable -D /data/pgdata注意:启用 data_checksums
后,每个数据页面的写入都多了 Fletcher-16
计算开销。这个开销很小(微秒级),但如果你跑的是极端写入密集负载(每秒数十万页面写入),开销可能累积到
2-5% 的 CPU。
六、zero_damaged_pages:跳过损坏不是修复
zero_damaged_pages = on
是一个极其危险但命名有误导性的 GUC
参数。它的真实语义不是”修复损坏的页面”,而是:
当检测到页面校验和不匹配(data_checksums 开启时)时,不报错、不中断查询、不记录 WAL,而是把这个页面视为全零页面返回给调用者。
全零页面在 PG 内部意味着”这个页面不存在”——对于 heap page,它等价于没有任何 tuple;对于 index page,它等价于空索引页面。但关键问题是:原始的页面数据没有恢复,它被丢弃了。
什么情况下这个参数”有用”
唯一的合法使用场景是:数据库已经损坏,你需要尽快让数据库启动并
pg_dump
抢救数据,而某个不重要的表(比如日志表、缓存表)的页面坏了。你宁可丢掉那个页面的数据,也要把其他还能读的数据
dump 出来。
它不是”修复”手段——用完之后,被 zero 的页面里的数据已经永久丢失了。如果你没有其他备份,这些数据就消失了。
开启后的连锁反应
开启 zero_damaged_pages = on
后,一个损坏页面的影响会扩散:
索引损坏不报错:如果 B-Tree 索引的某个页面校验和不匹配,PG 会把它当成空页面,而且不会报错。后续查询可能因为索引返回的数据与 heap 不一致而产生错误结果——没有报错,只是结果错了。
VACUUM 不可信:VACUUM 扫描一个 zero 的页面时,认为该页面没有任何 tuple,不会清理其中的 dead tuple 指针。但 dead tuple 在其他 index 页面中仍有引用——导致膨胀和不一致。
WAL 不会记录 zero 行为:zero_damaged_pages 不会产生 WAL record——这意味着 standby 不会同步这个 zero 操作,primary 和 standby 从此在物理上分叉。
结论:zero_damaged_pages
是数据抢救的最后手段,不是修复方法。用完立刻
pg_dump
所有还能读的数据,然后重建集群。不要在这个集群上继续跑生产负载。
七、pg_resetwal:最后手段的代价
pg_resetwal 是 PG
工具链中最危险的一个——它不仅清空 WAL,还会重置
pg_control
中的关键元数据。它的内部操作和代价需要逐项拆解。
pg_resetwal 的实际操作
当 PG 拒绝启动(通常是 WAL 损坏、pg_control
损坏、或 timeline
状态不一致),且所有其他恢复手段都失败时,pg_resetwal
做以下几件事:
重置 WAL:创建一个新的 WAL 段,从 LSN 0 开始。所有旧的 WAL 段被重命名为
000000010000000000000000.00000000.backup之类的归档名——它们不再被 PG 识别为有效 WAL。重置 pg_control:将
pg_control中的 checkpoint LSN、REDO LSN、timeline ID 重置为初始值。事务 ID 计数器(nextXid、nextOid、nextMultiXid等)被设置为比当前数据文件中最大 XID 大 1 的安全值(可以通过-x等参数手动指定)。清空 pg_xact(CLOG)和 pg_commit_ts:这些目录中的提交日志是 WAL 恢复的辅助数据——清空 WAL 后它们不再可信,必须重建。但
pg_resetwal不清理它们,你需要手动清理。不碰数据文件:这是最关键的——
pg_resetwal不修改任何 relation fork(heap、index、TOAST、FSM、VM)。数据文件中的 tuple 原封不动保留。
核心代价:hint bit 不可信
pg_resetwal
不碰数据文件,但这个”优点”反过来说是最大的坑。WAL
被清空后,CLOG(提交日志)也被清理——但数据文件中每个 tuple
header 的 t_infomask 可能设置了 hint
bit(HEAP_XMIN_COMMITTED /
HEAP_XMAX_COMMITTED /
HEAP_XMAX_INVALID 等)。
Hint bit 是在第一次读取 tuple 时,通过查询 CLOG 写入的”缓存标记”。它的作用是避免后续每次读取都要查 CLOG。当 CLOG 被清空后:
- 已经有 hint bit 的 tuple:hint bit 说 xmin 已提交——但 CLOG 没了,无法验证这个 hint bit 是否正确。如果 hint bit 是在 WAL 损坏时被错误设置的,PG 无法检查。
- 没有 hint bit 的 tuple:PG 需要查 CLOG
来判断事务是否提交——但 CLOG 被清空了,PG 只能用
TransactionIdDidCommit()的 fallback 逻辑(检查事务 ID 是否比当前 oldest 活跃事务还旧),这个判断在某些边界情况下是错误的。
结果:设置了 hint bit 的 tuple
的可见性不确定。在 pg_resetwal
之后的数据库中运行查询,你可能看到: - 本应不可见的 tuple
突然变成可见(hint bit 错误标记为 committed) - 本应可见的
tuple 不可见(没有 hint bit,CLOG 又无法确认) -
索引扫描和顺序扫描返回不同的结果(索引页面的 hint bit 状态与
heap 不一致)
必须 VACUUM FULL 的陷进
pg_resetwal 之后的标准操作是:
pg_resetwal -f /data/pgdata # reset WAL
pg_ctl start -D /data/pgdata # 启动数据库
```text
但启动后第一个关键操作是 `VACUUM FULL`——因为只有 VACUUM FULL 才会重建所有数据文件的内容,用事务 ID 的当前状态重新写入每个 tuple 并在新的 WAL 中生成正确的可见性标记。
然而这里有一个陷进:`VACUUM FULL` 本身需要写 WAL。如果 `pg_resetwal` 之后集群无法正常写 WAL(比如 WAL 目录权限问题、磁盘空间不足),`VACUUM FULL` 会失败。而如果 `VACUUM FULL` 失败,hint bit 的不一致就无法修复。
更进一步的陷进是:`VACUUM FULL` 在 PG 9.4 之后使用了 `CLUSTER` 风格的重写——它创建新的 relation fork 文件,把活 tuple 从旧文件复制到新文件,然后删除旧文件并重建索引。这个过程本身会生成大量 WAL,如果 `max_wal_size` 配置不当,可能导致 WAL 在 VACUUM FULL 期间被高频切换,进一步拖慢恢复。
### pg_resetwal 的正确使用流程
1. **确认所有其他手段都失败**:尝试过 `pg_controldata` 检查控制文件、尝试过手动恢复 WAL、尝试过从 standby 拷贝 `pg_control`。
2. **运行前备份整个数据目录**:包括 `pg_wal`、`pg_xact` 和所有表空间。
3. **指定 `--next-transaction-id`**:如果你记得最近的事务 ID,手动指定它比让 pg_resetwal 自己猜测安全得多。
4. **启动后立即检查**:`SELECT count(*)` 遍历所有用户表的行数,比对业务预期。如果有行数异常偏多(hint bit 导致无限 tuple 被当成可见)或偏少,立即 `pg_dump` 抢救。
5. **VACUUM FULL 所有用户表**:重建数据文件和索引。
6. **重新做 base backup 并重建 standby**:现有 standby 已经和重置后的 primary 物理分叉,必须重建。
---
## 八、pg_dump/pg_restore 的并行策略边界
当物理恢复路径全部走不通时,`pg_dump` 是数据抢救的最后一道防线——它是逻辑导出,只读取 SQL 可见的数据(通过正常的 MVCC 快照),不依赖任何 WAL 或物理一致性。
### 格式选择:-Fc vs -Fd
`pg_dump` 支持四种输出格式:
| 格式 | 选项 | 特点 | 并行恢复 |
|------|------|------|----------|
| plain | `-Fp` | SQL 文本,只支持单线程恢复 | 不支持 |
| custom | `-Fc` | 压缩的二进制格式,`pg_restore` 可直接读取 | 支持 `--jobs=N` |
| directory | `-Fd` | 每个 object 一个文件,放在目录下 | 支持 `--jobs=N` |
| tar | `-Ft` | tar 归档,最大 8GB 限制 | 不支持 |
`-Fc` 和 `-Fd` 的关键差异在恢复性能上。`-Fc` 是一个单一的压缩文件——所有 table data、schema、index、constraint 按顺序写入。`pg_restore --jobs=N` 读取它时,主进程需要先解析整个文件建立 TOC(Table of Contents),然后才分发给 worker。
`-Fd` 则把每个 object(每个表的数据、每个索引定义、每个约束)存成 `toc.dat` 中的条目和对应的 `<n>.dat.gz` 数据文件。`pg_restore --jobs=N` 主进程读取 `toc.dat` 建立依赖图,然后直接让 worker 读取各自负责的 `.dat.gz` 文件——这避免了 `-Fc` 格式下主进程需要解压整个文件的瓶颈。
在大表场景下(>100GB),`-Fd` 的并行恢复性能优于 `-Fc`,因为 worker 各读各的文件,避免了单文件 I/O 串行化的瓶颈。
```bash
# -Fd 导出(并行导出需要 PG 9.3+)
pg_dump -Fd -j 4 -f /backup/dumpdir dbname
# -Fc 导出
pg_dump -Fc -f /backup/dump.dump dbname
# 并行恢复(pg_restore 需要读取 -Fc 或 -Fd)
pg_restore -Fd -j 8 /backup/dumpdir
pg_restore -Fc -j 8 /backup/dump.dumppg_restore –jobs=N 的内部调度
pg_restore --jobs=N 的并行不是”N 个 worker
同时跑,每个处理一部分 SQL”。它的调度逻辑是:
- 主进程读取 dump 文件的 TOC,建立所有 object 的依赖图(schema → table → data → index → constraint)。
- 主进程维护一个 work queue(
ParallelSlot数组),最多N个 slot。 - 每个 slot 对应一个数据库连接。主进程从 TOC
中选出一个满足依赖条件的
object(比如某个表的数据),分配给一个空闲 slot 的 worker
去执行
COPY ... FROM STDIN或重建索引。 - 一个表的数据(COPY data)全部给同一个 worker——不能把一个表的数据拆成多个 worker 并行加载。
这意味着: - 不是 N 个 worker
同时处理一个表——每个表的 COPY 数据是单 worker
串行加载的。并行加速来自不同表同时在加载(多表并行)或数据加载与索引创建并行(一个
worker 加载表 A 的数据,另一个 worker 同时为表 B
创建索引)。 -
索引创建是并行的杀手锏:pg_restore
在所有表的 COPY 完成后才创建索引。如果只有一个大表(比如一个
500GB 的日志表),并行恢复的意义不大——COPY
是单线程的。但如果有一堆中等大小的表,多个 worker
可以同时加载多张表并最后并行创建索引。 -
--jobs 的上限:数据库的
max_connections 需要至少
jobs + 1(主进程一个连接 + worker
各一个)。
大表分表 dump 策略
如果单表超过几百 GB,并行恢复的加速效果被单线程 COPY
卡住。解决策略是用 --table 按条件分表
dump:
# 按主键范围分片 dump
for i in $(seq 0 9); do
pg_dump -Fd -j 4 -f /backup/dump_part_${i} \
--table=large_table \
--where="id % 10 = ${i}" dbname &
done
wait
# 恢复:10 个独立 pg_restore 并行跑
for i in $(seq 0 9); do
pg_restore -Fd -j 4 -d dbname /backup/dump_part_${i} &
done
```text
注意:分表 dump 的前提是表有合适的分区键或主键范围可以切分。`--where` 子句会下推到数据库执行,所以切分效率取决于是否有对应索引。
### COPY vs INSERT 恢复性能
`pg_dump` 默认使用 `COPY` 语句导出数据——COPY 走的是 bulk load 路径,直接写 heap page,跳过大部分约束检查和 trigger。恢复时 `COPY ... FROM STDIN` 也是逐页写入,不生成每条 tuple 的独立 WAL record(在 `wal_level=minimal` 下甚至只写少量 WAL)。
相比之下,`--inserts` 选项强制用 `INSERT` 语句导出数据——每行一条 INSERT 语句。恢复时每条 INSERT 都是一个完整的 DML 事务路径(parse → plan → execute → WAL),比 COPY 慢 10-100 倍。`--inserts` 的唯一合法用途是跨数据库类型迁移(比如 PG → 其他 SQL 数据库)。
```bash
# 默认(COPY):最快,但不可跨数据库
pg_dump -Fc -f dump.dump dbname
# INSERT:可跨数据库,但恢复极慢
pg_dump --inserts -Fc -f dump.dump dbname
# 恢复加速:禁用 trigger 和 owner 重设
pg_restore --no-owner --disable-triggers --jobs=8 -d dbname dump.dump恢复时的 --disable-triggers 让所有用户
trigger 在 restore session
中禁用(session_replication_role = replica),避免
trigger
导致的级联写入和约束检查拖慢恢复。恢复完成后需手动启用
trigger 并验证数据完整性。
九、关键要点
- PITR 恢复有三个必须同时满足的窗口:base backup、WAL archive 完整性、pg_wal 中未归档的段。缺任何一个,恢复就不完整。这是 pre-check 清单,不是事后补。
- Timeline fork 是单向操作——promote
之后原 primary 的 WAL 与新 primary
物理分叉,没有自动合并机制。
pg_rewind是回退到分叉点的唯一路径,前提是双方的 WAL 完整。 pg_checksums检测物理校验和不匹配,不检测逻辑损坏。校验和匹配不等于数据正确——内存中的损坏可能在校验和计算时被一起”合法化”。zero_damaged_pages = on不是修复,是丢弃——损坏的页面被替换为零填充,数据永久丢失。只应该在抢救数据的最后手段中使用,用完立刻 dump 重建。pg_resetwal清空 WAL 但不碰数据文件——代价是 hint bit 不可信。所有设置了 hint bit 的 tuple 的可见性不确定。必须 VACUUM FULL 重建,但 VACUUM FULL 又需要 WAL——如果新 WAL 写入也有问题,这个循环无法打破。pg_dump/pg_restore的并行不是”一张表被拆成 N 份同时加载”——是多个表并行加载。大表需要分表 dump 来绕过单线程 COPY 的瓶颈。-Fd格式的并行恢复 I/O 模式优于-Fc。- 恢复操作的决策顺序应该是:PITR(如果 WAL 完整)→ pg_rewind(如果只是 timeline 分叉)→ pg_resetwal(如果 WAL 彻底损坏)→ pg_dump 抢救(如果数据文件损坏但还能读)→ zero_damaged_pages(最后手段中的最后手段)。每降一级,数据损失的范围扩大一级。
上一章:性能异常调查方法论 下一章:大版本升级与迁移实战,拆解 pg_upgrade –link 的 hard link 原理、逻辑复制跨版本迁移的流程和陷阱,以及迁移方案决策树——从 10GB 小库到 500GB+ 大库的升级路径选择。
参考资料
源码(PG 17)
src/backend/access/transam/xlog.c:StartupXLOG()、recovery_target 处理、promote 流程src/backend/access/transam/xlogarchive.c:RestoreArchivedFile()——restore_command 的调用逻辑src/include/access/xlogrecord.h:XLogRecord 结构体定义src/include/storage/checksum_impl.h:pg_checksum_page()——校验和计算算法src/backend/access/transam/rmgr.c:RmgrTable——各 RMGR 的 redo 函数注册src/backend/access/transam/clog.c:CLOG 的 SLRU 页面管理src/backend/access/heap/heapam_visibility.c:HeapTupleSatisfiesMVCC()、SetHintBits()src/backend/access/heap/rewriteheap.c:VACUUM FULL 的 heap 重写逻辑src/bin/pg_resetwal/pg_resetwal.c:pg_resetwal 主逻辑src/bin/pg_rewind/pg_rewind.c:pg_rewind 主逻辑src/bin/pg_dump/pg_dump.c:pg_dump 导出逻辑src/bin/pg_dump/pg_backup_archiver.c:pg_restore 的并行调度(ParallelSlot)
官方文档
- PostgreSQL Documentation, Chapter 26: Backup and Restore(PITR、pg_basebackup、pg_dump)
- PostgreSQL Documentation, Chapter 30: Reliability and the Write-Ahead Log(WAL 架构、checkpoint、恢复)
- PostgreSQL Documentation, Chapter 51: Overview of PostgreSQL Internals(WAL 内部结构)
- PostgreSQL Documentation, Appendix C: pg_resetwal
- PostgreSQL Documentation, Appendix D: pg_rewind
- PostgreSQL Documentation, Appendix G: pg_checksums
工具手册
- pg_waldump(WAL 内容查看工具)——WAL record 的解析和连续性检查
- pg_controldata(控制文件信息查看)——pg_control 字段含义
- pg_verifybackup(base backup 完整性验证)——配合 pg_checksums 使用
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】大版本升级与迁移实战:pg_upgrade --link 为什么快以及为什么没有回滚
拆解 pg_upgrade 的三种模式(--link 硬链接零拷贝、--clone CoW 快照、--copy 物理复制)的执行流程、内部机制和不可回滚的根本原因;逻辑复制跨版本迁移的低停机方案及序列/large object/DDL 三大盲区;四种常见坑的根因与应对;附带迁移方案决策树,从小库到大库选哪种方案一次说清。
【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 内核】WAL 内部机制:从事务提交到磁盘刷写
拆解 PostgreSQL WAL 的完整内部机制:XLogInsert() 从分段锁到 WAL Buffer 的插入路径、XLogRecord 的物理布局(Header + Block Headers + Data)、Checkpoint 的两阶段流程与 IO 摊平算法、REDO 恢复的 RMGR 分发、wal_level 三级差异的 WAL 记录对比。运维部分聚焦 checkpoint IO 风暴的根因与 checkpoint_completion_target 的调优陷阱、max_wal_size 设小导致 WAL 段疯狂切换的机制,以及用 pg_waldump 定位问题 WAL record 的实操方法。
【PG 内核】PostgreSQL 内核机制深度拆解
从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。