failure - reliability
04. 失败模型与可靠性
0. 本章先解决什么问题
初学写程序时,我们通常只写正常路径:
输入正确
-> 程序执行
-> 输出正确
但真实系统从来不只运行在正常路径里。它会遇到:
- 输入为空、超长、格式错误。
- 内存不够、磁盘满、文件不存在。
- 网络丢包、断连、响应迟到。
- 多个任务同时修改同一份状态。
- 写文件写到一半突然断电。
- 系统时钟不准。
- 依赖服务部分成功、部分失败。
可靠性不是“代码写得认真一点”就自然得到的。可靠性来自对失败的提前建模。
你能想到哪些失败,系统就能设计出对应策略。
你没有想到的失败,系统通常会在真实环境里替你暴露出来。
这张图给出可靠性思维的闭环:先建模失败,再设计检测证据、隔离损失、恢复状态、验证复盘,最后把新认识写回设计。可靠系统不是不会失败,而是失败时状态可解释、损失可限制、路径可恢复。
这张图怎么读
可靠性闭环不是“出了问题再补救”的流程,而是设计时就要问:
失败会在哪里发生?
我怎么知道它发生了?
失败会影响多大范围?
系统能否自动恢复?
恢复后状态是否仍然满足不变量?
这五个问题对应五类设计:
| 问题 | 设计手段 |
|---|---|
| 失败在哪里 | 失败模型、边界条件、依赖清单 |
| 怎么知道 | 错误码、日志、指标、校验、心跳、超时 |
| 影响多大 | 隔离、限流、熔断、降级、权限边界 |
| 怎么恢复 | 重试、补偿、回滚、重建、崩溃恢复 |
| 恢复后对不对 | 不变量检查、校验和、版本号、审计记录 |
可靠性要从“会不会失败”改成“失败后系统处在哪个可解释状态”。这也是后面学 OS、网络、数据库、分布式系统时最核心的共同语言。
1. 什么是失败模型
失败模型就是回答:
系统可能以哪些方式失败?
失败时状态会停在哪里?
外部能看到什么现象?
系统如何恢复或限制损失?
比如“读文件失败”太模糊。更准确的失败模型会拆成:
| 失败 | 可能原因 | 外部现象 |
|---|---|---|
| 文件不存在 | 路径错、文件被删 | open 失败 |
| 权限不足 | 用户无权限 | permission denied |
| 读到一半失败 | 设备错误、网络文件系统断开 | 部分数据已读 |
| 内容损坏 | 写入不完整、存储损坏 | 校验失败或解析失败 |
| 编码不对 | 解释规则不一致 | 乱码 |
拆得越具体,处理策略越清楚。
1.1 失败模型必须包含失败域
失败域是指一次失败会影响多大范围。
| 失败域 | 例子 | 设计重点 |
|---|---|---|
| 单个请求 | 一个输入非法、一次超时 | 返回明确错误,不污染共享状态 |
| 单个任务 | 后台任务失败 | 可重试、可恢复、状态可查询 |
| 单个进程 | 进程崩溃、内存耗尽 | supervisor 重启、持久化进度 |
| 单台机器 | 磁盘坏、主机宕机 | 副本、迁移、故障转移 |
| 单个依赖 | 数据库、缓存、DNS、外部 API 异常 | 熔断、降级、隔离 |
| 整个区域 | 机房/网络区域不可用 | 多区域、灾备、流量切换 |
初学时常把失败当成“这一行代码抛异常”。工程上更重要的是:失败会不会扩散。
例如一个下游服务变慢,如果没有隔离,它会:
占住调用线程
-> 请求队列堆积
-> 连接池耗尽
-> 其他正常功能也变慢
-> 重试流量放大
这就是失败从一个依赖扩散成全站故障。可靠性设计的第一目标,通常不是让失败消失,而是让失败不要扩散。
2. 常见失败类型
| 类型 | 例子 | 核心问题 |
|---|---|---|
| 边界失败 | 空输入、超大输入、越界下标 | 输入超出假设 |
| 资源失败 | 内存不足、磁盘满、端口耗尽 | 资源有限 |
| 时间失败 | 超时、延迟抖动、时钟回拨 | 时间不可靠 |
| 并发失败 | 竞态、死锁、可见性问题 | 多条时间线交错 |
| 持久化失败 | 写一半崩溃、缓存未落盘 | 状态没有完整保存 |
| 网络失败 | 丢包、乱序、断连、重复包 | 通信不可靠 |
| 协议失败 | 格式错误、版本不兼容 | 双方约定不一致 |
| 依赖失败 | 下游服务挂了、返回错误 | 外部系统不可控 |
这些失败类型会贯穿四大件:
- 组成原理:位宽、浮点、硬件错误。
- 数据结构:边界、退化、并发修改。
- 操作系统:资源耗尽、崩溃、权限、IO 错误。
- 计算机网络:丢包、乱序、延迟、连接状态。
2.1 失败的可见性也不同
有些失败很明显,有些失败很隐蔽。
| 可见性 | 例子 | 危险点 |
|---|---|---|
| 显式失败 | 返回错误码、抛异常 | 容易处理,但别吞掉 |
| 静默失败 | 写入丢了但没有报错 | 最危险,用户以为成功 |
| 延迟失败 | 现在成功,稍后才暴露 | 异步任务、写回缓存 |
| 部分成功 | 一部分状态改了,另一部分没改 | 最容易造成不一致 |
| 错误成功 | 返回成功但语义错误 | 解析错、版本不兼容、脏读 |
很多可靠性问题不是“系统报错”,而是“系统没有在该报错时报告错误”。比如写入缓存成功但持久化失败,如果接口仍然返回成功,就会制造错误成功。
3. 正常路径和失败路径
正常路径通常很短:
打开文件 -> 写入数据 -> 关闭文件
失败路径要问很多问题:
打开失败怎么办?
写入一半失败怎么办?
关闭失败怎么办?
写入成功但没有落盘怎么办?
程序崩溃时文件处于什么状态?
下次启动怎么判断文件是否完整?
真正可靠的程序,不是从不失败,而是失败时仍能保持可解释、可恢复、可限制损失。
3.1 失败路径要覆盖每个副作用
一个操作里最危险的是副作用:
写文件
写数据库
发消息
扣库存
扣钱
发邮件
调用外部服务
释放资源
因为副作用一旦发生,可能不能简单撤销。分析失败路径时,不要只看代码阶段,要看副作用边界:
| 阶段 | 副作用 | 失败后问题 |
|---|---|---|
| 写入前校验 | 无或少 | 通常可直接失败 |
| 写入本地状态 | 内存/文件/数据库变化 | 是否需要回滚或恢复 |
| 通知外部系统 | 外部世界已看到事件 | 重试是否重复通知 |
| 返回调用者 | 调用者形成认知 | 调用者不知道真实状态怎么办 |
例如“创建订单后发送消息”:
订单写入成功
消息发送失败
此时订单已经存在,但下游不知道。你不能只返回失败然后让调用方重试创建订单,否则可能重复创建。更稳的设计是把“要发送的消息”也记录成可靠状态,后续异步补发。
4. 部分失败
单机里很多操作看起来像“成功或失败”二选一。
但网络和分布式系统里,经常出现部分失败。
例子:
A 发送请求给 B
B 收到并处理成功
B 返回响应
响应在路上丢了
A 超时,以为失败
B 实际已经成功
这会导致一个关键问题:
A 要不要重试?
如果 A 重试,B 可能重复处理同一个操作。于是系统需要更多设计:
| 设计 | 解决什么 |
|---|---|
| 超时 | 不无限等待 |
| 重试 | 应对短暂失败 |
| 幂等 | 重复执行不造成重复效果 |
| 确认 | 明确对方收到或完成 |
| 请求 ID | 区分重复请求 |
| 日志 | 记录发生过什么,便于恢复 |
| 事务 | 把一组操作变成可控的整体 |
部分失败是网络系统最难的地方之一。
4.1 重试风暴:可靠性手段也可能制造故障
重试本来是为了恢复短暂失败,但没有边界的重试会放大故障。
下游变慢
-> 上游超时
-> 上游重试
-> 下游收到更多请求
-> 下游更慢
-> 更多超时和重试
避免重试风暴,要给重试加约束:
| 约束 | 作用 |
|---|---|
| 最大重试次数 | 防止无限放大 |
| 指数退避 | 失败越多,等待越久 |
| jitter 随机抖动 | 避免所有客户端同时重试 |
| deadline | 整个操作有总时间上限 |
| 幂等键 | 防止重复副作用 |
| 熔断 | 下游明显异常时快速失败 |
| 限流 | 控制进入系统的请求量 |
可靠性设计不是“失败就重试”,而是“在不会扩大损失的前提下重试”。
手推:一次小故障如何被重试放大成系统故障
假设正常情况下:
下层每秒最多稳定处理 1000 个请求
上层正常发送 900 个请求/秒
这时还有余量:
余量 = 1000 - 900 = 100 个请求/秒
如果下层短暂变慢,容量降到 700 个请求/秒。上层如果设置“失败立刻重试 2 次”,那么原来的 900 个请求会快速变成更多尝试:
第一次尝试: 900
第一次重试: 部分失败请求再次进入
第二次重试: 仍失败的请求继续进入
实际进入流量可能超过 1000,甚至继续上升
下层本来只是承受不了 900,现在却被更多重复尝试冲击。于是:
容量下降
-> 超时增加
-> 重试增加
-> 请求量增加
-> 排队更长
-> 更多超时
这就是重试风暴的正反馈。缓解它要打断正反馈:
| 手段 | 打断哪一环 |
|---|---|
| deadline | 请求过期后不继续浪费资源 |
| backoff | 降低重试频率 |
| jitter | 避免同一时刻集中重试 |
| retry budget | 限制重试占总流量比例 |
| circuit breaker | 明显异常时快速失败 |
| idempotency key | 让重复请求不重复产生副作用 |
重试是药,不是粮食。剂量和边界决定它是在恢复系统,还是压垮系统。
5. 幂等
幂等是指同一个操作执行一次和执行多次,最终效果一样。
例子:
| 操作 | 是否天然幂等 | 原因 |
|---|---|---|
| 设置 x=5 | 是 | 重复设置仍是 5 |
| 删除某个固定 ID 的资源 | 通常可以设计成幂等 | 删过后再次删除仍不存在 |
| x = x + 1 | 否 | 每执行一次都会继续增加 |
| 创建一条没有去重 ID 的记录 | 否 | 重试可能创建多条 |
为什么重要?
因为失败后常常需要重试。如果操作不是幂等的,重试可能制造新错误。
这不是某个业务系统独有的问题。文件写入、网络请求、消息处理、任务执行都可能遇到。
5.1 幂等不是“不执行第二次”,而是“第二次不改变最终效果”
很多人把幂等理解成“发现重复就拒绝”。这只是实现方式之一。更一般地说:
同一个业务意图重复到达,最终状态只能推进一次。
常见实现:
| 方法 | 思路 |
|---|---|
| 唯一请求 ID | 同一请求 ID 只处理一次 |
| 业务唯一键 | 同一订单号、文件 ID、任务 ID 只创建一次 |
| 状态机终态保护 | SUCCESS 后重复 success 不再改变状态 |
| 去重表 | 记录已处理消息 ID |
| compare-and-set | 只允许从期望旧状态推进 |
例如扣库存更安全的状态推进是:
如果 order_id 未扣过库存:
扣库存
记录 order_id 已扣
否则:
返回之前的扣减结果
这样重试不会重复扣减。
6. 并发失败
并发失败通常不稳定复现。
两个执行流都做:
读 x
计算 x + 1
写回 x
可能交错成:
T1 读 x=0
T2 读 x=0
T1 写 x=1
T2 写 x=1
结果应该是 2,却变成 1。
并发 bug 难在:
| 难点 | 解释 |
|---|---|
| 依赖时序 | 只有特定交错才触发 |
| 复现概率低 | 本地跑十次都可能没事 |
| 日志会改变时序 | 加日志可能让 bug 消失 |
| 压力下更明显 | 并发越高,交错越多 |
| 肉眼难检查 | 源码顺序不等于真实执行顺序 |
所以并发可靠性要依赖同步规则、不可变数据、队列、事务、状态隔离等设计,而不是靠运气。
6.1 并发失败也可以建模
并发不是“玄学”,它的失败模型通常围绕三件事:
| 问题 | 含义 | 例子 |
|---|---|---|
| 原子性 | 一组操作是否会被打断 | x++ 被拆成读、加、写 |
| 可见性 | 一个线程的修改别人何时能看到 | 写了 flag,另一个线程一直看不到 |
| 顺序性 | 操作顺序是否可能被重排或交错 | 先初始化对象再发布引用的顺序被破坏 |
用状态视角看,锁、队列、事务、不可变对象都是在限制状态交错。
例如把多线程直接改同一个计数器,改成单线程消费队列:
多个生产者 -> 队列 -> 单个消费者更新状态
这牺牲了一部分并发度,但换来更简单的状态转移和更强的可解释性。
7. 崩溃一致性
持久化系统必须考虑:
如果程序或机器在任意一步崩溃,会留下什么状态?
例子:更新一个文件。
天真做法:
打开旧文件
覆盖写入新内容
关闭
如果写到一半崩溃,文件可能变成半旧半新。
更可靠的思路:
写入临时文件
确保临时文件完整
原子替换旧文件
常见手段:
| 手段 | 作用 |
|---|---|
| 日志 | 先记录意图或操作,再执行修改 |
| 原子替换 | 新状态完整后再切换引用 |
| 校验和 | 发现数据损坏 |
| 版本号 | 区分新旧状态 |
| fsync | 请求系统把数据刷到稳定存储 |
| 恢复流程 | 下次启动时修复未完成状态 |
可靠持久化的核心不是“永不崩溃”,而是“崩溃后知道怎么恢复”。
7.1 先写日志,再改状态
很多可靠系统都用一个思想:
先把“我要做什么”记录到稳定位置
再执行真正修改
崩溃后根据日志判断继续、回滚或重做
这就是 write-ahead 的直觉。它在文件系统、数据库、消息系统里都很常见。
一个简化例子:
- 写日志: 准备把 A 改成 B
- 确保日志落盘
- 修改 A -> B
- 写日志: 修改完成
如果在 2 之后崩溃,恢复程序看到“准备修改但未完成”,就知道要检查并修复状态。如果完全没有日志,系统只能猜现在是旧状态、新状态,还是半完成状态。
崩溃一致性要回答:
| 问题 | 为什么重要 |
|---|---|
| 哪些步骤已经持久化 | 决定恢复从哪里开始 |
| 哪些步骤可以重做 | 重做必须幂等 |
| 哪些步骤必须回滚 | 防止半完成状态暴露 |
| 如何判断数据完整 | 校验和、长度、版本、commit 标记 |
| 旧状态何时被替换 | 原子 rename、版本切换、事务提交 |
边界条件:补偿不是时间倒流
有些失败无法通过“回滚”恢复到从未发生过。原因是副作用已经越过了系统边界:
文件已经被外部程序读取
消息已经被对方消费
请求已经让远端状态改变
邮件已经发出
设备已经执行动作
这时你能做的通常是补偿,而不是真正回滚。
| 操作 | 回滚直觉 | 真实边界 |
|---|---|---|
| 写本地临时状态 | 可以删除临时状态 | 还没暴露给外部,接近可回滚 |
| 更新已发布记录 | 可以写反向更新 | 读者可能已经看到旧结果 |
| 发送消息 | 可以发撤销消息 | 对方可能已经处理原消息 |
| 调用远端动作 | 可以调用取消接口 | 远端可能已经完成且不可取消 |
| 通知人或外部设备 | 很难回滚 | 副作用已经进入现实世界 |
所以可靠性设计要在副作用前设置边界:
能先验证就先验证
能先预留就先预留
能后发布就后发布
能幂等就幂等
能补偿就记录补偿所需信息
补偿的目标是把系统推进到一个新的可接受状态,而不是假装历史没有发生。
8. 超时不是失败本身
超时的含义是:
我在规定时间内没有等到结果。
它不等于:
对方一定没有执行。
可能情况:
| 情况 | 说明 |
|---|---|
| 对方没收到 | 请求在路上丢了 |
| 对方收到了但没处理 | 排队或资源不足 |
| 对方处理了但响应丢了 | 典型部分失败 |
| 对方处理很慢 | 响应迟到 |
| 本机读取慢 | 结果到了但没及时处理 |
所以超时后是否重试、如何重试、是否需要幂等,都要设计。
边界条件:超时后系统进入“未知态”
超时最麻烦的地方在于:调用方不知道远端状态。
调用方看到: timeout
真实世界可能:
请求没到
请求到了但没开始
请求执行了一半
请求已经成功,响应丢了
请求失败了,错误响应丢了
所以 timeout 后系统进入的不是“失败态”,而是“未知态”。
未知态下直接重试可能重复副作用;直接报失败可能让用户误以为没有执行;一直等待可能占住资源。
可靠设计通常会加一个查询或幂等层:
| 手段 | 目的 |
|---|---|
| request_id / idempotency_key | 让重复请求指向同一个业务意图 |
| status query | 超时后查询远端是否已完成 |
| outbox / operation log | 本地记录操作是否已经发起 |
| retry with deadline | 在总时限内谨慎重试 |
| compensation | 发现半完成后做补偿动作 |
只要你把 timeout 理解成“未知”,而不是简单“失败”,很多重试、幂等、状态查询设计就变得自然。
8.1 超时要分阶段
一个“请求超时”太粗。真实请求有多个阶段:
DNS timeout
connect timeout
TLS handshake timeout
request write timeout
server processing timeout
response read timeout
overall deadline
不同阶段超时,修复方向不同:
| 超时阶段 | 可能原因 |
|---|---|
| DNS | 解析服务慢、缓存失效、网络配置 |
| connect | 目标不可达、端口没开、防火墙、SYN 丢失 |
| TLS | 证书、握手、CPU、协议兼容 |
| request write | 本机发送缓冲满、对端读取慢 |
| server processing | 服务端排队、数据库慢、锁等待 |
| response read | 响应太大、网络慢、客户端读取慢 |
可靠系统会给不同阶段设置合理超时,并记录阶段证据。只有一个总 timeout,排查时信息太少。
9. 可靠性和性能的权衡
更可靠通常要付出成本。
| 可靠性手段 | 成本 |
|---|---|
| 重试 | 增加流量和延迟 |
| 日志 | 增加写入量和存储 |
| 副本 | 增加存储和同步成本 |
| 校验 | 增加计算 |
| 锁 | 降低并发度 |
| fsync | 增加等待 |
| 强一致 | 降低可用性或吞吐 |
系统设计不是“可靠性越高越好”这么简单。你要明确:
哪些失败必须防?
哪些失败可以降级?
哪些失败可以让用户重试?
哪些失败只需要记录证据?
9.1 可靠性策略要按风险分级
不是所有操作都需要同样强的可靠性。
| 操作类型 | 可接受策略 |
|---|---|
| 临时预览、搜索建议 | 失败可降级,缓存旧结果也许可以 |
| 普通读取 | 可重试、可走副本、可返回明确错误 |
| 写入用户数据 | 要防止丢失、重复、半完成 |
| 金额/库存/权限 | 要强约束、审计、幂等、事务或补偿 |
| 日志/统计 | 可批量、可延迟,但要知道丢失边界 |
设计可靠性时先问业务和系统语义:
丢一次会怎样?
重复一次会怎样?
晚到会怎样?
乱序会怎样?
读到旧值会怎样?
这些答案决定你需要的是缓存、重试、事务、队列、审计,还是简单报错。
10. 联系实际:如何给一个操作做失败分析
假设有一个操作:
读取输入 -> 计算结果 -> 写入文件 -> 返回成功
你可以按表分析:
| 阶段 | 可能失败 | 需要策略 |
|---|---|---|
| 读取输入 | 空、格式错、过大 | 校验、限制大小、错误信息 |
| 计算结果 | 溢出、除零、超时 | 边界检查、超时控制 |
| 写入文件 | 权限不足、磁盘满、写一半崩溃 | 临时文件、校验、恢复 |
| 返回成功 | 返回前崩溃 | 启动后检查持久化状态 |
这就是失败模型的实际用法:把正常路径拆成阶段,然后对每个阶段问“会怎么失败”。
练习卡:给一个操作写失败模型
选一个看似简单的操作,例如:
读取配置 -> 连接服务 -> 写入结果 -> 返回成功
不要只写 happy path。按这张表填:
| 阶段 | 可能失败 | 外部现象 | 策略 |
|---|---|---|---|
| 读取配置 | 文件不存在、格式错、权限不足 | 启动失败或使用旧配置 | 校验、默认值、明确错误 |
| 连接服务 | DNS 慢、connect timeout、TLS 失败 | 请求卡住或失败 | 分阶段超时、重试、证据日志 |
| 写入结果 | 部分写入、磁盘满、崩溃 | 状态不完整 | 临时文件、原子替换、校验 |
| 返回成功 | 响应丢失、调用方超时 | 对方不知道是否成功 | 幂等 key、查询状态、去重 |
再问四个关键问题:
- 超时后,对方有没有可能已经执行成功?
- 重试会不会造成重复副作用?
- 崩溃后,下次启动怎么判断状态完整?
- 哪些失败需要恢复,哪些只需要降级或记录证据?
如果这些问题没有答案,系统只是“正常时能跑”,还不能算可靠。
问题:保证“写数据库 + 发消息”最终可靠
学完本章,可以尝试设计一个非常经典的问题:
创建一条记录后,要通知另一个系统。
要求:记录不能丢,消息不能永久丢;重试不能导致重复副作用。
天真做法:
写数据库
发送消息
返回成功
问题在于:
| 崩溃点 | 结果 |
|---|---|
| 数据库写入前崩溃 | 什么都没发生,可以失败 |
| 数据库写入后、消息发送前崩溃 | 记录存在,但通知丢了 |
| 消息发送后、返回前崩溃 | 调用者可能重试,导致重复 |
| 消息发送成功但响应丢失 | 本地以为失败,可能重复发送 |
一个更可靠的 outbox 思路:
同一个事务里:
写业务记录
写 outbox 消息记录,状态=PENDING
后台投递器:
扫描 PENDING 消息
发送到下游
成功后标记 SENT
失败则按策略重试
下游:
按 message_id 去重处理
这里的核心不是某个框架,而是状态设计:
| 状态 | 作用 |
|---|---|
| 业务记录 | 真实业务事实 |
| outbox PENDING | 有消息需要发送 |
| outbox SENT | 消息已投递完成 |
| message_id | 下游幂等去重 |
这套模型能解决“数据库和消息系统不是一个原子操作”的部分失败问题。
11. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 为什么真实系统不能只写正常路径?
- 为什么网络超时后,对方可能其实已经处理成功?
- 为什么重试必须考虑幂等?
- 为什么并发 bug 可能本地不出现,压力下才出现?
- 为什么文件写入要考虑“写一半崩溃”的状态?
- 为什么可靠性、性能、复杂度之间一定有 trade-off?
- 如何给一个操作列出失败点,并设计对应策略。
如果你以后写或读一个系统,能主动问“这个操作在哪些地方可能失败,失败后状态是什么,下一次怎么恢复”,你就已经开始具备可靠性思维。