11 失败检测与容错:超时、心跳、重试、幂等

开篇提问

先给你一个几乎所有分布式系统都会遇到的场景:

服务 A 调用服务 B,B 迟迟不返回。A 等了 3 秒,超时了。现在问你一个看似简单、实则极难的问题:B 到底怎么了?

  • B 挂了?
  • B 还活着,只是处理得慢(负载高、GC 停顿、磁盘 IO 慢)?
  • B 处理完了,但响应在网络上丢了?
  • B 根本没收到请求(请求在半路丢了)?

关键来了:从 A 的视角,这四种情况完全无法区分。 它只知道”到时间了还没收到响应”。

请认真想一下这个事实的后果:如果你连”对方是不是死了”都判断不了,那你怎么可能正确地做容错?

这个”无法区分”不是技术不够好,而是分布式系统的一个基本事实。理解它,你就理解了为什么超时、重试、幂等这些机制那么难做对。


子主题一:失败检测的三种”不知道”

在分布式系统里,一个节点判断另一个节点是否存活,有三种典型机制:

机制一:超时(Timeout)。 发请求,超过 T 时间没响应就认为失败。 机制二:心跳(Heartbeat)。 节点定期发”我还活着”的消息,收不到心跳超过 N 次就认为它死了。 机制三:租约(Lease)。 节点定期”续约”,租约过期就认为它不再持有某项权利(比如主节点身份)。

三者本质相同:都用”时间”来推断”状态”。而这个推断有个根本性的哲学问题:

超时只能证明”我没收到响应”,不能证明”对方失败了”。

这就是分布式系统里那个著名的区分:崩溃(crash)vs 慢(slow)。 从观察者角度,这两者无法区分。

这个不可区分性带来一个残酷的推论:你永远无法 100% 准确地检测失败。 任何失败检测器,都只能在两个错误之间权衡:

  • 误报(False Positive):把活着的节点判成死了。代价:不必要的 failover、可能的脑裂。
  • 漏报(False Negative):把死了的节点判成活着。代价:请求继续发往死节点,服务不可用。

你不可能同时消除两者——这被称为失败检测的”不可能结果”(在特定异步网络模型下)。所以真实系统的做法都是:接受误报,因为误报的代价通常可控(多一次 failover),而漏报会导致服务持续不可用。


子主题二:超时时间怎么定——一个没有标准答案的难题

既然要用超时,那超时设多久?这个问题比你想象的难得多。

设太短的后果:把”慢”误判成”死”,产生大量误报。更要命的是——重试会放大流量。一个本来只是慢一点的服务,因为超时重试,收到的请求翻倍,变得更慢,进而触发更多重试……这就是”重试风暴”,能把一个健康的服务直接打死。

设太长的后果:故障迟迟不被发现,用户一直等,资源(连接、线程)一直被占用,故障影响面扩大。

怎么定?真实工程里有几种做法:

做法一:固定超时 + 人工调参。 简单,但很难调准——因为延迟是随负载变化的。

做法二:自适应超时。 根据历史响应时间动态计算(比如取 P99 + 余量)。这是工业级 RPC 框架的标准做法。

做法三:超时预算(Timeout Budget)。 更聪明的做法——给整条调用链分配一个总超时预算。比如用户请求总预算 1 秒,调用链上有 5 个服务,每层的超时 = 剩余预算 / 剩余层数。越往下层,超时越短。 这避免了”每个服务都设 1 秒超时,导致总超时 5 秒”的经典错误。

做法四:超时 + 熔断组合。 连续失败多次后熔断(不再调用,快速失败),给下游恢复的时间。(详见第 13 章)

还有一条重要经验:超时必须配上下文传播(context propagation)。上游超时取消了,下游应该立刻停止工作,而不是继续白白消耗资源。这就是为什么 Go 的 context 这么重要——它把”取消”变成了一个可以沿调用链传播的显式信号。


子主题三:重试——一把双刃剑

超时之后最常见的动作是重试。但重试是分布式系统里最容易用错、也最容易造成灾难的机制。

重试的危险在于:你不知道上一个请求到底执行了没有。

回到开篇那四种情况:如果请求其实已经到达 B 并且执行成功了(只是响应丢了),你重试一次,就等于执行了两次。如果这不是幂等操作(比如扣款),就出大事了。

所以要理解重试的适用边界:只有在”操作幂等”或”操作可安全重复”时,重试才是安全的。

重试的正确打开方式:

1. 只对”可安全重试”的错误重试。

  • 可重试:连接失败、超时、503(服务暂时不可用)、限流拒绝
  • 不可重试:400(参数错误)、401(认证失败)、404——重试一万次也是同样的错

2. 用指数退避(Exponential Backoff)+ 抖动(Jitter)。

  • 指数退避:失败后等 1s、2s、4s、8s……而不是立刻重试
  • 为什么必须加抖动? 因为如果 1 万个客户端同时失败、同时退避 1 秒、同时重试——它们会在同一时刻再次冲击服务器,形成”重试同步风暴”。加随机的抖动,把这些重试在时间上打散,这是必做的一步,很多人漏掉。

3. 设置重试预算(Retry Budget)。

  • 限制”重试请求占总请求的比例”(比如不超过 10%)
  • 这是防止重试风暴最有效的手段:当系统已经很糟糕时,重试只会让它更糟,所以要在系统层面限制重试总量

4. 重试要配熔断。

  • 连续失败 N 次就熔断,停止调用一段时间
  • 给下游喘息的机会

子主题四:幂等——让重试变得安全

既然重试有风险,那怎么让重试变安全?答案是幂等(Idempotence)。

幂等的定义:一个操作执行一次和执行多次,对系统状态的影响相同。

举例:

  • 非幂等:balance -= 100(执行两次扣 200)
  • 幂等:balance = 500(执行多少次结果都是 500,这是”赋值”而非”累加”)
  • 幂等:DELETE FROM t WHERE id=1(删一次和删多次结果相同)

实现幂等的几种经典手法:

手法一:唯一请求 ID + 去重表。 客户端给每个请求生成唯一 ID,服务端记录”已处理过的 ID”,重复请求直接返回上次的结果。这是最通用的方案。

手法二:状态机检查。 订单状态流转:待支付 → 已支付 → 已发货。“支付”操作只在”待支付”状态下有效,重复调用时状态已变,直接返回成功。这天然幂等。

手法三:乐观锁 / 版本号。 更新时带上预期版本号:UPDATE t SET val=x, version=version+1 WHERE id=1 AND version=5。重复请求时版本号已变,更新影响 0 行,直接返回成功。

手法四:Token 机制。 先申请一个一次性 token,操作时带上,服务端校验并作废 token。重复请求时 token 已失效。

幂等的意义远超”让重试安全”:它是整个分布式系统可靠性的基石。因为在分布式世界里,“消息可能重复”是常态(at-least-once 传递),幂等是应对这个常态的唯一正确姿势。


子主题五:容错的设计范式

把前面这些机制串起来,形成几个完整的容错范式:

范式一:快速失败(Fail Fast)。 与其等超时,不如在明知会失败时立刻返回错误。关键是不要浪费资源在注定失败的事情上。

范式二:优雅降级(Graceful Degradation)。 核心功能不可用,也要保证基本可用。推荐服务挂了就返回热门榜单;个性化推荐挂了就返回默认推荐。(详见第 13 章)

范式三:冗余 + 对冲请求(Hedged Requests)。 同一个请求发给两个副本,取先返回的那个,取消另一个。用一点额外资源换取尾延迟的大幅下降。 Google 的经典优化。

范式四:舱壁隔离(Bulkhead)。 把资源(线程池、连接池)按调用方/功能隔离。A 服务慢了,只耗尽 A 的线程池,不影响 B。(详见第 13 章)

范式五:故障演练(Chaos Engineering)。 主动注入故障,验证容错机制真的有效。Netflix 的 Chaos Monkey 是代表。核心洞察:你的容错机制,如果没有被验证过,就假定它是坏的。 很多系统的 failover 代码从未真正执行过,真出事时才发现是坏的。


子主题六:失败检测器的理论基础——为什么”完美检测”在数学上不可能

前面我们说”崩溃和慢无法区分”,听起来像一句经验之谈。现在把它变成一个严肃的理论问题。

问你一个问题:如果让你设计一个”绝对准确”的失败检测器,它必须满足哪两条性质?

答案是两条,一条管”不漏”,一条管”不误”:

  • 完整性(Completeness):每一个真正崩溃的进程,最终都会被所有存活的进程怀疑。也就是不漏报。
  • 准确性(Accuracy):任何存活的进程都不会被怀疑。也就是不误报。

两条同时满足,就是所谓的完美失败检测器(Perfect Failure Detector,记作 P)。

为什么在异步系统里做不到?

给你一个直觉证明——它短得惊人,却几乎是整个分布式系统理论的地基:

假设检测器 D 承诺”绝不误报”。现在节点 q 迟迟收不到 p 的心跳。两种可能:

  • (a) p 真的崩了;
  • (b) p 活着,只是消息延迟了很久。

在异步系统模型里,消息延迟没有上界——任意大的延迟都是”合法”的。所以 q 永远面对着一个”再等一会儿,也许下一个瞬间就到了”的局面。那么:

  • 如果 q 选择”再等一会儿”——在 (a) 的情况下,它永远等不到,于是违反了完整性(漏报)。
  • 如果 q 选择”判定 p 死了”——在 (b) 的情况下,它误杀了一个活着的节点,于是违反了准确性(误报)。

关键在于:只要延迟无上界,“再等一会儿”和”放弃”,在任何一个具体的等待时刻都无法被系统本身区分。 不存在一个由系统告诉你的”界限”。

这不就是生活里那个场景吗——你给朋友打电话没人接。响到第三声你就挂断,可能他正在洗澡(误报);你一直等到天荒地老,如果他真出事了,你也永远等不到那声”确认”(漏报)。“等等看”和”放弃”之间,没有正确答案,只有代价。

(顺便说一句:这个直觉和 FLP 不可能结果——Fischer、Lynch、Paterson 1985 年证明的”异步系统里即使只有一个进程崩溃,也不存在确定性的共识算法”——是同一枚硬币的两面。它们共享同一个根源:你无法区分”慢”和”死”。)

Chandra & Toueg 的破局:把”完美”降级为”最终完美”

1996 年,Chandra 和 Toueg 做了一件极其聪明的事:他们承认完美检测做不到,于是把失败检测器从”一个问题”变成了一个可插拔的抽象模块,然后给它分级——不同的系统按自己的需要取用不同等级的检测器。

分级是沿两个维度展开的:

完整性维度:

  • 强完整性(strong):每个崩溃的进程,最终会被所有存活进程怀疑
  • 弱完整性(weak):每个崩溃的进程,最终会被至少一个存活进程怀疑

准确性维度(有强弱、还有”最终”之分):

  • P(Perfect,完美):强完整 + 从不误报——就是上面那个做不到的
  • S(Strong,强):强完整 + 存在一个正确进程永不被怀疑
  • ◇P(Eventually Perfect,最终完美):强完整 + 允许暂时误报,但存在一个时刻之后,不再误报任何正确进程
  • ◇S(Eventually Strong):弱一点的组合
  • W / ◇W(Weak):只保证弱完整性

请盯着那个 ◇(diamond)符号看三秒——它是整个分布式系统工程的分水岭。

它的意思是”最终(eventually)“:我允许你犯错,但你的犯错必须是有窗口的、会结束的。

现实中所有能工作的失败检测器,都是”最终准确”的,不是”始终准确”的。

你的心跳检测、你的 ZooKeeper session、你的 etcd lease、你的 Kubernetes node heartbeat、你的数据库连接池探活——全都是 ◇P 的近似实现,没有一个是 P。

