Hacker News 中文摘要

RSS订阅

Uber 如何抵御重试风暴 -- How Uber Protects Against Retry Storms

重试风暴历来影响业务运营和品牌信任。重试配置调优和重试预算虽能在服务层面缓解,但需手动配置,缺乏对深层依赖链和扇出模式造成的跨服务放大的可见性,难以挡住堆栈深处单个服务故障引发的多米诺效应。重试行为不具备上下文感知:能控制重试次数,却无法精确控制何时重试,难以区分服务自身错误和仅经其传播的错误。

正文

重试风暴历来会影响业务运营和品牌信任。在服务层面调优重试配置、设置重试预算虽然能带来一定缓解,但这些配置靠人工完成,也看不到深层依赖链和扇出模式造成的跨服务放大效应。因此,当技术栈深处某个服务宕机引发多米诺效应时,基础设施很难独善其身。

一个关键原因是,今天的重试行为并不感知上下文。我们能控制重试多少次,却无法精确控制何时重试。根源在于,很难可靠区分错误是由某个服务产生的,还是仅仅经由它传递的。

结果就是,重试被统一应用,而非按条件应用。

这种方式对短暂或低频故障有效。但在中度或严重降级期间,它反而适得其反:对已经吃力的服务激进重试会加重负载、加速故障,并把重试流量放大到上游依赖。起初的局部宕机可能迅速升级为全栈事故,最终降低甚至彻底破坏终端用户体验。

有人会说,可以把下游服务的错误码翻译后向上传递,为上游重试提供上下文。理论上可行,但在 Uber 无法规模化落地:扇入扇出规模庞大、调用流不断演进、需要频繁自适应调整。因此我们在共享基础设施中开发了一套上下文感知机制来更高效地处理错误。

背景

考虑图 1 中的简单调用链,到达节点 A 的请求总数为 Ƞ。由此推导,在稳态(无节点出错)下,B、C、D、E、F、G 各节点都服务 Ƞ 个请求。

图 1:七个蓝色圆圈 A 到 G 由向右箭头连成一条直线。

如果服务 D 开始出错,且每个服务都配置为重试一次(一次常规尝试,失败后再试一次),看各节点服务的请求总数会变成多少。

图 2:一排带箭头的圆圈 A 到 G,D 被标红。

| 节点 | A | B | C | D | E | F | G | | --- | --- | --- | --- | --- | --- | --- | --- | | 深度 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | | 服务请求数 | Ƞ | 2 × Ƞ | 4 × Ƞ | 8 × Ƞ | 8 × Ƞ | 8 × Ƞ | 8 × Ƞ |

假设每一跳的重试次数 R 相同,可以归纳成一个简单公式。ɗ 表示节点在调用链中的深度,若节点产生或传递了错误,则其服务的请求数为:

Rɗ × Ƞ

重试预算

引入重试预算可以优化这一点。假设每一跳的重试预算相同,记为 B,则新公式为:

(1+B)ɗ × Ƞ

现在看重试预算为 10% 时各节点服务的请求数:

| 节点 | A | B | C | D | E | F | G | | --- | --- | --- | --- | --- | --- | --- | --- | | 深度 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | | 服务请求数 | Ƞ | 1.1 × Ƞ | 1.21 × Ƞ | 1.33 × Ƞ | 1.33 × Ƞ | 1.33 × Ƞ | 1.33 × Ƞ |

上例中错误源自节点 D。如果只允许 D 与 C 之间重试,完全禁止 A 和 B 针对该错误重试,就能在不让 D、E、F、G 过载的情况下,保证调用路径的可用性大致相当。

错误归属

沿用重试预算的例子,但把重试限制在错误来源边 C 到 D 之间:

| 节点 | A | B | C | D | E | F | G | | --- | --- | --- | --- | --- | --- | --- | --- | | 深度 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | | 服务请求数 | Ƞ | Ƞ | Ƞ | 1.1 × Ƞ | 1.1 × Ƞ | 1.1 × Ƞ | 1.1 × Ƞ |

这样就把从 D 到叶子节点 G 的所有节点服务请求总数压到仅比基线高 10%,同时允许在错误首次返回时为最多 10% 的错误至少重试一次。但节点 C 看到的节点 D 可用性如何?针对不同的可用性场景算一下重试后的可用性:

| 基础可用性 % | 基础错误率 % | 重试预算 | 重试后错误率 % | 重试后可用性 % | | --- | --- | --- | --- | --- | | 99.9 | 0.1 | 10% | 0.0001 | 99.9999 | | 99 | 1 | 10% | 0.01 | 99.99 | | 95 | 5 | 10% | 0.25 | 99.75 | | 90 | 10 | 10% | 1 | 99 | | 80 | 20 | 10% | 12 | 88 | | 70 | 30 | 10% | 23 | 77 |

如表所示,当被调方可用性下降不超过 10% 时,哪怕只重试一次,也能把调用方感知到的可用性拉高到 99%。超过这个区间,由于重试预算的存在,大量请求根本不会被重试,感知可用性会明显下滑。

