增强 B · 韧性工程(Circuit Breaker + Retry + 降级链)—— 开发文档(面试向)#
这篇讲为什么这么做、做了哪些取舍,不讲代码。读完能用大白话复述。 对应
docs/BACKEND_ENHANCEMENT.md的 B 块(后端增强第一波 P0)。
一句话概括#
给外部依赖(精排服务、公网搜索这类「会挂的别人家服务」)装上断路器和智能重试:偶尔抖一下就自动重试一次救回来;真挂了就「熔断」——记住它在挂,接下来一段时间直接走备用方案,不再每次都傻等到超时。配上项目里本来就有的降级兜底,做到「部分依赖挂了,整个系统还能给出有意义的结果」。
打个比方:原来是每次按门铃都站门口等 10 秒没人应才走人;现在是连按几次没人应,就记住「这家没人」,之后直接绕路走备用门,过会儿再回来试一次正门。
1. 背景:原来缺哪一环#
先说清家底——这个项目的降级兜底其实大部分已经有了:精排服务挂了会退回本地打分、公网搜索挂了会返回一句「拿不到」、向量编码挂了会退回本地编码。这点比很多 demo 强。
但有两个洞:
- 挂了之后每次还要干等超时。比如精排服务真宕机了,没有断路器时,每一次调用都还要傻等满 10 秒超时、确认失败、才走本地兜底。十几个并发请求就是十几次 ×10 秒的白白等待,延迟和连接全被拖死。系统「知道」它会失败,却每次都要重新证明一遍。
- 该重试的没重试。网络偶尔抖一下(一个数据包丢了、连接被重置),其实再试一次大概率就成了。但精排、公网搜索这两个外呼一次失败就直接降级,没给它们「再试一下」的机会——白白把本可救回的请求降级了。
B 块补的就是这两环:断路器(解决「每次干等」)+ 退避重试(解决「该试没试」)。
2. 断路器:连续失败就「快速失败」#
断路器是个三态的小开关,套在外呼前面:
- 关合(正常):正常放行。连续失败累计到阈值(默认 5 次)→ 跳到「断开」。
- 断开(熔断):不再发起真实调用,直接抛一个「我现在拒绝」的信号,让调用方立刻走备用方案。这就是省掉「每次干等超时」的关键。过了恢复窗口(默认 30 秒)→ 跳到「半开」。
- 半开(探测):放一个请求出去试探。成功 → 回到「关合」(服务恢复了);失败 → 退回「断开」(继续晾着)。
一个值得讲的取舍:为什么用「连续失败计数」而不是「滑动窗口失败率」#
教科书和我最初的方案里,断路器常用「最近 N 次里失败率超过 X% 就熔断」。但我故意没这么做,改用更简单的「连续失败 N 次就熔断」。
原因是这些外呼调用频率很低——一次购物查询也就调几次精排。频率一低,「最近 N 次」里样本太少,失败率会被一两次偶发抖动放大得离谱(比如最近就 3 次调用、挂了 1 次,失败率瞬间 33%,看着吓人其实只是抖了一下)。而「连续失败」要求接连失败才熔断,单次抖动会被中间的成功打断、清零,不会误伤。
这又是那条主线:知道默认方案(失败率)不匹配场景(低频调用),就别硬套——和 C 块「任务级背压不硬用 Semaphore」是同一种判断。
3. 退避重试:只重试「该重试的」#
重试不能无脑重试。我把失败分成两类:
- 可重试:网络超时、连接被拒/重置、服务端 5xx(临时抽风)——再试一次大概率就好。
- 不可重试:4xx(参数或鉴权错,重试还是错)、返回数据格式错(数据问题,重试无意义)、代理/协议配置错(配置不改重试一万次也错)。
只对第一类退避重试。对第二类盲目重试,只会拖长「注定失败」的等待、还放大下游压力。
两个细节:
- 退避带「抖动」(jitter):重试不是固定隔 1 秒、2 秒,而是加一点随机。因为多个请求往往同时失败,如果都「齐步走」按同样节奏重试,会再次同时砸向下游(专业叫「惊群」)。加随机抖动把它们错开。
- 重试包在断路器里面:顺序是「断路器 → 重试 → 真实调用」。这样断路器统计的是「连重试都救不回的最终失败」——偶发抖动被重试消化掉、不算进熔断账上,只有真·持续故障才推动熔断。
4. 怎么和现有的降级兜底接起来(改动小是优点)#
这是设计上最省力也最干净的一点:断路器熔断时抛出的「拒绝」信号,在语义上就等于「这次外呼失败了」——所以我把它直接塞进各外呼本来就有的 except(失败处理)分支里。
以精排为例:它原来就是「试远程,挂了就 except 里走本地打分」。我只是把远程调用用断路器包了一层,并在 except 能捕获的失败类型里加上「断路器拒绝」。于是熔断期间,断路器一抛拒绝,代码自然落进那条早就写好的本地兜底,省掉 10 秒干等。降级逻辑一行没改,只是让它触发得更快。
顺手还修了个小瑕疵:公网搜索原来异常降级时忘了打「降级」标记,前端和监控看不出这次结果是兜底来的。现在统一标上了。这正是 B 块的真亮点——降级信息要让上层看得见、能参与决策:主流程在反思阶段看到「这个平台已降级」,可以决定换个策略(比如不再去搜它)。降级不是默默吞掉异常,而是「带着标记的结果」。
5. 范围取舍:这批做透两个,另两个诚实留待#
项目有 4 个真实外呼,我没有一次全铺,而是「做透 1~2 个 > 铺一堆半成品」:
| 外呼 | 这批怎么处理 | 为什么 |
|---|---|---|
| 精排(reranker) | ✅ 断路器 + 重试 | 无重试、降级现成,集成最干净,当样板 |
| 公网搜索(web_search) | ✅ 断路器 + 重试 | 同上,且顺手修了降级漏标 |
| 向量编码(towers) | ⏸ 本批不动 | 它已经有手写的 3 次退避重试 + 本地回退,韧性本来就高,加断路器边际收益小、还可能动到召回主链路,风险不划算 |
| 大模型(LLM) | ⏸ 本批不动 | 调用发生在 LangGraph 框架内部,断路器很难干净地包进去;且 SDK 自带 2 次重试、主流程还有整体超时兜底。强行包风险高 |
这种「讲得出为什么先不做」的诚实边界,比硬凑「四个全做了」更能体现判断力。
6. 踩过的坑#
- tenacity 的动态调用方式:tenacity 最常见的是当装饰器
@retry贴在函数上。但我要的是「运行时包任意一个调用」,得用它的AsyncRetrying对象、而不是retry装饰器——一开始用错,报「不能 await」。 - 差点漏了「连接错误」:第一版重试白名单只放了「超时」和「5xx」,漏了「连接被拒/重置」这类——而这恰恰是最常见的瞬时故障。代码 review 抓到了:文档里写了「连接重置可重试」,代码却没做。补上后还特意加了测试,并且白名单是明确列举而非宽泛地「所有传输错误」——因为传输错误里还混着「代理配置错」这种重试也没用的,不能一锅端。
7. 后续复用:断路器成了基础设施原语#
B 块做好之后,断路器成了项目里可复用的基础设施原语——后续新增外呼时直接套用,不重新设计。最典型的复用是 ENH-D 事件回放的 Redis 操作:event_log 的每次 Redis XADD / XRANGE 都包了一个独立的断路器实例(CircuitBreaker("event_log", ...))。Redis 没配/挂了时,断路器快速熔断 → 事件持久化静默降级为无回放(新事件靠 WS 直播照常推、只是断线补发不可用),避免每次写事件都干等 Redis 连接超时拖慢主链路。
断路器的自注册表(创建时自动 _BREAKERS[name] = self)让 A 块的 Prometheus 指标能在 /metrics 抓取时遍历所有断路器读状态——新加一个断路器,metrics 自动看到它,不用手动注册。这是「用数据结构选型解决可维护性」的一个实例。
8. 面试可讲点小结#
- 「先认清家底再补」:能说清「降级兜底项目本来就有,我补的是断路器(省干等)和重试(救抖动)这两环」,而不是把已有的也吹成自己做的。
- 「连续失败 vs 失败率」的取舍:低频外呼用连续失败计数更稳,能讲出滑动窗口失败率在小样本下为什么失真。
- 「断路器 + 重试的嵌套顺序」:重试在内、断路器在外,让偶发抖动不污染熔断统计。
- 「降级信息参与决策」:降级不是静默吞异常,而是带标记的结构化结果,上层反思阶段能据此换策略——比一个
try/except高一个层次。 - 「白名单要精确」:重试白名单明确列举而非宽纳传输错误,能讲出「为什么不能把代理/协议配置错也重试」。
- 诚实边界:towers 已有韧性故不动、LLM 在框架内难包故留待——讲得出为什么暂不做。