而 ◇P 的理论地位是:它是能用来解决共识(consensus)的最弱失败检测器类别之一。换句话说:只要你有一个”最终靠谱”的失败检测器,共识就变得可解。 这一句话,支撑起了后面所有的一致性系统(Raft、Paxos 的工程实现、ZooKeeper、etcd)。

那现实系统凭什么能工作?——部分同步

理论说不可能,现实却在跑。这个矛盾怎么解?

答案是 Dwork、Lynch、Stockmeyer 在 1988 年提出的**部分同步(partial synchrony)**模型。它有两个常见表述:

  • 表述一:系统”大部分时间”表现得像同步系统(消息延迟有上界 Δ),只是偶尔不稳定;
  • 表述二:存在一个全局稳定时间 GST(Global Stabilization Time),在 GST 之后延迟有界——但系统不知道 GST 什么时候到来。

现实网络正是这样:绝大多数时候延迟是几毫秒到几十毫秒,偶尔(网络抖动、GC、交换机收敛、故障切换)出现秒级的异常延迟。

于是我们得出了一个特别实在的结论:

你配的每一个超时值,本质上都是你对”当前处于稳定期、延迟不会超过 X”的一次下注。

  • 现实延迟超过你的下注 → 误报(把慢的判成死的)
  • 你把下注值调大 → 误报少了,但真故障时你要等更久才发现(漏报窗口变大)

所以”调超时”根本不是调参,而是在给一个数学上无法两全的权衡选一个落点。 下次有人问你”超时为什么设 3 秒”,你可以回答:“因为在我们这个网络环境里,3 秒是我愿意接受的’误报概率 vs 检测延迟’的落点。“

失败检测器的三个 QoS 指标

既然是权衡,就得能度量。Chen、Toueg、Aguilera 在 2002 年给出了三个量化指标:

  • 检测时间 T_D:进程崩溃后,多久被检测到(越小越好)
  • 平均错误间隔 T_MR:两次误报之间平均隔多久(越大越好,即误报越少)
  • 错误持续时间 T_M:一次误报持续多久才被纠正(越小越好)

这三者构成一个无法同时优化的三元组。 你想要 T_D 小(快速发现故障),就得把阈值压低,代价是 T_MR 变小(误报变频繁)。

任何”又快又准”的宣传都是在撒谎。 你能做的只有一件事:明确自己的业务对哪个更敏感,然后往那个方向压。看两个真实的对照:

  • 存储系统的主节点检测:希望 T_D 小(快速 failover,减少不可用时间),但极怕误报(误判一个健康的主节点会导致脑裂)。所以做法是”连续多次心跳丢失 + 多节点交叉验证 + fencing token”——用检测延迟换误报率的降低,再用 fencing 兜住剩余的误报。
  • 长跑批处理任务的心跳:可以容忍 T_D 大(晚几分钟发现没关系),但极怕误报——一个跑了六小时的任务被误判死亡然后重跑,代价是六小时算力和可能的数据重复。所以这类系统的超时阈值往往设得非常宽松。

反问一句:你的系统里,误报一次值多少钱?漏报一次值多少钱? 如果你答不上来,那你的超时大概率是抄来的默认值。


子主题七:心跳与租约深入——从”多久发一次”到 fencing token

心跳周期:一个两边挨打的参数

心跳只有两个参数,但它们互相乘:

  • η(eta):心跳间隔,多久发一次”我还活着”
  • δ(delta):超时阈值,多久没收到就怀疑对方死了

最坏检测时间 ≈ η + δ(运气最差的情况:刚错过一个心跳,要等下一整个周期,再等 δ)。

η 太小的代价: 消息量爆炸。N 个节点全互联心跳,每周期的消息量是 O(N²)——每个节点向其余 N−1 个节点各发一条。几百个节点、亚秒级心跳,光心跳就能吃掉可观的 CPU 和网络。所以大规模集群必须换拓扑:

  • Gossip 协议:每周期只和随机几个节点交换成员信息,靠传染扩散,把开销压到 O(N) 或 O(N log N)(Cassandra、Consul、Redis Cluster 都是这个路子)
  • 中心协调器:节点只向 ZooKeeper/etcd 维持 session 心跳,由协调器广播成员变化(开销 O(N),但协调器本身成了关键组件)

η 太大的代价: 故障发现慢。η=10s 意味着最坏要十几秒才开始怀疑,加上 failover 时间,不可用窗口可能是半分钟量级。

δ 怎么定?绝对不能设成 η。 因为那样一次丢包就误报了。实际做法是容忍连续 k 次丢失,δ ≈ k·η + 网络抖动余量。

注意这里有个乘法效应:总检测时间 ≈ η·(1+k)。η 和 k 都在放大你的检测延迟。你想做到”1 秒内发现故障”?那 η 和 k 都得压得很小,代价是带宽和误报率双双上升。

一个高段位的替代方案:φ 值累积检测器

布尔判断(活着/死了)有个致命缺陷:它丢掉了几乎所有信息。

你不知道”刚好卡在超时线上”和”早该死透了”的区别,也不知道”网络整体变慢了”和”这个节点真挂了”的区别。一个固定阈值,把连续的现实压成了一个比特。

φ-accrual 失败检测器(Hayashibara 等,2004)不输出布尔值,而是输出一个连续的怀疑程度 φ:

φ = −log₁₀( 从上次心跳到现在还没收到心跳的概率 )

它维护历史心跳到达间隔的统计分布,算出”如果这个节点还活着,我已经等了这么久还没收到心跳”的概率,再取负对数:

  • φ = 1 → 这个现象有 10% 的概率纯属巧合(不太可疑)
  • φ = 2 → 1%(有点可疑了)
  • φ = 3 → 0.1%(很可疑)

好处有两个,都很值钱:

  1. 阈值由应用自己决定,而且可以按不同场景设不同阈值(核心链路 φ>3 才怀疑,边缘任务 φ>1 就动手)。
  2. 自动适应负载:网络整体变慢时,分布变宽,φ 增长变慢,误报自动减少;网络好的时候 φ 涨得快,故障发现也快。

这比”固定超时”高一个段位——它把”是不是慢”的判断,从拍脑袋的常数,变成了统计推断。

心跳最大的坑:它证明的是 liveness,不是 readiness

这是很多人栽跟头的地方,请务必记住:

心跳只能证明”进程还在执行指令、网络还能通”,证明不了”这个进程能干活”。

想想这几种情况:

  • 进程活着,但线程池全被慢查询占满 → 心跳正常,请求全超时
  • 进程活着,但它依赖的数据库挂了 → 心跳正常,业务全失败
  • 进程活着,但磁盘满了写不进去 → 心跳正常,写入全失败
  • 进程活着,但 GC 卡了 30 秒(stop-the-world)→ 心跳发不出去,可它马上就好了

前三种是”心跳说好、实际坏”(漏报);第四种是”心跳说坏、其实好”(误报)。

所以现代编排系统把这两件事拆开了。Kubernetes 是最经典也最值得学的例子:

  • liveness probe(存活探针):进程还活着吗?不过 → 重启容器
  • readiness probe(就绪探针):能接客吗?不过 → 从 Service 的 endpoint 里摘掉,不再给它流量,但不重启
  • startup probe(启动探针):启动特别慢的应用,用它避免在还没起来的时候就被 liveness 打死

这个区分是救命的。 把 readiness 问题当 liveness 处理 = 一个只是变慢的服务被重启,重启后更慢(还要预热、重建缓存),又被重启 → 陷入重启循环。反过来,把 liveness 问题当 readiness 处理 = 一个死锁的进程永远占着流量不干活,谁也不重启它。

租约:一个”不需要通信就会失效”的权利

心跳是推的模型:我定期告诉你我还活着。它失效需要接收方主动判定。 租约是到期的模型:我给你一份有时间边界的权利,到点了自动失效——不需要任何人通知任何人。

“到期自动失效”这六个字是租约的全部精髓。 为什么它这么重要?因为通知这件事本身就可能失败。

来看那个理解租约必须看的场景:

主节点 M 持有”主”的身份,独占写入共享存储。某一刻,M 发生了一次超长的 GC 暂停(或者虚拟机被挂起、或者网络分区)。

  1. 集群等不到 M 的心跳,判定 M 死了,选出新主 M2。
  2. M2 开始写入。
  3. 然后 M 醒了。

关键在这里:M 完全不知道自己被判死了。在它的主观视角里,它只是”卡了一下”。 于是它继续以”主”的身份写入。

两个主同时写,数据就毁了。这就是脑裂(split brain)。

请注意 M 犯的是什么错——它不是恶意的,也没有 bug。它只是持有一份已经过期、但它无法知道的本地状态(“我是主”)。

这就是租约要解决的问题。租约的解法朴素得惊人:主身份带一个有效期。 M 想继续当主,必须在到期前续约;一旦超过有效期,M 必须停止一切需要主身份的操作。

但——光靠客户端自觉,够吗?

不够。这就是 fencing token 存在的理由

还记得上面那个场景吗?M 卡了 10 秒醒来,它确实会去检查”我的租约还有效吗”——但检查完到真正发出写请求之间,还有一段时间。而这段时间里它可能又卡了 10 秒。

更极端一点:M 检查租约 → 有效 → 构造写请求 → 正要发送时被 GC 暂停 15 秒 → 醒来 → 发出请求。此时租约早过期了,但 M 不知道。

你无法在自己的进程里可靠地判断时间。 这是分布式系统里最反直觉、也最重要的一条。GC、IO 阻塞、虚拟机迁移、进程被 SIGSTOP、时钟跳变——随便哪一个都能让你的本地判断在真正生效的那一刻变成谎言。

于是有了 fencing token:

  1. 每次授予/续约租约时,颁发一个严格单调递增的 token(33 → 34 → 35…)
  2. 客户端的每个写请求都带上自己的 token
  3. **存储层(资源侧)**记住自己见过的最大 token;任何 token 小于该值的写请求,一律拒绝

回到场景:M 的 token 是 33。M 卡住。集群选出 M2,token 34。M 醒来,带着 token 33 来写 → 存储层已见过 34 → 拒绝。

脑裂被挡住了,而且是在最后一个可能的防线上被挡住的。

这个机制的关键,也是最容易做错的一点:

fencing 的检查必须在资源(存储)这一侧,不能只在客户端。

为什么?因为客户端不可信——不是因为恶意,而是因为客户端会停顿。只有存储层能在”真正执行写入的那一瞬间”做检查,也只有它能原子地执行”比较并拒绝”。

(这正是 Martin Kleppmann 在批评 Redlock 时最核心的论点:一个分布式锁服务如果不能让被保护的资源侧验证 fencing token,那么当客户端暂停时,锁就是失效的。客户端自己检查”我的锁过期了没有”是没用的,因为检查完到真正写入之间还有一段时间。一句话:谁来保护数据,谁就必须自己验证 token。)

租约的几个工程细节

  • 租约时长怎么定? 太长 → 真故障后要等剩余租约到期才能让新主接管(不可用时间 ≈ 剩余租约);太短 → 网络一抖就掉租约,频繁切换。所以租约通常配”宽限期”,并要求持有方在租约剩余时间不足时主动降级(停止写入、主动退位)。
  • 时钟漂移问题:如果授予方和持有方各用自己的时钟判断过期,漂移会导致提前或延后失效。缓解手段:用单调时钟计时;让租约时长显著大于可能的最大漂移;或者干脆由授予方统一判定过期,持有方只负责续约。
  • 别等到最后一刻才续约:在租约过半时就开始续(类似 DHCP 的 T1/T2 时间点),给续约失败留出重试余地。
  • 租约不只用于”锁”:它还是缓存一致性的基础——给缓存项一个租约,到期作废,避免 invalidate 消息丢失导致的永久脏数据。

