题干与适用场景
一个结账 API 会同步调用第三方税费服务。正常时下游 p50 为 80 毫秒、p99 为 180 毫秒,但会偶发连接失败、慢响应、HTTP 429、503,以及“请求可能已经到达服务端、响应却丢失”的情况。结账 API 的端到端目标为 800 毫秒;服务调用链可能有五层,多个 SDK 也可能已经内置重试。
请设计调用策略:怎样划分截止时间与单次超时,哪些失败可以重试,怎样设置最大尝试次数、指数退避和抖动,熔断器如何从关闭进入打开再恢复,以及怎样用隔离、降级、指标和故障注入证明方案不会把局部故障放大成级联故障。
题目中的延迟和层数都是面试假设。它考察的是同步服务间调用的失败语义、资源保护和恢复控制,因此归入 backend。订单接口幂等题重点解决一次写操作的数据库契约;本题只把幂等作为“超时后能否重试”的判断条件,核心是整条调用链的时间与负载预算。
面试官考察点
第一项信号是先给请求建立一份时间账本。端到端 800 毫秒不能等同于下游超时 800 毫秒;本地校验、排队、序列化和返回响应都需要预算。每次尝试还要覆盖连接、TLS 和读取阶段,并在开始下一次重试前检查剩余时间是否足够。
第二项信号是按失败语义决定是否重试。连接建立前失败、短暂 503 和带重试提示的 429 可能值得重试;参数、认证和权限错误不会因为等待而修复。写请求在发出后超时,结果可能是“已提交但响应未知”,只有稳定幂等键、状态查询或对账机制才能把盲目重试变成安全重试。
第三项信号是能算出重试放大。五层调用若每层都执行最多三次尝试,最底层可能收到 3^5 = 243 次调用。重试应集中在一个理解业务截止时间的层,并受尝试次数、累计时间和令牌式重试预算约束。
最后看恢复闭环。超时限制一次等待,重试处理短暂失败,熔断器在持续失败时快速拒绝,隔离限制并发和队列占用;它们职责不同。强回答还会约束半开探针、拒绝不真实的 fallback,并分别观察逻辑请求和实际尝试次数。
回答前需要澄清的问题
- 调用是读取还是有副作用的写入? 读取通常可重复;写入超时后结果可能未知,需要幂等键、查询状态或对账。
- 800 毫秒是硬截止时间还是观测目标? 本题把它当作调用方不再等待的截止时间,上游取消后下游也应尽快取消,避免完成无人需要的工作。
- 下游公开了哪些可重试错误? 应依据协议和供应商契约分类,尤其是 429 的
Retry-After、503、连接错误和业务错误,不能仅按“非 200”处理。 - SDK、代理和服务网格是否已经重试? 必须盘点所有层的默认行为与最大尝试次数,否则应用只重试一次也可能叠加成重试风暴。
- 下游的延迟分布和容量是多少? 超时从分位数、可接受的误超时率和网络余量推导,并在部署新连接、跨地域和峰值负载下验证。
- 失败时允许怎样降级? 商品税费若有合规要求,不能伪造一个“成功”的默认税额;可以明确失败、转人工或延后结账,但要由业务契约决定。
- 熔断状态按什么边界隔离? 应按独立供应商、地域、端点或操作设置,避免一个分片故障让所有健康资源一起被断开。
30 秒回答框架
“我先把 800 毫秒拆成端到端预算,并向下游传播绝对截止时间。每次调用都有连接和请求超时,下一次尝试只有在剩余预算容得下时才开始。错误按可恢复性和副作用是否已知分类:429 遵守服务端提示,短暂连接错误和选定 5xx 才重试;写入结果未知时必须有幂等键或查询对账。重试只放在一层,最多两次尝试,使用有上限的指数退避和抖动,再用令牌预算限制故障期额外流量。持续失败时熔断器打开并快速失败,冷却后只放少量半开探针;并发隔离和有界队列保护本地资源。最后用故障注入核对截止时间、实际尝试数、重复副作用和恢复过程。”
分步骤深入解答
先把时间变成可计算的预算。以本题为例,800 毫秒中预留 120 毫秒做调用前本地工作,100 毫秒做调用后的响应处理,留给税费依赖的总预算是 580 毫秒。一种可验证的初始配置是:首次尝试最多 220 毫秒,失败后等待 [0, 80) 毫秒的随机退避,第二次尝试最多 220 毫秒;最坏占用 520 毫秒,依赖预算还剩 60 毫秒。数字不是通用答案,生产值要从下游分位数、误超时容忍度、跨网延迟和压测中校准。
调用链传播绝对截止时间或单调递减的剩余预算。准备重试时重新计算 remaining,若它小于“下一次尝试上限 + 最小收尾时间”,直接返回,不启动注定来不及的调用。客户端要明确连接超时和请求超时的含义,并确认 DNS、TLS 与连接池等待是否包含在内;新实例可在接流量前预热连接,避免握手被误判为下游变慢。上游取消后应向下游传播取消,但仍要假设取消可能到得太晚。
再建立失败语义矩阵:
| 结果 | 是否重试 | 前提与动作 | |---|---|---| | 连接建立前失败 | 可以 | 剩余预算足够;使用退避与抖动 | | HTTP 429 | 有条件 | 遵守 Retry-After;等待后仍须能在截止时间内完成 | | HTTP 503 或选定 5xx | 有条件 | 供应商声明为暂时故障,且重试预算未耗尽 | | HTTP 400、401、403 | 通常不可以 | 修正参数、凭证或权限,原请求等待后不会成功 | | 已发送写请求后超时 | 不能盲目重试 | 使用同一幂等键,或先查询操作状态并对账 | | 上游已取消或截止时间已到 | 不可以 | 停止增加无效工作并返回明确失败 |
重试策略同时受三道门控制:错误可重试、剩余时间足够、重试预算还有令牌。退避采用有上限的指数增长,并加入随机抖动以打散同时失败的客户端;若服务端提供可用的重试时间,应优先遵守。最大尝试次数包含首次调用,本题先从 2 次尝试开始。读清 SDK 配置,因为 maxAttempts = 2 与“重试 2 次”可能分别表示总共 2 次和总共 3 次。
重试只放在一个最了解业务截止时间的层。若五层都尝试三次,最底层理论负载会被放大 243 倍。可用 downstreamattempts / logicalrequests 作为重试放大系数;正常时接近 1,故障时必须受预算上限约束。令牌桶或类似重试配额在失败增多时耗尽,随后暂停或以低速允许重试,避免所有客户端在下游最脆弱时继续加压。
熔断器包裹具体依赖操作。关闭状态允许调用,并在有最小样本量的滑动窗口内统计可归因的失败和慢调用;超过阈值后进入打开状态。打开状态不访问下游,立即返回可识别的失败或经过业务批准的降级。冷却期结束后进入半开,只允许少量并发探针;探针达到成功条件才关闭,任一关键失败则重新打开。阈值必须来自流量与恢复特征,不能把示例里的“20 个请求、50% 失败”当成所有服务的常数。
熔断器本身也有代价:状态模式会让测试和恢复更复杂,冷却过长会延迟恢复,边界过粗会误伤健康分片。因此要记录每次状态变更,限制半开探针并发,并按真实故障域分组。对于短暂、低风险故障,严格的超时、单层重试和重试配额可能已经足够;不应为了术语完整而强行增加熔断器。
本地资源还需要隔离。给税费服务设置独立并发上限、连接池和有界队列,防止慢调用占满结账服务的全部线程或连接。队列中的请求也消耗截止时间,出队时若已无法完成就丢弃。fallback 只返回业务上真实且可解释的结果:允许显示“暂时无法计算税费”或进入人工流程,不能把未知税费当作零并宣告结账成功。
验证要在故障条件下检查不变量。分别注入连接失败、250 毫秒慢响应、429 携带不同 Retry-After、503、不可重试 400,以及“写操作已提交但响应丢失”。断言单个逻辑请求最多产生 2 次下游尝试、总延迟不越过 800 毫秒、永久错误不重试、未知写结果不重复执行。再持续注入失败让熔断器打开,确认请求快速失败、半开只有有界探针、依赖恢复后能重新关闭,同时检查隔离池未耗尽。
线上指标要分清逻辑请求与尝试:端到端成功率和 p95/p99、每次尝试的结果与耗时、连接和请求阶段超时、重试放大系数、重试挽回率与新增延迟、重试预算余额、熔断状态与拒绝量、半开探针结果、并发池与队列深度,以及幂等冲突和对账结果。只看最终成功率会隐藏“成功是靠三倍下游流量换来的”。
高质量示范回答
“我会先确认这是同步税费查询,端到端调用方 800 毫秒后就不再等待。假设本地前置工作预留 120 毫秒、返回处理预留 100 毫秒,依赖预算是 580 毫秒。初始配置用两次尝试:首次和第二次各最多 220 毫秒,中间做 [0, 80) 毫秒抖动退避;每次开始前都检查传播下来的剩余截止时间,所以排队变长时不会机械地发第二次。
错误必须分类。连接建立前失败、供应商定义的暂时 503 可以在预算内重试;429 遵守 Retry-After,若等待后会越过截止时间就直接失败;400、401、403 不重试。若调用会写入,发出请求后的超时属于结果未知,必须复用幂等键,或者先查状态和对账,不能换一个新请求盲重试。
我会盘点 SDK、网关和服务网格的默认重试,把重试集中在一个能看见业务截止时间的层,并设置令牌式重试预算。五层各三次尝试会把底层调用放大到 243 倍,所以我要监控实际尝试数除以逻辑请求数。
持续失败时,按税费供应商和操作设置熔断器。关闭状态在有最小样本的窗口内计算失败率,打开后快速失败;冷却后半开只放少量探针,成功才恢复。税费依赖使用独立并发池和有界队列,降级只能返回业务认可的明确状态,不能伪造零税费。
最后我会做故障注入,验证最大两次尝试、800 毫秒边界、不重试永久错误、没有重复写入、熔断能打开且靠有界探针恢复。线上同时看逻辑成功率、尝试级结果、重试放大、预算余额、熔断状态和隔离池饱和度。”
常见错误
- 把下游超时直接设为 800 毫秒 → 本地收尾没有时间,上游放弃后下游仍占资源 → 从端到端截止时间扣除本地与网络预算,并传播剩余时间。
- 所有非成功响应都重试 → 参数和权限错误不会自愈,只会增加负载与延迟 → 按协议、错误码和副作用结果分类。
- 每层都打开三次尝试 → 五层调用可把底层放大到 243 次 → 只在一个合适层重试,并监控放大系数。
- 写请求超时后换新 ID 重发 → 第一次可能已提交,导致重复扣费或创建 → 复用幂等键,或查询状态与对账。
- 只有指数退避,没有抖动和次数上限 → 客户端仍可能在相同时间成批重试 → 使用有上限的指数退避、随机抖动和累计时间上限。
- 把熔断器当成超时替代品 → 已经发出的慢调用仍会占用线程、连接和队列 → 每次尝试保留超时,同时做并发隔离。
- 半开后立即放开全部流量 → 刚恢复的依赖再次被压垮 → 只允许有界探针,并用明确成功条件恢复。
- 一个全局熔断器覆盖所有供应商与端点 → 局部故障会阻断健康资源 → 按独立故障域划分熔断状态。
- fallback 始终返回成功 → 把未知结果伪装成正确数据,破坏业务语义 → 只使用经业务批准、可说明限制的降级。
- 只看最终成功率 → 重试风暴可能暂时掩盖下游恶化 → 同时看逻辑请求、实际尝试、额外延迟和资源饱和度。
追问及应对
追问一:超时应该设置成下游 p99 的多少倍?
没有固定倍数。先选可接受的误超时率,从对应延迟分位数出发,再加入网络、跨地域、连接建立和小幅抖动余量;同时受上游截止时间约束。若 p99 与 p50 很接近,小幅延迟变化就可能触发大量超时,需要额外 padding。上线前还要验证 DNS、TLS、连接池等待是否包含在计时范围内。
追问二:429 和 503 都可以重试吗?
都只能有条件重试。429 应优先遵守 Retry-After,但等待与下一次尝试必须放得进剩余预算;否则直接把可识别失败返回上层。503 只有在供应商契约声明为暂时故障时才进入退避。两者都要消耗重试预算,不能把服务端已经过载的信号转化成更多即时流量。
追问三:为什么重试挽回率很高,系统还是可能不健康?
因为最终成功可能由额外流量和额外延迟换来。若 100 个逻辑请求产生 180 次下游尝试,放大系数是 1.8;下游已接近容量时,这 80% 的附加流量会延迟恢复。把重试挽回率与放大系数、尝试延迟、预算耗尽率和下游饱和度一起看,才能判断收益是否值得。
追问四:熔断器打开后,还需要限流或隔离吗?
需要。熔断器只阻止特定依赖的新调用;本地可能仍有已经在途或排队的请求,其他依赖也可能继续占资源。并发上限、独立连接池和有界队列控制资源所有权,入口限流控制新负载。恢复时,半开探针还需要独立小额度,不能与正常流量共享无限容量。
追问五:多个实例各自维护熔断状态,会不会不一致?
会,但共享强一致状态也会增加同步依赖和延迟。常见做法是每个实例用相同配置维护本地状态,让故障信号自然扩散;高流量时各实例都有足够样本。若实例流量很低或故障域需要全局协调,可由服务网格或集中层执行。无论哪种方案,都要说明状态范围、样本量和故障时的最大额外流量。
追问六:什么时候应该改成异步队列?
如果业务不要求在本次 HTTP 响应里得到税费结果,或下游恢复时间可能远超用户截止时间,异步化更合适。请求先持久化任务并返回可查询状态,消费者按独立重试与死信策略处理。异步队列没有消除幂等、过期和降级问题,但能把长时间恢复从同步连接和 800 毫秒预算中移走。
追问七:怎样上线而不因配置错误制造事故?
先只记录拟执行的超时、重试和熔断决策,不实际增加重试或拒绝流量;核对 SDK 是否已有隐藏尝试。随后按供应商或少量实例灰度,限制全局重试预算,并保留快速关闭开关。重点观察尝试量、端到端尾延迟、熔断打开时长、半开失败和下游容量,再逐步扩大。