该计算假设被调方的错误相互独立,且重试能带来恢复。但在服务过载、数据库主机异常、数据库过载、分片问题等许多真实场景中,即便重试,再次失败的概率依然很高。

这就动摇了"重试总能提升调用方感知可用性"的看法。也正是这种直觉构成了错误归属的基础:当某个服务错误率很高时,错误不太可能是随机分布的,增加重试次数不会带来好处,反而可能加剧降级。

架构

解法是建立错误归属,可以用症状与病因的类比来理解。

如果一个服务为完成请求调用 N 个出站,某个出站出错导致它返回错误,那么该服务返回的错误只是症状;与此同时,该服务自身的病因是来自下游的传入错误。

反过来,如果该服务在完成请求过程中没有任何出站出错,它仍然返回错误,那么该服务就是返回错误的病因,也就是错误的归属者。

简单关联

声明错误归属

我们使用服务依赖分析方案把入站失败与出站失败关联起来,并依据图 3 中的规则集来决定声明或否认错误。

图 3:服务 S 处理错误的流程图,根据下游调用结果说明声明与取消声明的逻辑。

带错误归属的重试

调用方使用图 4 的逻辑决定是否重试请求。

图 4:根据 x-uber-error-claim 头是否存在及其取值处理下游错误响应的流程图。

缺少错误声明头属于不配合的情况,原因可能是下游服务没有部署服务依赖分析方案,无法关联出站与入站错误;也可能是下游服务的上下文传播缺失,导致出站与入站错误关联不完整。

此时,第一个发现下游缺少错误声明的节点会取消声明该错误,把重试扰动的影响半径限制住(不再形成风暴),同时仍对返回错误的服务保留足够的重试。

决策矩阵

| 被调方错误 | 调用方错误 | 被调方错误声明 | 调用方是否应重试(重试中间件) | 调用方传递的错误声明 | | --- | --- | --- | --- | --- | | 否 | 是 | 不适用 | 不适用 | 声明 | | 是 | 是 | 缺失 | 是 | 取消声明 | | 是 | 是 | 已声明 | 是 | 取消声明 | | 是 | 是 | 未声明 | 否 | 取消声明 |

图 5:三个粉色圆圈 A、B、C,箭头由 A 指向 B 再指向 C。

当边 A → B 和 B → C 都是 fail-close,且节点 C 返回一个它已声明的内部错误时,节点 B 看到 C 的已声明错误后应向 C 重试。如果重试失败,它会把错误传给节点 A,但在返回错误时必须取消声明。节点 A 看到 B 的错误和未声明错误头后,不应向 B 重试请求。

图 6:在 fail-close 边下节点 A、B、C 之间的错误传播与重试逻辑流程图。

巧合错误与为什么需要服务依赖分析

上面的决策矩阵覆盖了下游调用失败或出现内部服务器错误的情况,但也可能两者同时发生,如图 7 所示。

图 7:节点 A 通过箭头与节点 B、C 相连的简单有向图。

这个例子里,节点 A 由于一个未被服务依赖分析方案跟踪的过载缓存/数据库,也在经历 10% 的错误率。这构成了节点 A 的内部服务器错误,而节点 B 和 C 也因无关原因有 10% 的错误率。

服务依赖分析方案会优先把错误归因于下游、最后才归因于自身。因此即使 B 和 C 是 fail-open,当事务并发时它仍会把责任归给它们。这会取消错误声明,阻止节点 A 的上游向 A 重试、从而可能恢复。

这种情况下错过合法重试机会的影响很大。如果节点 A 服务 100 个请求,其中 10 个是源自 A 自身的内部服务器错误,那么这 10 个里会有 1.9 个被 A 错误地取消声明(当作非自身错误),从而错过合法重试。

基于 6 个月的服务依赖分析数据,诸如 fail-open 依赖错误期间恰好发生合法的服务器错误或 fail-close 依赖错误这类巧合错误极为罕见。最坏情况下,对于一分钟内被调方失败超过 100 次的边,约 2% 的情况调用方也失败了(巧合失败)。按上面的例子,10 个请求里约有 0.396 个会被 A 错误取消声明。

不过即便按最坏情况,由于服务依赖分析方案会形成失败模式记忆,它可以利用这份记忆,只在入站失败与 fail-close 依赖的出站失败相关联时才取消声明错误。这消除了巧合错误期间抑制重试的风险。

图 8:处理节点错误、根据依赖失败关联决定取消声明还是保留重试的流程图。

边缘场景

保证至少重试一次

许多服务没有为其 fail-close 出站配置重试。如果消除这些服务调用方的重试,会导致上游调用链的可用性下滑。

解决办法是引入一个标志,表示下游返回的错误是否满足重试条件。重试中间件看到下游错误时,可以判断重试条件是否满足,并把结果传给服务依赖分析方案。

图 9:跨四个服务组件的出站调用链、重试逻辑与错误处理时序图。

只要满足图 10 中任一条件,重试中间件就把重试条件标记为满足。

图 10:判断重试条件是否满足的重试中间件逻辑流程图。

服务依赖分析方案利用重试中间件给出的这个信号,在入站侧决定是否需要拥有 fail-close 下游返回的错误。