子主题八:幂等的四种手法详解,以及幂等键的生命周期

子主题四列的四种手法只是目录。现在把它做深——因为在真实系统里,幂等不是”加个唯一索引”就完事了。你必须回答四个问题:谁生成键?并发怎么办?结果怎么返回?键什么时候删?

手法一:唯一请求 ID + 去重表——最通用,坑也最多

问题一:ID 由谁生成?

必须是**客户端(调用方)**生成。

为什么?因为重试的本质是”同一件事再做一次”。如果 ID 由服务端生成,那么客户端重试 = 服务端收到两个独立请求 = 生成两个不同 ID = 去重表形同虚设。“这是同一件事”这个信息,只有知道”这是重试”的一方才掌握,而只有客户端知道。

所以这个 ID 有个专门的名字:幂等键(Idempotency Key)。对外 API 做得最好的例子是 Stripe——客户端在请求头里带 Idempotency-Key,服务端保证一段时间内同键同参数只执行一次。(这个”一段时间”是 Stripe 自己的具体选择,不是通用标准,你得按业务定。)

问题二:并发怎么办?

两个携带相同幂等键的请求同时到达(用户手抖点两次,或者重试间隔小于网络延迟)。

错误做法:先 SELECT 查去重表,没有就 INSERT 再执行业务。这是经典的 check-then-act 竞态——两个请求同时 SELECT 都发现”没有”,然后都去执行业务。

正确做法:让数据库的唯一约束来做仲裁。

  1. INSERT INTO idempotency (key, state) VALUES (?, 'IN_PROGRESS') —— 依赖唯一索引
  2. 插入成功 → 我是第一个,执行业务,然后把 state 改成 COMPLETED 并写入结果
  3. 插入失败(唯一键冲突)→ 已经有人在处理了。读那条记录:
    • 状态是 COMPLETED → 直接返回缓存的结果
    • 状态是 IN_PROGRESS → ?

“IN_PROGRESS 怎么办”是这道题的精华。 你有三个选择:

  • (a) 返回 409 / “处理中”,让调用方稍后重试。简单,但把复杂度甩给了调用方。
  • (b) 阻塞等待第一个请求完成,然后返回它的结果。体验最好,但要处理等待超时和唤醒机制。
  • (c) 把这条记录当分布式锁用,抢到锁的执行,抢不到的按 (a) 或 (b) 处理。

选哪个取决于业务:支付类通常选 (a) 或 (b)(用户愿意等,也必须给确定结果);异步任务类直接返回”已受理”。

问题三:同一个键,不同的参数,怎么办?

用户手滑(或客户端有 bug),用同一个幂等键发了两个金额不同的请求。服务端必须有明确行为。

正确做法:记录请求参数的指纹(hash)。 发现同键不同指纹 → 返回明确的错误,而不是继续执行,也不是假装成功。

为什么不能”假装成功”?因为那会掩盖一个严重的客户端 bug,让它在生产里潜伏半年。为什么不能”按新参数执行”?因为第一次请求可能已经生效了——你现在要执行的是另一件事,幂等键的语义已经被破坏。这里的正确态度是:明确失败,让 bug 暴露。

问题四:结果怎么返回?

重试的请求必须拿到第一次执行的结果,而不是一个”已处理”的占位符。

想想看:第一次扣款成功,返回订单号 A;响应丢了,客户端重试;如果你只返回”已处理”却不给订单号,客户端就永远不知道订单号了。所以去重表必须存结果(或者结果的引用)。 这也是它膨胀的根源。

手法二:状态机检查——最自然,但有前提

订单:待支付 → 已支付 → 已发货 → 已完成。“支付”操作的前置状态是”待支付”。重复调用时状态已是”已支付”,前置条件不满足 → 直接返回成功。

前提:状态转移必须单调(单向、无环)。 如果状态可以回退——比如”已退款”之后又回到”待支付”——那么”当前状态 ≠ 待支付”就推断不出”支付已经执行过”,幂等性就断了。

更严谨的做法:配合状态转移日志(记录每一次转移,而不只存当前状态)。这样即使状态能回退,也能通过”这条转移是否发生过”来判断。

狠一点的比喻:状态机检查就像地铁闸机——你刷过卡,闸机就记录了”你进站了”,再刷一次它知道你已经进站。它靠的是”状态单向”这个前提。一旦允许你退回”未进站”,这个检查就彻底废了。

手法三:乐观锁版本号——有个致命的坑

标准写法大家都会:

UPDATE account SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 5;   -- 影响 0 行说明版本变了

坑在哪?在于重试时你用的是哪个 version。

很多人的重试逻辑长这样:

for attempt in range(3):
    row = SELECT ... WHERE id = 1                      # 每次重试都重新读!
    affected = UPDATE ... WHERE id = 1 AND version = row.version
    if affected > 0:
        break

这个重试是完全无效的。 因为第二次循环重新读,读到的正是自己刚更新成的 version=6,于是 WHERE version = 6 成功了 → 又扣了一次。

所以乐观锁做幂等的铁律是:版本号(或前置状态)必须来自”原始请求”,重试时绝对不能重新读取。

expected_version = 5            # 在循环外确定一次!
for attempt in range(3):
    affected = UPDATE ... WHERE id = 1 AND version = expected_version
    if affected > 0:
        break
    # 影响 0 行:要么被别人改了,要么"我第一次其实成功了" → 查一下再决定

这个坑之所以致命,是因为它看起来是对的:代码能跑,测试也能过(正常路径第一次就成功)。只有在”第一次执行成功但响应丢失”这条罕见路径上才暴露。

这就是为什么幂等 bug 那么难发现——它的触发路径,是你的错误路径的错误路径。

还有一种更隐蔽的失效:如果业务本身是”累加”语义(balance -= 100),光有版本号只能防”基于过期数据的写入”,防不住”基于最新数据的重复写入”。所以幂等键(标识”这是同一件事”)和乐观锁(标识”基于哪个版本”)经常要一起用,它们防的是不同的东西。

手法四:一次性 Token——最适合防用户端重复提交

流程:

  1. 客户端进入提交页面时,先向服务端申请一个一次性 token
  2. 提交时带上这个 token
  3. 服务端原子地校验并作废
  4. 作废成功 → 执行业务;作废失败(token 不存在)→ 重复提交,拒绝

关键点:校验和作废必须是一个原子操作。

错误做法:SELECT 检查存在 → DELETE 删除。这两步之间有并发窗口,两个请求都能 SELECT 到存在的 token,然后都去执行业务。

正确做法:

  • 数据库:DELETE FROM tokens WHERE token = ?,看影响行数(1 = 消费成功,0 = 已消费)——DELETE 本身就是原子的 compare-and-delete
  • Redis:DEL key 看返回值,或者用 Lua 脚本把 get + del 包成一个原子操作
  • 唯一索引:token 列加唯一索引,INSERT 成功即消费成功

这套最适合”用户点了两下提交按钮”这类场景,因为 token 在页面加载时就能拿到,天然与”用户的一次意图”绑定。局限是需要一次额外的申请往返,也不适合系统内部的自动重试(自动重试时没谁去申请新 token)。

四种手法怎么选

手法适合场景谁生成标识做错了就完全失效的红线
幂等键 + 去重表通用;对外 API、跨系统调用调用方服务端生成 ID(去重失效);IN_PROGRESS 处理不当
状态机检查有明确单向状态流转的业务(订单、工单)业务主键状态可回退导致推断失效
乐观锁版本号单记录更新、防并发覆盖从原始请求读取重试时重新读版本 → 完全失效
一次性 Token用户端防重复提交服务端预发校验与作废非原子 → 并发穿透

这四种手法没有一种是开箱即用的,每一种都有一条”做错了就完全失效”的红线。

幂等键的生命周期:去重表会无限增长吗?

会。 每一次成功的请求都留下一条记录,而且你还要存结果。日积月累,这张表终将成为你系统里最大、最热、也最容易出问题的表之一。

于是引出最实际的工程问题:TTL 定多长?

这是一个新的两难:

  • TTL 太短:客户端在这个窗口之外才来重试 → 去重记录已删 → 当成新请求执行 → 重复执行。
  • TTL 太长:存储成本、索引膨胀、清理压力、备份和 DDL 变慢。

怎么定?铁则:TTL ≥ 你的最大重试窗口。 也就是说,你必须回答”最坏情况下,客户端可能隔多久才来重试”。这取决于:

  • 你的客户端重试策略(最多几次、最大退避多少 → 算出最坏总时长)
  • 客户端是否支持离线重试(移动端断网几小时后再提交!TTL 必须覆盖这个)
  • 是否有人工重试 / 对账补单(可能隔天甚至隔周)

几种清理策略,按推荐顺序:

  1. 按时间分区,定期 DROP 整个分区。 最省力的做法:按天/按周建分区,过期后 DROP PARTITION。这是 O(1) 的元数据操作,比 DELETE FROM ... WHERE created_at < ... 快几个数量级,而且不产生大量死元组(PostgreSQL 的 bloat)和 binlog(MySQL 的主从延迟)。在数据库里,“删得快”有时候比”删得准”更重要。

  2. 异步后台清理,绝不在前台路径上删。 不能让业务请求等着删数据。用定时任务/后台 worker 慢慢扫、慢慢删,并且限速(避免一次删太多把 IO 打满,影响线上流量)。

  3. 结果外置,去重表只存指针。 把完整响应体塞进去重表会让表迅速膨胀。正确做法:去重表只存幂等键、状态、参数指纹、以及结果的引用(对象存储的 key 或结果表 ID),大 payload 放对象存储——那里便宜,而且有原生的生命周期策略。

  4. 分层存储。 最近 N 天放高性能库(精确去重、低延迟);更早的归档到冷存储/数据仓库,只服务于对账和纠纷排查,不参与实时去重。

  5. 用 Bloom filter 加速?要极其小心。 想法是”用一个紧凑的概率结构快速判断’肯定没见过’“。但请记住:Bloom filter 只会假阳性(把没见过的说成见过),不会假阴性。 而在这里,假阳性意味着一个合法的新请求被误判为重复——对支付场景这是不可接受的事故。所以 Bloom filter 不能作为拒绝的依据,只能作为”加速查找”的旁路(命中了再去查精确存储确认)。永远不要让概率结构去守一条不可撤销的边界。

还有一个更根本的问题:去重表本身挂了怎么办?

去重表是你防重复扣款的最后一道防线,而它自己不可能 100% 可用。那它不可用时你选哪边?

  • Fail-closed(拒绝业务请求):安全,但去重表一挂整个业务就不可用——你把”去重组件”变成了新的单点。
  • Fail-open(放行,接受重复风险):可用,但故障期间可能产生重复扣款。

没有标准答案,取决于”重复一次”和”不可用一分钟”哪个更贵。但关键是你必须显式做出这个选择,而不是让它在代码里随机发生(比如连接池耗尽时抛出某个异常,恰好走进了某个分支)。

一个常见的折中:去重表挂了 → fail-open,但给这个请求打上”未去重”标记并写入对账队列,事后由对账系统发现并补偿。这叫”承认不可靠,但把不可靠显性化、可修正”。

最后一个被忽略的问题:幂等的边界在哪一层

假设 A 调 B,B 调 C。B 自己做了幂等。现在 A 重试了一次,B 收到两次请求——B 幂等了,然后呢?

B 处理第二次请求时,还会再调一次 C。如果 B → C 的调用没有带上”这是同一件事”的标识,C 就会执行两次。

