故障注入、RTO/RPO、演练与回滚:恢复能力怎样被证明
恢复目标可以写得很具体:订单服务中断后,允许多长时间无法接单;恢复出来的数据,最多可以缺少故障前多长时间内已经确认的写入。接着还要定义“恢复完成”:端口开放、能够查旧订单、能够创建新订单、积压处理结束,分别发生在不同时间。
将恢复目标写成可以测量的条件
RTO、RPO 与实际测量值
RTO(Recovery Time Objective)是可以接受的最长恢复时间,RPO(Recovery Point Objective)是可以接受的数据恢复点与故障之间的最大时间间隔。两者都是目标,实际演练测到的数值要与目标比较。AWS 灾难恢复目标给出了这两个定义。
时间向右
最后可恢复的数据点 ── 已确认但可能缺失的写入 ── 服务中断 ── 恢复服务 ── 积压与对账结束
└──────────── 数据恢复点间隔 ────────────┘
└─ 实际恢复时间 ─┘RPO 使用时间单位。缺失 1 条订单、3 条流水或 100 元交易额,是数据损失数量或业务影响,不能把 rpoRecords=3 当作 RPO。记录数还要结合写入频率解释:一分钟没有写入,与一分钟确认了一万笔交易,风险完全不同。
RTO 的结束条件必须与服务目标对应。如果承诺的是恢复接单,至少要验证依赖、认证、读取和新订单写入都可用;只启动数据库进程尚未到达这个条件。若允许先恢复核心接口,再补历史报表,应分别报告恢复时刻和剩余降级。
明确数据比较基准
演练前保留调用方已经收到成功确认的操作集合,恢复后按稳定业务标识核对。数据库行数相同也可能出现“少一条旧记录、多一条重复记录”;至少要比较标识、金额或其他关键业务字段,以及必要的不变量。
可用的恢复点可能来自备份快照、WAL 位置或可查询的业务水位。它们描述的对象不同:事务 ID、日志位置不能随便换算成墙钟时间;应用记录的创建时间也未必就是提交时刻。要把测量来源、时钟和近似方式写清楚。
限定故障和停止条件
一次故障实验需要先确定目标、影响范围、持续上限、停止条件和回收方式。学习环境可以只包含两个随机命名的 PostgreSQL 容器;生产演练则需要对应负责人授权,确认备用容量、数据保护和停止入口。
| 注入方式 | 主要测试什么 | 需要观察的命中情况 |
|---|---|---|
| 延迟或超时 | 等待预算、在途占用、取消 | 请求实际进入受影响路径,阶段耗时改变 |
| 选定 HTTP 错误 | 分类、重试、熔断与降级 | 状态来源、下游访问数、恢复探针 |
| 丢包或网络分区 | 连接等待、重连与主从协调 | 实际网络方向、受影响连接及未受影响路径 |
| TERM 或 KILL | 排空、强杀后的重新处理 | 信号、退出原因、已接收工作状态 |
| 存储不可写、容量耗尽 | 写入失败及资源处理 | 文件系统或数据库实际错误,停止条件有效 |
| 逻辑误删或损坏备份副本 | 恢复点选择和归档校验 | 错误是否被捕获、是否错误接入恢复目标 |
稳态应来自用户可感知的成功率、延迟、吞吐或业务不变量,而不只看进程存活。带着可检验的假设、小范围注入和停止条件开展实验,是 Principles of Chaos Engineering 的基本思路。
从一份归档恢复到新的 PostgreSQL 实例
实验环境和数据顺序
下载 备份恢复实验,解压进入 reliability-recovery。宿主需要 Java 17 或兼容的更高版本、Docker CLI 和访问本地 Docker daemon 的权限;数据库使用 PostgreSQL 18.6,不发布宿主端口。
Java 程序只是编排命令、计时和检查结果。数据修改、备份与恢复由真实 PostgreSQL 执行。实验管理员 postgres 用于创建数据库和导入归档;生产应用应另设权限较小的账号。
java -version
docker version
docker pull postgres:18.6
javac -d out RecoveryLab.java
java -cp out RecoveryLab run先拉取镜像,将网络下载从恢复计时中剔除。程序只操作自己创建的随机名称容器和专用卷,流程如下:
源实例
建表 → 确认 A=10、B=20 → pg_dump 完整归档 → 确认 C=30
↓ 停止源实例,确认无法执行探测
新目标实例
创建空数据库 → pg_restore → 确认只含 A/B
→ 新写入 D=40 → 同键重放 A → 核对共 3 条、金额和 70源实例故障后没有被覆盖。原归档保留在宿主临时目录,恢复使用另一个新建实例。这样恢复命令或验收查询出错时,还能回到原始数据和原始归档重新检查。
归档是某个快照,不会自动包含后续写入
核心备份命令为:
pg_dump -U postgres -d lab -Fc -f /tmp/good.dump这里的 /tmp/good.dump 位于源数据库容器内,程序随后用 docker cp 将它复制到本轮临时目录。-Fc 生成自定义格式归档,由 pg_restore 恢复。备份期间保持一致快照,并不意味着备份完成之前或之后确认的每一项新写入都进入同一快照,见 PostgreSQL SQL Dump。
实验在 B 提交后开始备份,备份期间不继续写入,等归档完成后才写 C。因此归档中明确包含 A/B,不含 C。这个安排用于把快照前后数据分开,不代表生产数据库必须暂停写入才能做逻辑备份。
新目标使用:
pg_restore -U postgres -d restored \
--single-transaction --exit-on-error --no-owner /tmp/good.dump--exit-on-error 要求出现错误就停止;--single-transaction 将本次恢复放入一个事务,发生错误则不留下这次导入的半成品。大数据库是否适合单事务、是否需要并行恢复,要按大小、锁和资源情况选择。--no-owner 省略恢复原对象所有者,正式环境则应预先规划角色和权限。选项详见 pg_restore。
归档可以包含可执行的 SQL 定义,只恢复可信来源。pg_dump 的单库备份也不负责完整保存所有集群级角色、表空间和应用外部配置,恢复方案需要另外准备这些对象。
如何解释计时输出
程序会输出类似下面的结果,毫秒数由本机实际运行决定:
sourceConfirmed=A,B,C restoredBeforeNewWrite=A,B missingConfirmedRecords=1
recoveryPointAgeUpperBoundMs=... rpoTargetMs=60000 withinTarget=true
actualRecoveryMs=... rtoTargetMs=60000 withinTarget=true newWrite=D duplicateA=ignored两个 60 秒是这项小实验设定的目标,不是生产系统建议值。
恢复点年龄采用同一 JVM 单调时钟:在开始写 B 前记下时间,故障注入前再计时。归档确定包含 B,B 的真实提交发生在第一次计时之后,因此得到的是保守的年龄上界。整个求差使用同一进程的时钟,缺失记录数另外报告。B 到备份完成之间没有其他写入,使数据比较具有明确含义。
实际恢复时间从发出停止源实例的命令之前开始,到新目标读取正确、新写入 D 成功、A 同键重放没有增加记录结束。它包含本机启动新目标和执行恢复的时间,未包含生产故障发现、人工决策、网络切流和真实数据规模。可把这些阶段加入同一时间线,但不能拿这里几秒的结果直接承诺生产 RTO。
若 withinTarget=false,程序会非零退出,同时仍保留数据供查阅。需要判断时间花在启动、读取归档、创建对象、导入数据还是业务检查,再调整恢复方案或目标。
恢复后同时检查正确数据和错误路径
新写入与重放必须能继续工作
恢复旧表成功之后,程序插入 D,并用 ON CONFLICT DO NOTHING 重放 A。最终标识应为 A/B/D,记录数为 3,金额和为 70。这个去重操作只服务于固定数据实验;带不同请求内容的通用业务幂等还需要指纹检查,见重试、幂等与补偿。
实际业务还应检查序列值、唯一约束、外键、权限、扩展、排序规则,以及与应用版本匹配的数据结构。恢复出的数据库能够读取,仍可能因为序列落后或权限缺失而无法创建下一条记录。
损坏归档不能走成功路径
程序保留 good.dump,只把其副本截断为 16 字节,再向独立的 rejected 数据库执行恢复。预期结果为:
corruptArchiveExit=1 rejectedDatabaseHasOperations=false检查包含两部分:pg_restore 非零退出,目标中不存在应恢复的业务表。脚本没有忽略退出码,也没有在失败后仅用 pg_isready 报告“数据库健康”。pg_isready 可以判断服务器是否接受连接,却无法代替业务数据校验。
最后程序重新启动保留的源实例,只为确认原来 A/B/C 仍在;它没有把流量切回源实例。恢复目标和原源出现不同的新写入后,不能简单让两边同时接单,否则又会产生新的分叉。
结束时保留哪些东西
程序无论成功还是异常退出,都会尝试停止本轮创建的容器,并打印归档目录、容器名和专用卷名。成功结束时,源和目标均停止,但数据卷仍在。
archiveDirectory=本轮临时目录
retainedContainer=r19-recovery-随机标识-source
retainedContainer=r19-recovery-随机标识-target按输出的明确名称,可以重新启动相应容器查询。确认不再需要实验数据后,再执行程序打印的 docker rm 和 docker volume rm 清理命令。删除卷会清空该轮数据库;原归档在宿主临时目录中独立保留。不要把示例随机名称改成已有业务卷,也不要批量删除全部停止容器。
如果程序中途失败,先保留原归档和原源,查看命令错误及 bad-restore.txt。容器名冲突、镜像缺失、Docker 权限错误属于实验启动问题;归档解析失败、权限恢复失败和业务记录不符,则属于不同的恢复阶段。
将实验扩展成可执行的恢复方案
副本、备份与时间点恢复各解决什么
| 手段 | 主要价值 | 仍要处理的情况 |
|---|---|---|
| 同步或异步副本 | 节点故障后的接替与读扩展 | 复制延迟、切换权限、旧主隔离、逻辑错误同步传播 |
| 周期性逻辑备份 | 独立归档、对象级恢复和迁移 | 快照后写入缺失、恢复耗时、角色与外部配置 |
| 基础备份加连续 WAL 归档 | 在保留范围内选择恢复时间点 | 归档连续性、恢复目标、版本兼容和实际回放 |
| 跨故障域备份 | 降低同一机房、账号或介质失效的影响 | 独立访问权限、密钥可用性、带宽与恢复演练 |
PostgreSQL 时间点恢复需要可用的基础备份和后续连续日志链,不能只把 pg_dump 文件改名就当成 PITR。恢复停止点和后续接管步骤见 Continuous Archiving and Point-in-Time Recovery。
备份加密密钥、恢复账号和操作文档也可能与受损系统一起不可用。演练应使用真正可取得的归档、身份和网络入口,并把获取这些条件的时间计入恢复流程。
应用回滚要兼容已经发生的写入
回滚应用版本通常只改变执行代码。新版本已经写入的新字段、枚举值和事件格式仍然存在,旧版本必须能读取或安全忽略它们。删除列、改变字段含义和不可逆数据迁移,需要事先准备兼容期或向前修复方案。
数据库回退到旧恢复点会丢弃之后的变化,不宜作为每次应用发布失败的默认动作。对已确认订单进行补录时,保留原业务标识,按幂等与补偿规则核对;不明确最终状态的外部交易先查询渠道结果。
迁移可按“扩展结构 → 兼容读写 → 迁移数据 → 收缩旧结构”分阶段实施。每一步都要知道当前旧版本还能否运行,以及失败后选择退回代码还是继续修复数据。
从恢复服务继续到恢复正常负载
重新接流会触发连接重建、缓存预热和积压消费。先限制入口,恢复必要依赖,逐步增加处理量,观察成功吞吐、尾延迟和积压年龄。接口恢复可用之后,历史消息处理和资金对账可能仍要继续。
演练报告应保留实际注入的对象、原确认集合、归档身份、恢复步骤、时间分段、缺失数据及剩余任务。下次变更恢复脚本、数据库版本或业务结构时,可以重放相同实验,检查恢复条件是否仍成立。
权威资料与规范地址
- AWS Well-Architected:RTO、RPO 与灾难恢复目标。https://docs.aws.amazon.com/wellarchitected/latest/reliability-pillar/disaster-recovery-dr-objectives.html
- Principles of Chaos Engineering:稳态、假设、故障变量与影响控制。https://principlesofchaos.org/
- PostgreSQL 18 SQL Dump:一致快照、归档格式与备份对象。https://www.postgresql.org/docs/18/backup-dump.html
- PostgreSQL 18 pg_restore:错误处理、事务恢复和权限选项。https://www.postgresql.org/docs/18/app-pgrestore.html
- PostgreSQL 18 PITR:基础备份与连续 WAL 归档。https://www.postgresql.org/docs/18/continuous-archiving.html