图 11:处理服务错误、包含 fail-close 失败与重试条件决策点的流程图。

端到端流程如下:

图 12:上游 A、服务 B、SDA@B、RetryMW@B、下游 C 之间的错误处理与重试逻辑时序图。

我们不会在调用链上引入任何新重试。至少重试一次的行为仅在调用链中至少有一个服务配置了重试时生效。

通过引入至少重试一次的保证,我们消除了调用链中的可用性下滑,同时借助错误归属防止了重试风暴。

上下文丢失处理

图 13:四个粉色圆圈 A、B、C、D 由向右箭头线性连接。

对于服务依赖分析方案和错误归属来说,服务内上下文丢失会使可重试错误左移。以图 14 为例,节点 D 向节点 C 返回内部错误。节点 C 的服务内上下文断裂,服务依赖分析方案无法把 C 到 D 的出站请求与 B 到 C 的入站请求关联起来。

结果,节点 C 声明该错误,而重试中间件中解耦的重试继续在 C 与 D 之间发生。B 与 C 之间也会发生一组重试,因为 C 必须声明它返回的错误。当重试失败、节点 B 向 A 返回错误时,它同时返回未声明错误头,这会阻止节点 A 向 B 重试。假设所有节点都是单次重试配置(1 次尝试 + 1 次重试),这种情况下 B 到 D 的请求总数会减半。如果 A 左边还有 5 个节点,没有错误归属传播时最坏情况会多出 32 倍的请求。

图 14:跨越四个节点、包含上下文丢失与声明处理的错误传播与重试逻辑时序图。

生产环境中的错误归属

以上并非纸上谈兵——错误归属已在 Uber 的服务网格中全面实施并运行。该方案运行在重试中间件和服务依赖分析方案中,二者位于面向用户 API 的请求路径上,随着流量流经深层依赖链持续声明和取消声明错误。由于嵌入在共享基础设施中,各服务无需编写专门的服务级错误处理逻辑即可继承重试风暴防护。下面这起真实事故展示了这套生产部署在真正的大规模降级中的表现。

Uber 的应用案例

2025 年 11 月 18 日,Uber 因核心实体服务的问题发生了一次重大宕机。该服务位于调用链 5 层以上深处,对业务运营至关重要。由于底层基础设施问题,它开始返回极高的错误率,错误迅速沿调用链向上传播。在此期间,许多上游调用方服务有足够机会重试这些服务返回的失败请求。若只靠简单的重试预算,会使已经降级的服务流量增加 46% 至 135%,降低恢复概率、延长宕机。由于错误归属此前已在生产环境启用,系统自动控制住了爆炸半径。

对降级服务的直接调用方,立即重试被阻止,一些调用方因此少发出了多达 20 万次额外请求。我们还计算了这些直接调用方的祖先节点上被阻止的重试,并汇总到根节点,发现在服务网格中阻止了多达 950 万次无谓请求。这些请求本会持续冲击降级服务,轻易延长宕机。

结论

这套方案大幅降低了降级事件期间流经调用图的请求总量。

为了量化,我们定义启用错误归属后的最大重试风暴半径,即启用错误归属后仍可能发生重试风暴的调用路径最大深度,对每个根节点的调用图分别计算。在所有面向用户的 API 中,该值的最大值已降到 3,而此前最大重试风暴半径最高可达 25;平均值也从 20 降到 2。

图 15:列出边缘网关服务、端点及其最大重试风暴半径的表格,底部有分页。

通过把重试严格限制在真正拥有错误、能够解决问题的服务上,我们避免了重试风暴特有的请求指数级扇出,从而保护 Uber 基础设施免受单点降级引发的级联故障。

评论

社区主要探讨分布式系统中重试位置的取舍,文章提倡的底层重试加错误预算获部分人认可但被疑协调成本,少数人主张简单限流或下游自治。

认为应在故障点附近重试并引入错误预算

  • 调用链中仅允许靠近故障的服务重试,上游避免重做整链,结合错误预算思路可兼顾稳健与效率(较多人)。 > 若末端故障,仅相邻上游重试而非全链重放,这种错误预算思路十分巧妙(@prologic)

主张下游服务自管重试并快速失败

  • 让下游团队自主拥有重试逻辑,请求过载时立即报错,各团队依预算自适应,规避跨服务协调开销(少数人)。 > 替代做法是下游自己负责重试,过多请求就尽快错误返回,各团队独立调节(@siscia)

建议以令牌桶或负载丢弃等简单限流解决

  • 采用令牌桶算法或调用方指数退避配合负载丢弃,可作为避免重试风暴的轻量起点(少数人)。 > 只需令牌桶便足够应对(@whoevercares)

吐槽反机器人验证页诱发刷新冲动

  • 用户遇到“请等待勿刷新”提示时反而快速刷新,戏称或许那是人机验证环节(少数人,玩笑情绪)。 > 谷歌总让我等别刷新,我却偏要狂按刷新,或许那是人机测试(@Scoundreller)

有价值的补充

  • 有评论类比指出,文中流控思路类似于重新发明光纤通道的流控机制(@UltraSane)