结论:幂等键必须端到端传播。 整条调用链共用同一个幂等键(或由上游键派生出下游键),每个服务在处理时都用它去重。A 生成 key → 传给 B → B 传给 C → C 传给 D。

否则你得到的是”每一段各自幂等,但整体不幂等”。这就像接力赛:每一棒交接都很完美,但计时器是按人算的,最后总时间还是错的。


子主题九:重试的分类学——什么能重试、能重试多少、在哪一层重试

可重试 vs 不可重试:真正的判据不是错误码

先把常见的分类摆出来:

可重试(transient,暂时性):

  • 连接失败、连接被重置
  • 超时
  • 503 Service Unavailable、502 Bad Gateway
  • 429 Too Many Requests(但要尊重 Retry-After 响应头)
  • 网络抖动、TLS 握手失败
  • 下游明确返回的”请重试”语义

不可重试(permanent,永久性):

  • 400 Bad Request(参数错误)
  • 401 / 403(认证/授权失败)
  • 404 Not Found
  • 405 Method Not Allowed
  • 422 Unprocessable Entity(语义错误)

灰区(要看情况):

  • 500 —— 可能是”数据库连接池满”(可重试),也可能是”代码 NPE”(重试一万次也一样)
  • 409 Conflict —— 取决于冲突的具体语义
  • 超时 —— 这是可重试里最危险的一类

但——上面这张表只是”代理指标”,不是判据。 真正的问题有两条:

判据一:重试会不会改变结果? 也就是幂等性。如果操作不幂等,那么即使遇到的是”典型可重试”的超时,重试也不安全——除非你有去重机制。

错误码表告诉你”故障是否持久”;幂等性告诉你”重复执行是否安全”。两个都满足,才能重试。

判据二:重试有没有用? 这第二条更少有人想,但它极其重要。

  • 如果失败原因是”下游整体过载”——你的重试是在给一个已经喘不过气的服务再压一脚。重试不但没用,而且是加害。
  • 如果失败原因是”我这次运气不好,请求被路由到了一个正在 GC 的实例”——那么换一个实例重试,大概率立刻成功。

于是得到一条非常实用的原则:

对同一个实例重试,通常没用;对不同实例重试,通常有用。

它直接推导出两个工程结论:

  1. 重试要配合负载均衡,且重试时必须避开刚刚失败的那个后端(retry to a different backend)。打三次同一个坏实例,除了浪费时间没有别的意义。
  2. 如果失败是全局性的(下游所有实例都不行),重试就是纯粹的浪费——这正是重试预算要解决的问题。

重试预算:具体怎么做

前面说”重试预算是限制重试占总请求的比例”。现在说具体怎么实现。

做法(令牌桶视角):

维护两个计数(滑动窗口或令牌桶):

  • total:总请求数(含重试)
  • retries:其中属于重试的数量

在每个时间窗口(常见是 10 秒或 1 分钟)内计算 retry_ratio = retries / total。只有 retry_ratio < budget(比如 0.1)时才允许发起重试;否则直接把错误返回上游,一次都不重试。

为什么这个做法有效?它有一个优雅的自适应性质:

  • 系统健康时:失败很少 → retries 很少 → ratio 远低于预算 → 重试畅通无阻。单点偶发故障被自动吸收,用户完全无感知。
  • 系统真出大问题时:失败暴增 → retries 暴增 → ratio 迅速触顶 → 重试被掐断 → 下游收到的请求量被限制在 1/(1−budget) 倍以内。

预算取 10% 时,最坏情况下下游负载最多增加约 11%(1/0.9 ≈ 1.11)。

请把这句话单独记住:你把最坏情况下的放大倍数,从一个不可控的正反馈,变成了一个可以写进容量规划的常数。 这个转变非常值钱。

更精细的做法:

  • 按客户端分别计算预算:避免一个大客户把预算吃光,导致小客户的正常重试也被拒。
  • 按依赖分别计算:A 服务的预算不该影响 B 服务的。
  • 用令牌桶实现:桶容量 = budget × 窗口内典型请求数,每次重试消耗一个令牌,随请求流入按 budget 比例回充。比滑动窗口计数更平滑(没有窗口边界的抖动)。
  • 渐进降级:ratio 接近阈值时不是一刀切,而是只允许”最有价值的重试”(比如只重试高优先级请求)。

注意:重试预算和熔断解决的是不同问题,别混淆。

视角保护谁作用
重试预算”我”的视角保护下游限制我额外发出多少流量;本质是一种限流
熔断”下游”的视角保护我自己,也停止折磨下游观察到下游不健康就整体断开

重试预算不会让下游变好,它只是不让它变更坏;熔断才会给下游真正的喘息时间。所以两者要配合:熔断决定”要不要试”,重试预算决定”能试多少”。

重试放大:为什么只能在一层做

这是重试最经典的陷阱,值得把数字算清楚。

假设一条调用链有 L 层,每层最多尝试 R 次(1 次原始 + R−1 次重试)。那么最坏情况下,最底层收到的请求数是:

R^L

  • L=3, R=3 → 27 倍
  • L=5, R=3 → 243 倍
  • L=5, R=4 → 1024 倍

是指数,不是加法。

更糟的是,这个指数增长落在最下游、最脆弱的那一层(通常是数据库或存储),而且触发它的条件恰恰是”下游慢了”——也就是它最扛不住的时候。这是一份完美的风暴配方。

反问:为什么”每层都配重试”会这么自然地发生?

因为每一层的开发者都是善意的。网关想”我重试一下用户体验好”;服务 A 想”我重试一下就不用把错误抛给用户了”;服务 B 想”网络抖一下很正常,重试就好了”。

每一个局部最优的决策,加起来就是全局灾难。

这是分布式系统里最典型的涌现行为:没有任何一个人做错了,但系统错了。而且没人能在自己的代码里看出来。

怎么破?必须有一个全局的锚点。三种可行方案:

方案一:只在边界层重试,内部全部 fail-fast。 选定 Gateway / BFF 作为唯一的重试层,内部服务遇到错误立刻返回,一律不重试。

  • 优点:概念清晰,重试次数严格可控(就等于 R)。
  • 缺点:边界层不知道内部错误的细节(“这个 500 到底可不可重试”),需要内部服务用明确的错误码/元数据告诉它。这要求你有一套跨服务的错误语义规范。

方案二:每个调用点都可以重试,但用端到端的尝试预算封顶。 在请求上下文里带一个字段(比如 attempts_remaining),每次重试递减并向下传递,到 0 时任何一层都不许再重试。

  • 优点:灵活——每一层都能处理自己的局部抖动,同时总量有硬上限。
  • 缺点:需要全链路框架支持(tracing/上下文传播里要有这个字段)。

方案三:交给 sidecar / 服务网格统一配置。 把重试从业务代码里拿出来,下沉到基础设施。

  • 优点:统一、可观测、改配置不用发版。
  • 坑:这是最容易造成多层重试的做法。 在 Istio 这类服务网格里,每一个 client-side sidecar 都可能对它的上游发起重试。A 的 sidecar 重试 3 次 → B 的 sidecar 对收到的每个请求再重试 3 次 → 又是 9 倍。

所以:如果你用服务网格,必须同时配置全局的尝试上限和重试预算。否则你只是把放大问题从代码挪到了配置文件里——换了地方,没换本质。

重试的层数不是”越多越可靠”,而是”每多一层就多一个数量级的风险”。

你要能一句话回答”我们系统的重试发生在哪一层”。如果答案是”好像每层都有”——那你已经中招了。

补充:退避与抖动的三种具体算法

子主题三说了”指数退避 + 抖动”。现在把抖动具体化(AWS 的 Marc Brooker 在 2015 年做过系统的对比与压测)。

设 base 为基础退避(比如 100ms),cap 为上限(比如 1s),第 n 次尝试:

  1. 无抖动:sleep = min(cap, base × 2^n) → 问题:所有客户端整齐同步,重试形成尖峰。

  2. 全抖动(Full Jitter):sleep = random(0, min(cap, base × 2^n)) → 在 0 和退避值之间均匀随机。实测中总完成时间和下游负载都表现最好,是最推荐的默认选择。

  3. 等抖动(Equal Jitter):sleep = d/2 + random(0, d/2),其中 d = min(cap, base × 2^n) → 保留一半退避时间,再叠加一半随机。介于两者之间。

  4. 去相关抖动(Decorrelated Jitter):sleep = min(cap, random(base, prev_sleep × 3)) → 本次退避基于上次的随机值,去相关性最好,适合客户端数量极多的场景。

默认用全抖动——简单、效果好、参数少。它好在哪儿?它把重试的到达过程摊平成了一个近似泊松过程:下游看到的是平稳的额外负载,而不是一波波整齐的冲击波。

最后一条反直觉的提醒:退避本身也吃你的超时预算。

如果你有 3 次尝试、退避 100/200/400ms,光等待就 700ms,还没算请求本身的时间。所以——重试次数、退避时间、单次超时,三者必须一起塞进这一层的总预算里:

单次超时 × 尝试次数 + 累计退避时间 ≤ 该层的超时预算

这条不等式很多人从来没写过,然后就奇怪”为什么加了重试之后 P99 反而变差了”。


演进脉络

  • 早期(单机):失败是明确的(进程崩溃、机器断电),靠重启和人工介入。
  • 中期(分布式初期):固定超时 + 简单重试,经常踩重试风暴的坑。
  • 2010s:熔断(Netflix Hystrix)、舱壁、限流、指数退避+抖动成为标配;幂等设计被系统性地强调。
  • 2010s 后期:Google 提出”重试预算”和”对冲请求”;自适应超时和上下文传播(Go context 是代表)成为框架标配。
  • 现代:服务网格(Service Mesh)把重试、超时、熔断下沉到基础设施层;混沌工程成为验证容错的常规手段。

主线:容错的演进,是从”被动超时重试”走向”系统性的故障管理”——从相信”重试能解决问题”,到理解”重试本身就是风险的来源”,再到用预算、熔断、演练把风险关进笼子。


子主题十:对冲请求与”绑定等待”——用 5% 的资源买断长尾

先认清敌人:尾延迟是怎么被放大的

设想一个典型的 fan-out 场景(比如搜索、信息流聚合):你的服务要调用 100 个下游分片,并且必须等所有分片都返回才能响应客户端。

假设单个分片的 P99 延迟是 1 秒——也就是 1% 的请求要花 1 秒以上。

现在问你:一次用户请求(要等 100 个分片全部返回)中,至少有一个分片超过 1 秒的概率是多少?

1 − (0.99)^100 ≈ 63%

63%。 单个组件 99% 的请求都很快,看着挺体面;但 100 个组件一组合,长尾从”偶发”变成了”常态”。

而且规模越大越糟:1 − (0.99)^1000 ≈ 99.995%——几乎每一次用户请求都会踩到长尾。

这就是 Dean 和 Barroso 在《The Tail at Scale》(2013) 里给出的核心观察:

在大规模 fan-out 系统里,单个组件的尾延迟会被放大成整个系统的普遍延迟。

那长尾从哪儿来? 关键在于——不是因为请求本身慢,而是一堆随机的、与请求内容无关的因素:

  • 排队(这个实例刚好在队列里多待了一会儿)
  • GC 停顿
  • 网络丢包重传
  • 同宿主机上的其他进程抢 CPU(noisy neighbor)
  • 磁盘偶发慢 IO
  • 页缓存未命中

请盯住”与请求内容无关”这个限定——它是判断对冲是否有效的关键,稍后会回来。

对冲请求(Hedged Requests)

做法简单得不像优化:

  1. 发请求给副本 1
  2. 等待 τ 时间;如果 τ 到了还没返回,再发一个相同的请求给副本 2(这就是”对冲”,hedge)
  3. 哪个先返回就用哪个,取消另一个

