后端系统工程 38:数据备份、恢复与地域切换——恢复演练要检查数据,不只检查进程
本文属于《后端系统工程:从接口契约到故障恢复》系列。文中提到的演练工程、测试与实验证据位于博客源码仓库的
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 | |
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 与连接配置)。步骤与检查:
- 记录旧主的数据指纹,然后以 fast 模式停止旧主。
- 从
pg_controldata读取旧主最后的检查点位置,等待备库pg_last_wal_replay_lsn()不小于它。 pg_promote(true, 60),等待pg_is_in_recovery()为 false。- 校验新主指纹与旧主停止前一致,满足 INV-L,并写入一笔新转账。
- 在旧主数据目录放入
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 输出一致)。
- 26.3 Continuous Archiving and PITR:归档设置、归档命令返回值、
cp -i的问题、recovery.signal、时间线与历史文件。https://www.postgresql.org/docs/16/continuous-archiving.html - 20.5 WAL 配置:
recovery_target_action默认pause及其他取值的副作用;recovery_target_inclusive的适用范围;archive_timeout;wal_level。https://www.postgresql.org/docs/16/runtime-config-wal.html - 9.27 管理函数:
pg_create_restore_point、pg_switch_wal、pg_promote。https://www.postgresql.org/docs/16/functions-admin.html - pg_basebackup、pg_verifybackup(含 WAL 解析阶段与“不能替代测试恢复”的说明)。https://www.postgresql.org/docs/16/app-pgverifybackup.html
- F.2 amcheck:只能证明损坏存在。https://www.postgresql.org/docs/16/amcheck.html
- pg_dump、pg_dumpall:全局对象与配置参数授权。https://www.postgresql.org/docs/16/app-pg-dumpall.html
- pg_rewind:需要
wal_log_hints或数据校验和;--restore-target-wal;未正常关闭时先做单用户崩溃恢复;处理中失败可能使目标无法恢复。https://www.postgresql.org/docs/16/app-pgrewind.html - 27.2–27.3 备库与故障切换:异步复制可能丢失已提交事务;STONITH;PostgreSQL 不提供故障检测。https://www.postgresql.org/docs/16/warm-standby-failover.html
- 30.2 数据校验和:PostgreSQL 16 默认关闭,只覆盖数据页。
- AWS Well-Architected Reliability Pillar,Disaster Recovery (DR) objectives:RTO 与 RPO 定义。
- NIST SP 800-34 Rev.1(2010),3.2.1:MTD、RTO、RPO 定义及“RPO 不属于 MTD”。
本批导航
- 上一篇:37 超时预算与负载保护
- 下一篇:39 综合方案评审与交付
- 相关:36 指标采集与告警生命周期;27 测试账户的余额与账本






