本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的 batch-4/ 目录(examples/、evidence/ 等子目录);业务数据均为合成数据。

“备份成功”是一条日志,“能恢复到指定时刻且数据正确”才是一项能力。两者之间隔着几个容易略过的问题:恢复点到底落在哪里,归档的 WAL 是否完整,恢复出来的实例里账是否平,角色和权限是否还在,切换之后旧主库会不会继续接受写入。

本篇在一台机器上启动多个隔离的 PostgreSQL 16 实例,实际执行基础备份、WAL 归档、按命名还原点的时间点恢复(PITR)、计划内切换和注入复制中断的非计划切换,每一步都用 SQL 检查数据本身。所有演练证明的是脚本与步骤在单机上可执行、检查项能发现错误;它们不证明跨地域网络、存储、DNS 或权限体系的行为。

1. 需求、目标与不变量

被保护的数据是一个合成的双边记账库:account(id, balance) 有 200 个账户,entry(request_id, account_id, amount, batch) 记录每笔转账的借贷两条流水,request_id 唯一。业务不变量只有一条,但足以暴露大多数恢复错误:

  • INV-L 每个账户的 balance 等于该账户全部 entry.amount 之和;所有流水总和为 0。

恢复目标用两个通行的定义表达。AWS Well-Architected 可靠性支柱把 RTO 定义为服务中断到恢复之间可接受的最大延迟,把 RPO 定义为距上一个数据恢复点可接受的最长时间;NIST SP 800-34 Rev.1 给出的 RPO 是“中断前、业务数据能够恢复到的时间点”,并指出 RPO 不属于最大可容忍停机时间(MTD)的组成部分。本篇不为合成业务设定具体数值,只测量每种方案实际给出的恢复点与本地耗时,并说明它们为什么不能外推。

演练要回答的问题写成检查项:

编号 检查 观察方式
C1 基础备份文件与清单一致,所需 WAL 存在且可解析 pg_verifybackup
C2 恢复到误操作之前的命名还原点,数据与当时的快照逐行一致 指纹:行数、金额和、按 request_id 排序后的 md5
C3 恢复后的实例满足 INV-L SQL 不变量查询
C4 还原点之后的写入不存在 按批次计数
C5 角色与表权限随恢复保留 pg_roles、has_table_privilege
C6 索引与堆表结构没有可检测到的损坏 amcheck 的 bt_index_check、verify_heapam
C7 计划内切换后数据一致,新主可写,旧主被隔离且只能作为备库 指纹、写入、只读错误
C8 非计划切换时,“客户端已确认”的写入是否丢失 客户端确认日志与提升后备库逐条比对
C9 分叉的旧主库能回到新时间线并作为备库接入,且不残留分叉写入 pg_rewind 退出码、pg_stat_wal_receiver、按请求标识计数

2. 环境与契约

项 值
PostgreSQL 16.15(Ubuntu 包,/usr/lib/postgresql/16/bin)
初始化 initdb --data-checksums -A trust,监听 Unix 套接字,端口 55431–55462
归档 wal_level=replica,archive_mode=on,archive_command='test ! -f <arch>/%f && cp %p <arch>/%f'
运行入口 python3 examples/ch38_backup/drill.py --evidence evidence/38 --part all(27 项检查全部通过,见 evidence/38/results-all.json)
共享内存 initdb -c dynamic_shared_memory_type=mmap 并在配置中保持 mmap;本机 /dev/shm 只有 64 MB,且被同机其他实例占满

最后一行是演练中实际遇到的环境问题:第一次运行时,恢复实例的日志反复出现“could not resize shared memory segment … No space left on device”,等待提升的检查超时。第二天重跑时,连 initdb 的引导阶段也因同样原因中止,只能在初始化时就指定 mmap。它和备份本身无关,却足以让一次真实恢复失败。恢复环境与生产环境的差异,本身就是演练要找的东西。

归档命令的写法沿用 PostgreSQL 文档的示例。文档要求归档命令当且仅当成功时返回 0,并提醒 GNU cp -i 在目标已存在时也返回 0,因此不能用它防止覆盖。

3. 时间点恢复

3.1 步骤