代价分析(这是重点):

关键在于 τ 的选择。如果 τ 设为单个请求的 P95 延迟,那么只有约 5% 的请求会慢到触发对冲 → 额外负载约 5%。

而收益是:你拿到的是”两个独立样本取最小值”的延迟分布。如果慢的原因是随机的(排队、GC、丢包),那么两次都踩到长尾的概率,远小于一次踩到的概率。

用约 5% 的额外请求量,把 P99(甚至 P99.9)砍下一大截。

这是整个容错/性能优化工具箱里性价比最高的交易之一。

τ 就是收益和代价之间的旋钮:

τ 的取值触发对冲的比例额外负载收益
P50~50%约 +50%(贵)最大,但几乎没人这么干
P95~5%约 +5%显著,常见起点
P99~1%约 +1%温和
接近超时几乎不触发~0几乎没有,白搭

而且——对冲的额外负载是有界的,这一点和重试本质不同。

  • 重试是”失败才触发”,而失败率在系统崩溃时可以趋近 100% → 重试负载可以趋于无穷(还记得 R^L 吗)。
  • 对冲是”慢才触发”,而慢的比例是由 τ 决定的常数(τ=P95 → 永远只有约 5%)。

对冲的代价是可预测的常数;重试的代价是失控的指数。这是对冲比重试安全得多的根本原因。

绑定等待(Tied Requests)

对冲有个副作用,很多人没意识到:你取消的只是自己的等待,不是下游的工作。

客户端取消了对第一个请求的等待,但副本 1 并不知道(除非你有上下文传播),它还在老老实实地算。于是两个副本很可能都做了完整的工作——重复执行。

Tied Requests(同样出自《The Tail at Scale》)就是对症的解法:

  1. 把同一个请求同时发给两个副本(不是等 τ 再发)
  2. 每个请求里都带上另一个副本的地址——这就是”绑定(tied)“的含义:两个请求互相知道对方的存在
  3. 其中一个副本开始真正执行时,主动给另一个副本发一条取消消息
  4. 由于执行顺序的微小差异,通常一个先开始执行,另一个在被取消前只排队了极短时间

效果:重复执行的工作量远小于普通对冲。 普通对冲的重复执行窗口是”从 τ 到返回”这一整段;而绑定等待的重复执行窗口只有”两个副本开始执行的时差”,通常极短。

代价:需要服务端支持。 你的服务必须能识别这个特殊的”取消”消息并响应它。这限制了适用范围——自研的、可控的服务可以做,调第三方 API 就别想了。

所以实践中”对冲请求”远比”绑定等待”普及:对冲是纯客户端/代理侧实现,零服务端改造;绑定等待要改服务端,收益却只是”少浪费一点 CPU”。

对冲在什么情况下完全无效?(必读)

如果”慢”的原因是请求内容本身,对冲就是纯粹的浪费。

比如:这个请求要扫描一千万行数据、要做一次复杂聚合、用户就是要一个大结果集。这种情况下:

  • 发给副本 1 慢 → 发给副本 2 一样慢,而且慢的时间完全相同
  • 你先白等了 τ,然后多花了一份 100% 的计算资源
  • 延迟一点没降,负载翻倍

判断标准:同一个请求打到不同副本,延迟是否独立?

慢的原因延迟是否独立对冲
排队、GC、丢包、热点节点独立✅ 有效
请求本身重、数据倾斜、依赖了同一个慢的下游相关❌ 无效且有害

一个可操作的检验方法:对冲上线后看两件事——

  • (a) P99 有没有真的降下来?
  • (b) 在触发对冲的请求里,对冲请求是不是真的比原请求快?

如果 (b) 的答案是否定的——两个副本慢得差不多——说明你的慢是”内容相关”的,对冲纯属烧钱,赶紧关掉。

还有一个前提:系统必须有余量。 对冲靠的是”别的副本现在正空闲”。如果整个集群已经跑在很高利用率上,对冲请求只会让排队更长——对冲本身会制造它试图消除的长尾。

所以必须有保护机制:

  • 对冲预算(类似重试预算):限制对冲请求占总请求的比例
  • 按系统负载动态调整(更聪明的做法):负载低时对冲激进(τ 调小),负载高时对冲收敛(τ 调大,甚至直接关闭)

同类机制一览:同一个思想的不同马甲

“用冗余换延迟/可靠性”这个思想在业界到处都是,只是名字不同:

  • Backup requests / Speculative retry:Cassandra 的 speculative retry——超过 P99 就向另一个副本发探测请求
  • Speculative execution(推测执行):Hadoop/Spark 发现某个 task 明显慢于同批(straggler),就在别处另起一个,谁先完成用谁的
  • 并行发送:同时发给多个副本,不等 τ。最简单,也最费资源
  • 跨可用区对冲:对冲请求特意发到另一个 AZ,既能扛单 AZ 的抖动,也能扛单 AZ 故障

思想内核: 这些机制做的是同一笔交易——

用可预测的、少量的资源冗余,去消除不可预测的、偶发的延迟。

它之所以划算,是因为现代数据中心的机器大部分时候并不满负荷(要留高峰余量),闲置资源就在那里,你不利用它也是浪费。

冗余容错买的从来不是”速度”,是”确定性”。


子主题十一:混沌工程——凭什么相信你的容错机制?

一个刺耳的假设

你的容错机制,如果没有被验证过,就假定它是坏的。

这句话听着极端。但让我们用一个简单推理来检验它。

想想你系统里那些”容错代码”——failover 逻辑、降级分支、熔断后的处理、备份链路切换——它们有多久没被执行过了?

如果一切正常,它们一次都不会执行。一天不执行,一周不执行,一年不执行。

而与此同时,系统的其他部分在飞速变化:

  • 依赖服务的地址变了,DNS 记录没同步到备份链路
  • 证书过期了,新证书只配在了主链路
  • 配置中心里 failover 需要的某个开关被改掉了
  • 备份集群的版本落后三个大版本,接口已经不兼容
  • 备份区域的容量其实根本不够——只是从来没人验证过
  • 负责那套 failover 脚本的同事去年离职了
  • 监控告警的接收人已经换过三批

于是你得到一个必然的结论:

你的”正常路径”每一秒都在被生产流量验证;你的”容错路径”几乎从不被验证。两者的可靠性差距会随时间单调拉大——而且这个差距你自己看不见。

直到那个真正出事的凌晨三点。

这不是理论,这是几乎每一起重大线上事故的标准叙事:不是”我们没有容错机制”,而是”我们有,但它没生效”。

打个狠的比方:一台十年没启动过的备用发电机,等于没有备用发电机。 你不会在医院里装一台十年没启动过的发电机然后宣称”我们有备用电”。但你每天都在往生产环境部署”十年没验证过的 failover 逻辑”。

混沌工程的定义:它是实验,不是搞破坏

混沌工程(Chaos Engineering)的正式定义(来自 principlesofchaos.org 的表述):

混沌工程是在系统上进行实验的学科,目的是建立对系统抵御生产环境中动荡条件能力的信心。

注意关键词:实验——有假设、有对照、有观测、有结论。不是破坏(随机杀机器看会发生什么)。

混沌工程和”随便搞破坏”的分界线只有一条:你有没有一个在实验前就写下来的、可证伪的假设。

起源

Netflix。2010 年前后,Netflix 正把自己的基础设施往 AWS 上迁移。云上的虚拟机比自建数据中心的机器更容易挂——这不是缺陷,是设计使然:云用廉价的商用硬件 + 软件容错,而不是昂贵的高可用硬件。

Netflix 的应对不是”买更贵的机器”,而是:接受实例会随时挂掉这个事实,然后让整个架构在实例随时挂掉的前提下依然可用。

为了逼自己做到这一点,他们写了 Chaos Monkey——一个在生产环境随机终止实例的工具。逻辑直白得近乎粗暴:

如果我们连”随机挂一台机器”都扛不住,那我们的架构就是假的。

它后来被开源,属于范围更大的 Simian Army 工具集(其中 Chaos Gorilla 杀整个可用区、Latency Monkey 注入延迟、Conformity Monkey 找出不符合最佳实践的实例……)。“混沌工程”这个术语和知识体系随后被系统化,2016 年前后出现了专门的著述和社区。

怎么做:一个有纪律的流程

第一步:定义稳态(steady state)。

不是”CPU 多少”,而是系统正常工作的可测量指标:每秒成功订单数、P99 延迟、错误率、某条关键业务流的转化率……

这一步是整个混沌工程的地基。 如果你的系统没有这些指标,你就无法判断实验成功还是失败,混沌工程就退化成”看看会不会炸”——那不是工程,那是赌博。

第二步:提出假设。

形式如:“当终止 10% 的订单服务实例时,稳态指标不会显著变化(因为其他实例会自动接管,且重试 + 熔断会吸收错误)。”

假设必须是可证伪的。 “系统应该还行”不是假设;“P99 延迟上升不超过 10%,错误率不超过 0.1%“才是。

第三步:注入真实世界的故障。

故障清单要来自你真实经历过的故障,而不是凭空想象:

  • 实例终止 / 进程被 kill
  • 网络延迟增加、丢包、分区
  • 磁盘满、IO 变慢
  • CPU / 内存打满
  • 依赖服务返回错误或变慢
  • 证书过期
  • DNS 解析失败
  • 时钟偏移
  • 整个可用区失效

第四步:控制爆炸半径(blast radius)——这是最重要的一条纪律。

从最小开始,逐级放大:

  • 一个实例 → 1% 的实例 → 一个可用区 → 整个区域
  • 读流量 → 写流量(永远先做读)
  • 非高峰期 → 高峰期(上来就在高峰期做,那是鲁莽不是勇敢)
  • 内部用户 → 1% 外部用户 → 灰度 → 全量

而且必须有自动终止条件(abort condition):一旦稳态指标被破坏超过阈值,实验立刻自动停止,不等人来拍板——人反应太慢了。

关于”要不要在生产做”:这是混沌工程里争议最大、也最容易被误解的一点。完整的答案是三段论。

(a) 起点一定不在生产。

你第一次做混沌实验,应该在预发/灰度/独立的演练环境里做。原因不是”生产太危险”,而是——你的实验本身也可能是错的:注入脚本有 bug、指标看错了、回滚方案根本不成立。

先用非生产环境验证”实验是安全的”,再去生产验证”系统是可靠的”。这是两件不同的事,别混在一起做。

(b) 但最终必须在生产(或尽可能接近生产)做。 因为预发环境和生产有本质的、无法消除的差异:

  • 没有真实的流量模式(真实流量有峰谷、有突发、有长尾请求)
  • 没有真实的规模(很多问题只在几百个节点时才出现)
  • 没有真实的依赖拓扑(有些依赖根本没有预发版本)
  • 没有真实的数据分布(数据倾斜、热点 key 只在真实数据上出现)
  • 最关键:预发环境的故障注入不会触发真实的告警和真实的 on-call 响应流程。 而”人能不能在正确的时间收到正确的告警、知不知道该做什么”恰恰是故障响应的关键一环,它只能在生产演练。

(c) 生产实验的三条铁律:

  1. 有明确的假设和稳态指标
  2. 爆炸半径从小到大,且有自动终止条件
  3. 有经过演练的停止手段——而且停止手段本身不能依赖可能被这次实验搞坏的东西(这是经典陷阱:你的”紧急停止”按钮恰好依赖了正在被 Chaos 攻击的那个服务)

第五步:常态化,而不是一次性活动。

一次性演练只能证明”那天它是好的”。系统的架构、依赖、配置每天都在变,一次演练的结论会过期。

