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 的直觉。它在文件系统、数据库、消息系统里都很常见。

一个简化例子:

  1. 写日志: 准备把 A 改成 B
  2. 确保日志落盘
  3. 修改 A -> B
  4. 写日志: 修改完成

如果在 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、查询状态、去重

再问四个关键问题:

  1. 超时后,对方有没有可能已经执行成功?
  2. 重试会不会造成重复副作用?
  3. 崩溃后,下次启动怎么判断状态完整?
  4. 哪些失败需要恢复,哪些只需要降级或记录证据?

如果这些问题没有答案,系统只是“正常时能跑”,还不能算可靠。

问题:保证“写数据库 + 发消息”最终可靠

学完本章,可以尝试设计一个非常经典的问题:

创建一条记录后,要通知另一个系统。
要求:记录不能丢,消息不能永久丢;重试不能导致重复副作用。

天真做法:

写数据库
发送消息
返回成功

问题在于:

崩溃点结果
数据库写入前崩溃什么都没发生,可以失败
数据库写入后、消息发送前崩溃记录存在,但通知丢了
消息发送后、返回前崩溃调用者可能重试,导致重复
消息发送成功但响应丢失本地以为失败,可能重复发送

一个更可靠的 outbox 思路:

同一个事务里:
写业务记录
写 outbox 消息记录,状态=PENDING

后台投递器:
扫描 PENDING 消息
发送到下游
成功后标记 SENT
失败则按策略重试

下游:
按 message_id 去重处理

这里的核心不是某个框架,而是状态设计:

状态作用
业务记录真实业务事实
outbox PENDING有消息需要发送
outbox SENT消息已投递完成
message_id下游幂等去重

这套模型能解决“数据库和消息系统不是一个原子操作”的部分失败问题。

11. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. 为什么真实系统不能只写正常路径?
  2. 为什么网络超时后,对方可能其实已经处理成功?
  3. 为什么重试必须考虑幂等?
  4. 为什么并发 bug 可能本地不出现,压力下才出现?
  5. 为什么文件写入要考虑“写一半崩溃”的状态?
  6. 为什么可靠性、性能、复杂度之间一定有 trade-off?
  7. 如何给一个操作列出失败点,并设计对应策略。

如果你以后写或读一个系统,能主动问“这个操作在哪些地方可能失败,失败后状态是什么,下一次怎么恢复”,你就已经开始具备可靠性思维。

延伸阅读