---
title: 图 38-1 PITR 演练时间线(批次、备份、还原点与误操作)
---
flowchart LR
    A[建库与 b1: 2000 笔转账] --> B[pg_basebackup -X stream -c fast]
    B --> C[pg_verifybackup]
    C --> D[b2: 1500 笔转账]
    D --> E["pg_create_restore_point('before_bad_change')"]
    E --> F[误操作: 删除 b1 中 account_id%7=0 的流水]
    F --> G[b3: 500 笔转账]
    G --> H[pg_switch_wal 并等待归档]
    H --> I[复制基础备份 → recovery.signal + restore_command + recovery_target_name]
    I --> J[启动 → 回放 → promote → 逐项检查]

误操作只删流水、不改余额,所以会直接破坏 INV-L;它之后还有正常写入 b3,用来检验恢复是否“恰好停在还原点”。

恢复实例的配置:

1
2
3
4
restore_command = 'cp <archive>/%f %p'
recovery_target_name = 'before_bad_change'
recovery_target_action = 'promote'
archive_mode = off

recovery_target_action 的默认值是 pause,不是 promote。PostgreSQL 16 文档说明,暂停状态要用 pg_wal_replay_resume() 结束;hot_standby 关闭时 pause 等同于 shutdown;选 shutdown 时 recovery.signal 不会被删除,之后每次启动都会立即停机。演练脚本显式写成 promote,因为检查需要一个可写实例;生产恢复更稳妥的做法是先 pause,在只读状态下核对数据,确认后再结束恢复。recovery_target_inclusive 只作用于 lsn、time、xid 三种目标,对命名还原点不适用。

恢复实例关闭了归档。这是一个不起眼但必要的设置:恢复实例如果与生产共用归档目录,它在新时间线上产生的 WAL 会写进同一个归档。

3.2 结果

检查 结果 观察值
C1 pg_verifybackup PASS backup successfully verified
误操作后主库 INV-L 检出破坏 28 个账户余额与流水不符;b1=3442
C2 PITR 指纹 PASS 7000:0:2b7874da…,与还原点前主库快照相同
C3 PITR 后 INV-L PASS 0 个违例
C4 还原点后写入 PASS b3 行数 0
时间线 PASS timeline_id=2,.history 文件存在
C5 角色与权限 PASS app_ro,app_rw;app_ro 可读不可写
C6 amcheck PASS 3 个 B-tree 索引检查无错误;verify_heapam(entry) 0 行

本地耗时(evidence/38/results-all.json 所记录的那次运行):基础备份 0.41 秒,从启动恢复实例到 pg_is_in_recovery() 返回 false 0.55 秒;多次运行之间有零点几秒的波动。数据量只有数千行,这两个数字只说明脚本能跑完,不代表任何容量下的 RTO。真实 RTO 要包括拉取备份、回放 WAL、核对数据、切换流量和人工决策的时间,其中回放时间与备份之后的写入量成正比。

3.3 两个负向对照

不设恢复目标、回放全部归档的实例,指纹与误操作后的主库完全相同,INV-L 检查报出同样的 28 个违例。这说明“恢复到最新”在逻辑误操作场景下等于把误操作也恢复了,也说明不变量检查确实能发现这类问题。

只用基础备份、不回放归档的实例满足 INV-L,但只有 b1=4000,备份之后写入的 b2 全部缺失。它是一致的,也是过时的:只做定期全量备份时,RPO 就是两次备份之间的间隔。PostgreSQL 文档在 archive_timeout 的说明中指出,只有写满的 WAL 段才会被归档,archive_timeout 用于强制定期切换段,从而限制未归档数据的最大年龄;文档认为一分钟左右的设置通常合理,更小的 RPO 应当依赖流复制。

3.4 校验工具各自能证明什么

pg_verifybackup 读取 backup_manifest,检查文件是否缺失或多出、大小与校验和是否一致,并在第四阶段调用 pg_waldump 确认恢复所需的 WAL 存在且可以解析。文档同样明确:它“不会也不能”包含运行中的服务器在使用备份时执行的全部检查,仍然需要测试恢复并核对数据。清单默认使用 CRC32C 校验和,能发现意外改动,不能防篡改;防篡改需要 SHA 系列校验和,并把清单存放在备份之外。PostgreSQL 16 的 pg_verifybackup 只读取 plain 格式备份,tar 格式要先解开。

amcheck 的文档有一句话限定了 C6 的意义:它只能证明损坏存在,不能证明损坏不存在。数据页校验和(本演练用 initdb --data-checksums 开启;PostgreSQL 16 默认关闭)只覆盖数据页,也无法发现逻辑层面的损坏。所以 C2、C3 的业务级检查不能被 C1、C6 替代:前者回答“数据对不对”,后者回答“结构坏没坏”。

3.5 逻辑备份对照:权限不会自动跟随