成熟的实践是持续验证(Continuous Verification):把混沌实验做成定时任务,低烈度、高频次地在生产持续跑,把”容错能力”变成一个持续被回归测试的属性,而不是一年一次的消防演习。

再上一个台阶是自动化实验 + 自动发现:让工具自动分析架构、自动推断该注入什么故障、自动执行和判定。

混沌工程不能替代什么

  • 不能替代监控。 没有可观测性,混沌工程根本无从谈起。
  • 不能替代容量规划。 它验证的是”故障时的行为”,不是”能不能扛住流量”。
  • 不能替代压测。 压测是负载维度,混沌是故障维度,两者正交,都要做。
  • 不能替代好的设计。 混沌工程是检验器,不是生成器——它不能让一个设计糟糕的系统变好,它只是让你早点知道它有多糟。

它真正改变的是什么

混沌工程是把”运气”转化为”工程”的过程。

  • 它把”我们觉得应该没问题”变成”我们上周验证过它可以”。
  • 它把”故障是意外”变成”故障是预期的、被演练过的常规事件”。
  • 它强迫你先修好可观测性——否则你连实验都做不了,而这件事的收益往往比实验本身还大。
  • 它让新人在入职第一个月就见过一次真实的故障处理,而不是等一年后那次线上事故。

没有混沌工程,你系统可用性里就有一大块是——“我们运气好,还没出过那种事”。


子主题十二:两种容错哲学——Erlang 的”让它崩” vs Go/Java 的防御式

一个反常识的提议

如果我说:错误处理代码写得越多,系统可能越不可靠。 你信吗?

在主流语言里长大的工程师,本能反应是不信。我们从小被教育:要检查参数、要捕获异常、要考虑所有边界情况、要 try-catch-finally 面面俱到。防御式编程是”专业”的同义词。

但 Erlang 社区(以及 Joe Armstrong)提出了一个几乎相反的主张:let it crash——让它崩。

先别急着站队。我们来搞清楚这个主张到底在说什么,以及——更重要的——它成立的前提是什么。

Erlang/OTP 的做法

背景:Erlang 诞生于爱立信,用于电信交换机。这类系统的可用性要求极高(Armstrong 常举的例子是”九个九”,即 99.9999999%,一年停机时间在几十毫秒量级——这是电信设备的目标,不是随便哪个系统都能达到的)。

在那种要求下你很快会意识到一个事实:

你不可能在代码里穷举所有可能的错误状态。

一个长时间运行的系统,状态空间的组合会膨胀到你无法推理的程度。你写 if (x == null) return error 挡住了 NPE,但挡不住”x 不为 null,可它是昨天缓存的过期数据”,更挡不住”x 和 y 的这个组合理论上不可能出现,但因为三次重试的时序错乱,它出现了”。

Erlang 的答案是:不要试图在函数内部处理所有错误。让错误变成崩溃,让崩溃被一个专门的角色接管。

三根支柱:

支柱一:超轻量、内存隔离的进程。

Erlang 进程不是 OS 进程也不是 OS 线程,而是运行时自己调度的绿程,创建成本极低(起步几 KB,一个节点可以跑几十万甚至上百万个)。

最关键的是:进程之间不共享内存,只能通过消息传递通信。

这条”不共享内存”是整个哲学的物理基础。为什么?因为:

不共享状态 = 一个进程崩溃时,不可能把别的进程的内存弄成半损坏状态。崩溃是干净的。

支柱二:监督树(Supervision Tree)。

进程被组织成树。非叶子节点是 supervisor(监督者),它唯一的职责就是监视子进程,并在它们崩溃时按策略重启。supervisor 自己不干业务逻辑。

这是一个关键的关注点分离:

干活的进程只管干活,处理错误的进程只管处理错误,两者不在同一个函数里纠缠。

重启策略(OTP 的标准三种):

  • one_for_one:谁崩了重启谁(最常用)
  • one_for_all:一个崩了,所有兄弟一起重启(适用于兄弟间有强耦合状态)
  • rest_for_one:一个崩了,在它之后启动的那些兄弟重启(适用于有启动顺序依赖)

支柱三:重启强度限制 + 逐级上报。

每个 supervisor 配一个重启强度,比如”最多在 N 秒内重启 M 次”。超过这个强度,supervisor 自己也崩溃,把问题上交给它的父 supervisor。

这个设计极其漂亮,它意味着:

  • 偶发错误 → 局部重启,自愈,上层完全无感知
  • 持续的、重启解决不了的错误 → 不会陷入无限重启循环,而是层层升级:worker 崩 → supervisor 崩 → 更上层 supervisor 崩 → 可能整个子系统重启 → 甚至整个节点重启、由另一个节点接管

它把”什么时候该放弃自救、向上求助”变成了一个声明式配置,而不是散落在代码各处的 if-else。

为什么”让它崩”有时比”防御”更可靠?

核心论点一句话:

崩溃 + 重启,把你带回一个已知的、干净的初始状态;而在原地打补丁,你留在了一个未知的、可能已经损坏的状态。

换成一个狠类比:

你经营一栋公寓,发现某个房间着火了。

  • 防御式做法:冲进去,用灭火器对着火点猛喷。但你不知道火已经烧到墙里的电线没有,不知道地板下的燃气管有没有事。你扑灭了明火,可这个房间的状态是未知的——也许明天它又烧起来,而且是在你最没准备的时候。
  • “让它崩”做法:让这个房间烧完(它是隔离的,有防火墙,不会波及别的房间),然后按标准图纸重建一个全新的房间。重建完的房间,状态是完全已知的。

哪个更可靠?在”房间造价很低、重建很快、而且有防火墙”这三个前提下,答案显然是后者。

Erlang 的整个设计,就是把这三个前提变成现实:

前提Erlang 的对应机制
重建成本极低超轻量进程
有防火墙(不污染别人)无共享内存
自动触发重建监督树 + 重启策略
重建后能继续工作状态外置(数据库 / ETS / mnesia / 从对等节点重建)

所以 “let it crash” 不是”对错误不负责”,恰恰相反——它是”对恢复负责,而不是对错误负责”。

它承认”搞清楚这个错误状态到底怎么来的、怎么在原地修复”太难了,于是换了个问题:“怎么快速回到一个肯定没问题的状态?”

还有一个被严重低估的收益:代码会变得极短。 Erlang 的工业代码里错误处理代码的比例远低于 Java——因为只写 happy path。代码短 → bug 少 → 更容易推理。 这是一个正反馈。

那 Java 和 Go 为什么不能照抄?

因为前提不成立。

Java:

  • 线程是 OS 线程(或较新的虚拟线程),创建成本高,不能”随便重启一个”
  • 线程共享堆内存——这是致命的。 一个线程在更新一个共享对象时抛异常,那个对象可能已经是半更新状态:一半字段改了,一半没改。你重启了线程,但那个对象还在那儿,坏着。崩溃不是干净的。
  • 所以 Java 的正确做法就是防御式:try-catch-finally、事务回滚、不可变对象、显式清理。

Go:

  • goroutine 很轻量(初始栈几 KB,可增长),创建成本远低于线程 ✓
  • 但 goroutine 共享堆内存 ✗ —— 和 Java 一样,崩溃不干净
  • 更狠的是:一个 goroutine 的 panic 如果没有被 recover,会让整个进程崩溃退出(这是 Go 运行时的设计决定)。所以 Go 里”让 goroutine 崩”的后果比 Java 还严重——Java 里一个线程死掉通常只是那个线程没了,Go 里是整个服务没了。
  • 所以 Go 的惯例是:显式返回 error 并就地处理(if err != nil),并且在每个 goroutine 的入口处 recover,把 panic 转成 error 上报。
  • 社区里也有移植监督树的库(errgroup 提供”一个 goroutine 出错就取消其他”,一些 supervisor 库提供重启策略),但没有运行时的内存隔离作为支撑,这些只是”形似”——你还是得自己保证崩溃时不留下半损坏的共享状态。

所以核心判据是:

能不能”让它崩”,不取决于你的勇气,取决于崩溃的代价。 崩溃代价 = 崩溃时污染了多少别人能看见的状态 × 重建这个单元的成本。

污染半径重建成本崩溃代价结论
Erlang 进程0(无共享)≈ 0(轻量)≈ 0让它崩
Java 线程整个堆高极高必须防御
Go goroutine整个堆,且 panic 带走整个进程低高必须防御 + 边界 recover

两种哲学在现代的和解:分层使用

好消息是:这不是非此即彼的选择,而是”在哪个层级用哪种”的问题。

看看 Kubernetes 到底做了什么:

  • 容器崩溃 → kubelet 按 restartPolicy 重启
  • 进程不响应 liveness probe → 杀掉重启
  • 节点失联 → 超过阈值后把 Pod 调度到其他节点
  • readiness 不通过 → 摘流量(不重启)

这不就是 Erlang 的监督树吗?

  • 容器 = “进程”(隔离的、可丢弃的,而且隔离得比 Erlang 进程更彻底:独立内存空间、独立文件系统、可限制的资源)
  • kubelet = “supervisor”(只负责监视和重启,不干业务)
  • restartPolicy + 指数退避 = “重启策略 + 重启强度”(CrashLoopBackOff 就是重启强度超限后的退避行为)

Kubernetes 就是 “let it crash” 在云原生时代的全面胜利——它只是把隔离边界从”Erlang 进程”换成了”容器”,把 supervisor 换成了编排系统。

而同一个系统的不同层级,用的其实是不同哲学:

层级特点采用的哲学
进程/容器内部共享内存、崩溃代价高防御式(try-catch、error 返回、事务、不可变对象)
进程/容器之间无共享内存,崩溃对对方无害容错式(超时、熔断、重试、降级)
实例/节点级别无状态、可重启、有编排系统let it crash(崩了就重启,编排系统负责)

这个三层结构,才是现代容错设计的完整答案。

Java 世界里最接近这个融合的是 Akka——它把 Erlang/OTP 的监督树原样搬到了 JVM 上(Actor 模型、supervisor strategy 直接对应 one_for_one / one_for_all / rest_for_one,也有重启强度配置)。它靠的不是运行时隔离,而是**“Actor 之间不共享可变状态”这个约定**——虽是约定而非强制,但说明了一件事:

只要你自己守住”不共享可变状态”这条纪律,即使在没有运行时隔离的语言里,也能拿到相当一部分 let it crash 的好处。

三条务实建议

  1. 不要只学”让它崩”,不学配套。 只学一半的后果是灾难:一个共享内存的多线程服务,到处不 catch 异常,崩了靠 systemd 拉起来——这不是 let it crash,这是放弃。 配套是:隔离、无状态、状态外置、监督与重启、可重放的消息。

  2. 把”错误处理”和”业务逻辑”在结构上分开。 即使你不用 Erlang 也成立:不要在每个函数里撒 try-catch,把它收敛到统一边界(中间件、拦截器、supervisor)。散落的 catch 块是最难推理的代码——你永远不知道哪个 catch 会吞掉哪个错误。

  3. 问自己:我的这个单元崩溃后,系统能自动回到干净状态吗? 如果”能”(无状态、幂等、状态外置、有重启机制)→ 你可以少写很多防御代码,把精力放在快速恢复上。 如果”不能” → 老老实实防御。 这个答案由你的架构决定,不由你的语言决定。


子主题十三:实操——给一个服务设计完整的容错方案

前面十二节全是原理。现在把它们变成一张能照着执行的清单。假设你要给一个中等规模的后端服务设计容错,以下是完整的层次化配置流程。

第 0 步:先画依赖图,标出强弱依赖

在配任何参数之前,先做一件事:把你的服务依赖的所有东西列出来,逐个打标签——

  • 强依赖:它挂了,你的核心功能就无法完成(比如下单服务的库存服务、支付服务的账务系统)
  • 弱依赖:它挂了,你的功能可以降质完成(比如推荐服务、风控的次要规则、用户画像、个性化文案、积分计算)

这一步是所有后续配置的基础,也是最常被跳过的一步。 不做这一步,你后面所有降级方案都是空中楼阁——因为你根本不知道哪些东西可以没有。

并且要记住:强弱依赖会变。 大促期间风控可能是强依赖,平时它是弱依赖。这张图要随业务演进维护,不是画一次就完事。

第 1 步:超时——从用户往回推,而不是从下往上凑

唯一正确的做法:从上往下分配预算。

假设你的 SLO 是”用户请求 1 秒内返回”,调用链是 Gateway → 服务 A → 服务 B → 数据库:

层分配到的预算说明
用户 → Gateway1000 ms即 SLO
Gateway → A800 ms留 200ms 给 Gateway 自身处理 + 网络
A → B400 ms留 400ms 给 A 自身处理、组装、以及重试余量
B → DB150 ms留 250ms 给 B 的业务逻辑

三条必须遵守的规则:

规则一:每层的超时 = 剩余预算 − 本层自身处理时间 − 网络开销 − 重试余量。 是减法,不是拍脑袋。

规则二:重试次数 × 单次超时 ≤ 该层预算。 如果你给 A→B 配了 400ms 总预算,还想要 3 次尝试,那单次超时必须压到 130ms 左右(还要扣掉退避时间)。这条不等式是”加了重试之后 P99 反而变差”的头号原因。

规则三:超时必须配上下文传播。 上游超时了,下游要立刻停止工作。否则你只是停止了等待,下游还在老老实实地算,白白消耗它自己的 CPU、连接、线程——你把”我等不及了”变成了”我们两个都白干了”。Go 的 context、gRPC 的 deadline 传播、Java 的 CompletableFuture 取消,都是这个机制。

一个值得注意的设计细节: context 里传递的 deadline 通常是绝对时间戳,而不是”剩余 100ms”。这是有意为之的——如果传剩余时长,每一跳的网络传输时间就没人承担了,会导致层层缩水;传绝对时间戳,则每一跳自动扣掉自己的传输开销。一个小设计,防止了整条链的超时不断衰减。

第 2 步:重试——确定唯一的重试层

按子主题九的分析,先做一个决定:重试到底发生在哪一层?

推荐方案(适合大多数团队):

位置是否重试理由
入口层(Gateway / BFF)✅ 允许重试有全局视角,能控制预算,能区分错误语义
内部服务之间❌ 禁止重试,fail-fast杜绝 R^L 放大
基础设施层(如 MQ 消费)✅ 允许,但有独立预算语义不同(ack/nack 重试)

内部服务不重试,那局部抖动怎么办? 靠两点解决:

  1. 上游统一重试(Gateway 会重试整条链路)
  2. 负载均衡层的健康检查 + 实例摘除(慢实例被自动摘掉,不需要客户端重试)

如果你选择”每个调用点都可以重试”,那就必须配端到端的尝试预算(在 trace context 里带 attempts_remaining)。两种都行,但不能两种都没有。

第 3 步:重试参数——一个可以直接抄的起点

(说明:以下是常见的起始值,必须按你的业务压测调整,不是标准答案。)

重试条件:    仅对可重试错误(连接失败、超时、503、429)+ 幂等接口
最大尝试次数:3 次(即最多 2 次重试)
退避:        指数退避,base = 50~100 ms,倍数 = 2,cap = 1~2 s
抖动:        Full Jitter,sleep = random(0, min(cap, base × 2^n))
重试目标:    必须换一个后端实例(避开刚失败的)
重试预算:    重试请求 / 总请求 ≤ 10%

配套的四个必选项:

  1. 只对幂等接口重试。 非幂等的写操作,要么先做幂等化,要么干脆不重试。
  2. 重试要换实例。 对同一个坏实例重试三次,除了浪费时间没有别的意义。
  3. 重试预算必须配。 没有预算的重试就是定时炸弹。
  4. 重试要和熔断联动。 熔断打开时一次都不许重试——下游已经在求救了,你还去敲门?

第 4 步:熔断——给下游喘息的时间

三态机:

  • Closed(闭合):正常放行,同时统计错误率
  • Open(打开):直接失败,完全不走网络,给下游恢复时间
  • Half-Open(半开):熔断时长到点后,放少量试探请求;成功则回到 Closed,失败则退回 Open

关键参数(同样是起点,需压测调整):

参数常见取值说明
触发错误率50%必须是比例,不是绝对数
最小请求数20~100样本太少不做判断(否则 2 个请求错 1 个就熔断,太敏感)
熔断时长10~30 s太短则下游还没恢复又被冲进去;太长则恢复慢
半开试探数3~5 个少量试探,成功即恢复
统计窗口滑动窗口 10s~1min用滑动窗口,不用固定窗口(固定窗口有边界抖动)

最容易踩的三个坑:

  1. 用错误数而不是错误率触发熔断。 低峰期 3 个请求错 1 个就熔断;高峰期 500 个错 300 个还不熔断。必须看比例。
  2. 熔断了但没有降级。 熔断只是快速失败,用户看到的还是错误。熔断必须配降级,否则你只是把”超时”换成了”立刻报错”,用户体感可能更差(虽然系统资源省下来了)。
  3. 熔断器粒度不对。 要按”依赖服务”粒度熔断,不是全局一个熔断器。A 服务挂了不该导致你不能调 B 服务。

第 5 步:降级——为每个弱依赖准备 fallback

对每个弱依赖,明确写出它挂掉时你怎么办:

降级手段适用场景例子
返回缓存(哪怕过期)数据可容忍短暂陈旧个性化推荐挂了 → 返回上次缓存的结果
返回默认值 / 兜底值有合理默认用户画像挂了 → 用”大众默认”画像
返回精简结果部分信息比没有强评价服务挂了 → 不显示评价,其余照常
功能开关关闭该功能可整体下线积分计算挂了 → 关掉积分展示,下单照常
异步化不需要同步结果通知/埋点挂了 → 写本地队列,稍后补发
读降级到备源有异构备份主搜索挂了 → 用数据库 LIKE 兜底

降级的三条铁律:

铁律一:降级路径自身必须比主路径更简单、依赖更少。 如果你”降级到缓存”,而缓存服务正好和主服务一起挂了,那你写的不是降级,是第二个故障点。降级路径要定期演练——它和容错路径一样,不演练就等于不存在。

铁律二:降级必须可观测、可告警、可人工干预。 静默降级是最可怕的:系统在降级状态跑了三个月,业务指标悄悄下滑,没人知道。必须有降级率指标、降级告警,以及手动开关(既能强制降级,也能强制关掉降级逻辑——因为降级逻辑本身也可能有 bug)。

铁律三:明确优先级——宁可降质量,不可不可用。 但要警惕”降级雪崩”:一个非核心功能降级后,导致依赖它的核心功能也降级,最后整个系统半死不活,比干脆挂掉更难排查。降级要有明确的边界和终止条件。

第 6 步:隔离(舱壁)——别让一个依赖拖垮全部

做法:给每个依赖分配独立的资源池。

  • 线程池隔离:每个依赖一个线程池。A 慢了只耗尽 A 的池子,调 B 的线程照样干活。代价:线程上下文切换开销(依赖一多,切换成本显著)。
  • 信号量隔离:每个依赖一个计数器,超过并发上限直接拒绝。代价:没有超时保护(信号量不占线程,无法中断等待),但开销极小。
  • 连接池隔离:每个下游独立连接池,防止一个慢下游占满所有连接。

怎么选?

场景推荐
高开销、需要超时可中断、依赖响应时间差异大线程池隔离
依赖响应极快、QPS 极高、不担心阻塞信号量隔离(开销小得多)
异步/响应式架构(CompletableFuture、协程、Netty)线程不再被阻塞,信号量和并发限制的性价比更高

还有一个必须做的:并发限制(concurrency limit)。 限制”同时在途的请求数”,而不是 QPS。为什么?

因为在途请求数才是队列长度的直接指标。按 Little’s Law:队列长度 = 到达率 × 响应时间。当响应时间变长时,固定的 QPS 限制会导致在途请求堆积 → 队列变长 → 延迟进一步上升 → 堆积更严重。而限制在途数能自适应地保护系统。(Netflix 的 concurrency-limits 库、以及 TCP 的拥塞控制,都是这个思路。)

第 7 步:限流——保护自己

  • 入口限流:保护自己不被上游打垮(令牌桶、漏桶、滑动窗口计数)
  • 出口限流(对下游的并发限制):既是保护下游,也是舱壁的一部分

一条重要认知:限流不是容错的替代品,它是容错的最后一道保险。 如果限流经常触发,说明容量规划或上游行为有问题——限流只是防止问题演变成雪崩。

第 8 步:验证——不演练的容错等于不存在

按子主题十一:

  • 为每个容错机制设计对应的混沌实验(熔断真生效吗?降级能返回吗?failover 真能切吗?重试预算真能挡住放大吗?幂等真能去重吗?)
  • 从预发开始,逐步进生产
  • 常态化、低烈度、有自动终止条件

完整配置清单(一张表带走)

机制作用保护对象关键参数最常见的错误
超时防止无限等待自己的资源(连接/线程)按预算从上往下分配每层设同样的值;无上下文传播
重试吸收瞬时故障用户体验次数、退避、抖动、预算、唯一重试层多层重试导致 R^L 放大;重试非幂等操作
熔断停止折磨故障下游下游 + 自己的资源错误率、最小样本、熔断时长用错误数而非错误率;熔断了没降级
降级保证基本可用业务连续性fallback 策略、开关降级路径自己也会挂;静默降级不告警
隔离限制故障影响面其他依赖/功能资源池大小、并发上限所有依赖共用一个池;按 QPS 而非在途数限流
限流防止雪崩自己阈值、拒绝策略阈值拍脑袋;无优先级(重要请求被一起拒)
对冲消除尾延迟延迟 P99τ 阈值、对冲预算用于”内容相关”的慢;系统满载时对冲
演练验证以上全部信心频率、爆炸半径、终止条件只做一次;没有稳态指标

反模式清单(照着检查你的系统)

  • 有任何调用没有设置超时(用了框架的默认无限等待)
  • 有任何非幂等的写操作在自动重试
  • 重试发生在多层,且没有任何全局的尝试预算
  • 重试了但没有抖动,或退避时间没有算进超时预算
  • 熔断用绝对错误数触发
  • 熔断了但没有降级,用户看到的还是错误
  • 降级逻辑依赖了可能一起挂掉的组件
  • 超时了但下游还在继续干活(无 context 传播)
  • 认为心跳正常 = 服务健康(没有 readiness 检查)
  • 所有下游共用一个线程池/连接池
  • 分布式锁/主节点没有 fencing token,靠客户端自觉
  • 容错机制从未被演练过

最后一句忠告:容错设计最难的部分不是配参数,是承认”这套机制我没验证过”。 上面这张清单,每一行都比调参重要。


本章思考题