同一个库用 pg_dump -Fc 导出,在一个全新集群上 pg_restore。退出码为 1,新集群中没有 app_rw、app_ro 两个角色,授权语句全部失败。PostgreSQL 文档说明,pg_dump 只覆盖单个数据库,角色、表空间等全局对象需要 pg_dumpall(--globals-only);pg_dumpall 还会保存配置参数上的授权,这是 pg_dump 不保存的。逻辑备份的恢复流程因此至少有两步:先恢复全局对象,再恢复数据库,最后执行 C5。

4. 计划内切换

拓扑:主库 55441,流复制备库 55442(pg_basebackup -R 生成 standby.signal 与连接配置)。步骤与检查:

  1. 记录旧主的数据指纹,然后以 fast 模式停止旧主。
  2. 从 pg_controldata 读取旧主最后的检查点位置,等待备库 pg_last_wal_replay_lsn() 不小于它。
  3. pg_promote(true, 60),等待 pg_is_in_recovery() 为 false。
  4. 校验新主指纹与旧主停止前一致,满足 INV-L,并写入一笔新转账。
  5. 在旧主数据目录放入 standby.signal,把 primary_conninfo 指向新主,再启动它。

结果全部通过:新主指纹 3000:0:d6e98891… 与旧主停止前相同;旧主以备库身份接收新时间线(received_tli=2),回放到了新主上的写入;向旧主执行 INSERT 得到 cannot execute INSERT in a read-only transaction。同一次运行中,从停止旧主到新主可写用时 0.24 秒。

第 2 步不能省略。如果在备库尚未回放到旧主最后位置时提升,切换就从计划内变成了有损的非计划切换,而操作者不会收到任何提示。第 5 步是隔离(fencing)的最弱形式:旧主即使被误启动,也只能成为备库。PostgreSQL 文档在故障切换一节说明,必须有机制让旧主无法继续作为主库,否则两边都认为自己是主库;这类机制常被称为 STONITH。文档同时说明 PostgreSQL 本身不提供识别主库故障并通知备库的系统软件,故障检测与编排要由外部负责。

---
title: 图 38-2 计划内切换与非计划切换中的角色变化
---
stateDiagram-v2
    state "主库可写" as P
    state "停止写入" as S
    state "备库追平" as C
    state "新主可写" as N
    state "旧主作为备库" as F
    state "主库丢失" as L
    [*] --> P
    P --> S: 计划内:fast 停止旧主
    S --> C: 回放 LSN ≥ 旧主最后检查点
    C --> N: pg_promote
    N --> F: standby.signal + 指向新主
    P --> L: 非计划:进程被杀 / 地域失效
    L --> N: 直接提升(异步时已确认未复制的写入丢失)
    F --> [*]

5. 非计划切换:已确认的写入去了哪里

这一组演练比较异步复制与同步复制在主库突然失效时的结果。观察点是客户端:每条写入在 psql 返回成功后记入“已确认”列表,最后与提升后的备库逐条比对。

步骤:先在复制正常时写入 100 条并等待备库追平;然后切断复制链路;再尝试写入 50 条;随后 SIGKILL 主库所有进程,提升备库。

切断复制的方式经过一次修正。最初的做法是对备库的 walreceiver 进程发送 SIGSTOP,结果异步模式下 50 条“链路中断期间”的写入在提升后全部存在:主库已经把这些 WAL 写进了本机套接字缓冲区,walreceiver 恢复运行后照常读到。故障注入必须作用在数据真正所在的位置。修正后的做法是把备库的 primary_conninfo 改为不可达地址并重载配置,等主库 pg_stat_replication 变为 0 行后再写入。

模式 已确认写入 提升后存在 已确认但丢失 第二阶段客户端超时
异步 150 100 50 0
同步(synchronous_standby_names='FIRST 1 (standby1)') 100 100 0 3(随即停止尝试)

异步模式下,主库对 50 条写入都返回了成功,主库失效后它们不存在于新主库。PostgreSQL 文档在流复制一节写得很直接:主库崩溃时,一些已提交的事务可能尚未复制到备库,造成数据丢失。同步模式下,链路中断期间提交一直等待同步备库的确认,客户端在 2 秒后超时,没有任何一条写入被确认后丢失。代价也在表里:复制链路一断,写入就不可用。

同步模式下那 3 条超时的写入属于“结果未知”。它们在旧主库上已经写入了提交记录,只是没有收到同步确认;客户端不能把超时当作失败,也不能当作成功。重试必须带幂等键(本库的 request_id 唯一约束就是为此准备的),否则旧主库如果被修复并重新接入,就可能出现重复。