【思考题】

  1. 为什么说”从调用方视角,崩溃和慢无法区分”?这个不可区分性导致了什么后果?

  2. 重试为什么会引发”重试风暴”?指数退避为什么必须配抖动?

  3. 什么是幂等?请给出至少三种实现幂等的手法。

  4. 超时时间怎么定比较合理?什么是”超时预算”?

  5. 失败检测器的”完整性”和”准确性”分别指什么?为什么在异步系统里无法同时保证两者?现实系统靠什么假设绕开这个不可能结果?Chandra & Toueg 的”◇“符号代表什么含义?

  6. fencing token 为什么必须在资源(存储)侧校验,而不能由持有锁的客户端自己判断?请描述”客户端 GC 暂停导致脑裂”的完整过程,并说明 fencing token 是在哪一步、以什么方式阻断它的。

  7. 用乐观锁版本号做幂等时,有一个”看起来完全正确、实则彻底失效”的写法。它长什么样?为什么会失效?为什么这种 bug 在测试环境几乎不可能被发现?

  8. 假设调用链有 5 层,每层最多 3 次尝试。最坏情况下最底层收到的请求数是原来的多少倍?请给出三种把重试放大控制住的方案,并说明服务网格(如 Istio)为什么反而容易放大这个问题。

  9. 对冲请求(hedged request)的代价和收益分别是什么?τ 取 P95 意味着什么?什么情况下对冲会完全无效甚至有害?它和”绑定等待(tied request)“的区别是什么?

  10. 什么是混沌工程?为什么说”没有验证过的容错机制就假定它是坏的”?为什么它最终必须在生产做,同时又必须从非生产环境起步?做生产实验必须遵守哪些铁律?

  11. Erlang 的 “let it crash” 凭什么能成立?它的三根支柱和三个隐含前提分别是什么?Java 和 Go 为什么不能直接照抄?现代云原生系统是怎么把”防御式”和”let it crash”结合起来的?

  12. 给一个服务设计容错方案时,超时为什么要”从上往下分配预算”而不是每层各定一个值?重试次数、单次超时、累计退避时间三者之间要满足什么不等式?为什么”并发限制”通常比”QPS 限制”更适合保护系统?

【参考答案】

  1. 因为调用方只知道”到时间没收到响应”,无法区分对方是崩溃、处理慢、响应丢失、还是请求没送达——四种情况对外部观察者完全一致。后果:任何失败检测器都无法 100% 准确,只能在误报(把活的判死,导致不必要 failover)和漏报(把死的判活,服务持续不可用)之间权衡,且不可能同时消除两者。

  2. 重试风暴:服务因负载高变慢 → 调用方超时重试 → 请求量翻倍 → 服务更慢 → 触发更多重试,形成正反馈,把健康服务打死。指数退避必须配抖动:如果 1 万个客户端同时失败、同时退避相同时间、同时重试,会形成”同步风暴”在同一瞬间冲击服务器;加随机抖动把重试在时间上打散,这是必做的一步。

  3. 幂等:执行一次和执行多次对系统状态影响相同。实现手法:唯一请求 ID + 去重表(记录已处理 ID,重复请求返回上次结果);状态机检查(如订单状态流转,非目标状态直接返回成功);乐观锁/版本号(UPDATE … WHERE version=5,影响 0 行即已处理);一次性 Token(申请-校验-作废)。

  4. 固定超时难调准(延迟随负载变化),更好的是自适应超时(按历史 P99 + 余量动态计算)。超时预算:给整条调用链分配总超时,每层的超时 = 剩余预算 / 剩余层数,越往下游越短;避免”每层都设 1 秒导致总超时 5 秒”的错误。另外超时要配上下文传播,上游取消时下游立即停止,不浪费资源。

  5. 完整性:每个真正崩溃的进程最终都会被(全部/某些)存活进程怀疑,即不漏报;准确性:任何存活的进程都不会被怀疑,即不误报。无法同时保证:异步系统中消息延迟无上界,节点收不到心跳时无法区分”对方崩了”和”对方只是慢”——若继续等待,则在真崩溃的情况下永远等不到(违反完整性);若判定死亡,则可能在慢的情况下误杀(违反准确性)。现实系统靠”部分同步”假设绕开:系统在大部分时间(GST 之后)延迟有界,因此每一个超时值本质上都是对”当前处于稳定期、延迟不会超过 X”的一次下注。Chandra & Toueg 的”◇“(diamond)表示”最终”(eventually):允许暂时误报,但存在一个时刻之后不再误报任何正确进程;◇P(最终完美)是能用来解决共识的最弱失败检测器类别之一。

  6. 因为客户端不可靠——不是因为恶意,而是因为客户端会停顿:GC、IO 阻塞、虚拟机迁移、SIGSTOP、时钟跳变,任何一种都能让”我的租约还有效”这个本地判断,在真正发出请求的那一刻变成谎言。脑裂过程:主节点 M 发生超长 GC 暂停 → 集群收不到心跳,判定 M 死亡并选出新主 M2,M2 开始写入 → M 醒来,它完全不知道自己被判死(在它视角里只是”卡了一下”),继续以”主”的身份写入 → 两个主同时写,数据损坏。fencing token 的阻断方式:每次授予/续约租约时颁发严格单调递增的 token(33→34→35),客户端的每个写请求都带上自己的 token,存储层记住自己见过的最大 token,并拒绝任何更小的 token;M 带 33 来写,而存储层已见过 34 → 拒绝。关键点在于:检查发生在存储层”真正执行写入的那一瞬间”,且是原子的”比较并拒绝”;如果只由客户端自行检查,那么”检查完到真正写入之间”仍然存在停顿窗口,防线无效。

  7. 失效写法是”在重试循环内部重新读取版本号”:

for attempt in range(3):
    row = SELECT ... WHERE id=1                 # 每次重试都重新读!
    UPDATE ... WHERE id=1 AND version=row.version

第二次循环读到的是自己刚更新成的 version=6,于是条件成立,又扣了一次——重试彻底失去幂等保护。正确做法是在循环外确定一次 expected_version,重试时始终用它;若影响 0 行,说明要么被别人改了、要么第一次其实已成功,需要查询后判断再返回。这种 bug 难被发现,是因为正常路径第一次就成功,只有”第一次执行成功但响应丢失”这条罕见路径才会触发——它是”错误路径的错误路径”,常规回归测试几乎覆盖不到。

  1. 最坏情况下是 R^L = 3^5 = 243 倍(注意是指数不是加法;5 层 × 4 次尝试就是 1024 倍)。控制方案:(a) 只在边界层(Gateway/BFF)重试,内部服务全部 fail-fast,重试次数严格等于 R;内部局部抖动靠”上游统一重试 + 负载均衡健康检查摘除慢实例”来兜底。(b) 每个调用点都可以重试,但在请求上下文里带端到端的 attempts_remaining,每次重试递减并向下传递,到 0 时任何层都不许再试。(c) 交给服务网格统一配置。服务网格反而容易放大问题:在 Istio 这类架构里,每一个 client-side sidecar 都可能对它自己的上游发起重试,A 的 sidecar 重试 3 次 × B 的 sidecar 重试 3 次 = 9 倍,本质上又成了多层重试——所以必须同时配置全局尝试上限和重试预算,否则只是把放大问题从代码挪到了配置文件里。

  2. 代价与收益:设 τ 为等待阈值,τ 取 P95 时约 5% 的请求会触发对冲,即额外负载约 5%;收益是拿到”两个独立样本取最小值”的延迟分布,两次都踩到长尾的概率远小于一次,用约 5% 的额外资源把 P99(甚至 P99.9)大幅砍下。关键性质:对冲的额外负载是由 τ 决定的常数,而重试负载在系统崩溃时可以趋于无穷——对冲的代价可预测,重试的代价是失控的指数。无效甚至有害的情况:当”慢”的原因是请求内容本身(大扫描、复杂聚合、数据倾斜)时,打到任何副本都一样慢,延迟一点没降、负载翻倍;判断标准是”同一请求打到不同副本,延迟是否独立”,可用”触发对冲的请求里,对冲请求是否真的比原请求快”来检验。此外系统必须有余量,满载时对冲会制造它试图消除的长尾。与绑定等待的区别:对冲是等 τ 后再发第二个,且被取消的请求往往仍在下游继续执行(重复执行);tied request 是同时发两个、且每个请求都带上对方的地址,谁先开始执行就主动给对方发取消消息,把重复执行窗口压缩到”两个副本开始执行的时差”,代价是需要服务端支持取消语义。

  3. 混沌工程是在系统上进行实验的学科,目的是建立对系统抵御生产环境中动荡条件能力的信心;它是”有假设的科学实验”,不是随机搞破坏。“没验证过的容错机制就假定它是坏的”:正常路径每时每刻被生产流量验证,而容错路径(failover、降级、熔断)几乎从不执行,同时依赖地址、证书、配置、人员都在持续变化,两者可靠性的差距单调拉大且不可见——重大事故的标准叙事不是”我们没有容错机制”,而是”我们有,但它没生效”。最终必须在生产做,是因为预发环境缺少真实的流量模式、真实规模、真实依赖拓扑、真实数据分布,尤其无法触发真实的告警与 on-call 响应流程;但必须从非生产起步,因为实验本身也可能是错的(注入脚本有 bug、指标看错、回滚不成立),要先用非生产环境验证”实验是安全的”,再去生产验证”系统是可靠的”。生产实验铁律:有明确的稳态指标和可在实验前写下的可证伪假设;爆炸半径从小到大(一个实例 → 1% 实例 → 一个可用区,先读后写,先非高峰后高峰)且有自动终止条件;有经过演练的停止手段,且停止手段本身不能依赖可能被本次实验搞坏的东西。此外要常态化(持续验证),因为一次性演练的结论会随架构演进而过期。

  4. 凭什么成立:崩溃 + 重启把你带回一个已知的、干净的初始状态,而原地打补丁会把你留在未知的可能已损坏的状态;且代码只需写 happy path,代码量小、bug 少、更容易推理。三根支柱:超轻量且不共享内存的进程(崩溃不污染别人);监督树(supervisor 只负责监视与重启,业务逻辑与错误处理关注点分离,策略有 one_for_one / one_for_all / rest_for_one);重启强度限制加逐级上报(偶发错误局部自愈,持续错误则层层升级而不是陷入无限重启循环)。三个隐含前提:重建成本极低、有隔离(不共享可变状态)、状态外置以便恢复后继续工作。Java/Go 不能照抄是因为前提不成立:Java 线程和 Go goroutine 都共享堆内存,崩溃可能留下半更新的共享对象,崩溃不干净(Go 更严重——未 recover 的 panic 会带走整个进程),且重建成本更高。判据是”崩溃代价 = 污染半径 × 重建成本”。现代云原生把两者分层结合:容器内部用防御式(共享内存,崩溃代价高),容器之间用超时/熔断/重试/降级(无共享内存,对方崩溃对你无害),实例级用 let it crash(无状态 + 编排系统自动恢复)——Kubernetes 事实上就是监督树的普及版(容器 = 隔离进程,kubelet = supervisor,restartPolicy + CrashLoopBackOff = 重启策略与重启强度);Akka 则是在 JVM 上靠”Actor 之间不共享可变状态”的约定复刻了监督树。

  5. 从上往下分配,是因为用户的 SLO 是最上游的硬约束,每层的超时必须是”剩余预算 − 本层处理时间 − 网络开销 − 重试余量”的减法结果;若每层各自定值,就会出现”每层 1 秒、5 层总超时 5 秒”的经典错误,且上游早已放弃、下游还在空转。不等式:单次超时 × 尝试次数 + 累计退避时间 ≤ 该层的超时预算(很多人漏算退避时间,于是加了重试之后 P99 反而变差)。并发限制优于 QPS 限制:按 Little’s Law,队列长度 = 到达率 × 响应时间,当响应时间变长时,固定的 QPS 限制会导致在途请求堆积 → 队列变长 → 延迟进一步上升 → 堆积更严重,形成正反馈;而”同时在途请求数”是队列长度的直接指标,限制它能让保护随下游变慢而自动收紧,是自适应的。