5.1 分叉的旧主库:pg_rewind

异步场景中,旧主库比新主库多出那 50 条写入,两者的 WAL 从分叉点起各走各的路。旧主库不能直接以备库身份接入:它的数据包含新主库没有的事务。重建它有两种办法:从新主库重新做一次基础备份,或者用 pg_rewind 只复制分叉之后变化过的数据块。PostgreSQL 文档说明,pg_rewind 要求目标集群开启了 wal_log_hints 或在 initdb 时启用了数据校验和(两者默认都关闭),并且 full_page_writes 为 on;目标没有正常关闭时,它会先以单用户模式完成崩溃恢复。

演练第一次运行 pg_rewind 失败了,日志保存在 evidence/38/pg_rewind-first-attempt-without-archive.txt:单用户崩溃恢复完成后,它报告两个集群在时间线 1 的某个位置分叉,然后找不到 pg_wal/000000010000000000000002,因为它要从分叉点之前的最后一个公共检查点开始读旧主库的 WAL,而这个段已经在崩溃恢复后被回收。文档给出的解决办法是 -c/--restore-target-wal:用目标集群配置中的 restore_command 从归档取回 pg_wal 中已经不存在的 WAL。演练脚本随后给旧主库加上归档与 restore_command,改用 --restore-target-wal 运行,结果:

检查 观察
pg_rewind 退出码 0
旧主库以备库身份接入新主库 pg_stat_wal_receiver.status = streaming,received_tli = 2
新主库上的新写入 旧主库回放可见
旧主库中的分叉写入(async-b-*) 0 条;旧主库共 101 行,等于新主库提升时的 100 行加 1 条新写入

最后一行要读清楚:那 50 条客户端已确认的写入,在 pg_rewind 之后从旧主库上也消失了。它们原本是唯一的副本。如果业务需要找回这些写入,必须在运行 pg_rewind 之前把旧主库的数据导出或单独保留一份,用于事后对账。文档还提醒,pg_rewind 在处理过程中失败,目标数据目录很可能处于无法恢复的状态,所以“先保留副本,再回卷”也是对这一风险的防护。本演练没有做这一步,记为运行手册中的待补项。

6. 方案比较

方案 RPO 来源 恢复时间主要取决于 能处理逻辑误操作 本篇验证
定期全量物理备份 备份间隔 拷贝数据量 能,但只能回到某次备份 负向对照
基础备份 + WAL 归档(PITR) 最后一个已归档 WAL 段;受 archive_timeout 约束 拷贝 + 回放量 能,可停在误操作之前 通过
逻辑备份(pg_dump/pg_dumpall) 导出时刻 重建与索引构建 能,粒度为整次导出 权限对照
异步流复制 + 提升 复制延迟 故障检测 + 提升 + 流量切换 不能,误操作会被复制 通过(观测到丢失)
同步流复制 + 提升 已确认写入不丢 同上 不能 通过(链路断开时写入阻塞)

复制与备份解决的是不同的问题。PostgreSQL 文档把基于基础备份加回放的方式归为容灾而不是高可用;反过来,任何复制方案都会把误删除忠实地复制过去,只有带时间点的备份能回到误操作之前。地域切换方案通常同时需要两者:跨地域复制满足 RTO,独立保存的备份与归档满足误操作恢复,并且归档要放在与主库不同的故障域中。

7. 限制

  • 所有实例在同一台机器、同一个文件系统上;归档目录与数据目录同盘。演练不证明跨地域网络延迟、带宽、分区,也不证明对象存储或远端归档的持久性。
  • 数据量为数千行,耗时数字不可外推;未测试 WAL 回放速度与备份大小的关系。
  • 角色检查只覆盖两个角色和表级权限;行级安全、外部认证、密钥与证书不在演练范围。
  • 故障检测、自动编排、客户端连接切换(DNS、连接串、代理)未实现;切换由脚本直接执行。
  • 隔离只用 standby.signal 演示;真实环境需要在网络或存储层阻止旧主库接受连接。
  • pg_rewind 只演练了异步场景下的一次回卷;回卷前导出分叉写入、同步场景下旧主库上那 3 条结果未知写入的处理没有演练。

8. 来源

核对日期 2026-10-18,文档版本 PostgreSQL 16(/docs/16/,与本机 16.15 二进制的 --help 输出一致)。

本批导航