Appearance
分布式系统中的超时与重试:如何避免放大故障
修订于 2026-10-12:原文的“双十一事故”、生产环境实测数字和作者亲历说法没有可核查材料,现已删除。下文的场景均为设计示例。
一个写入请求超时后,客户端无法仅凭超时判断服务端是否已经完成写入。直接重发可能造成重复扣款或重复创建资源。另一个常见问题是:下游变慢时,所有客户端同时重试,反而给下游增加负载。因此,超时、重试和幂等必须一起设计。
先确定一次请求的时间边界
- 连接超时限制建立连接所等待的时间。
- 请求或读超时限制客户端等待响应的时间;不同语言和客户端库的计时起点、重置方式可能不同,不能把某一种实现当作通用语义。
- 整体期限限制一次业务操作从开始到结束的总耗时,通常还应覆盖 DNS、TLS、请求体传输、排队和所有重试。具体覆盖范围需要以所用客户端库的文档和测试为准。
设置时间边界时,先看调用链允许的总延迟、下游延迟分布和网络条件,再为连接、单次尝试和整个操作分配预算。超时过短会增加误判与重试流量;过长则会占住连接、线程等资源。没有统一适用的毫秒数。跨地域或公网调用也不能直接沿用内网延迟分位数。
例如,一个操作还有 800 毫秒的剩余预算,客户端就不能再安排一次预计会等待 1 秒的重试。这是演示预算约束的假设数字,不是本站的性能测量。
决定是否重试
| 情况 | 建议 |
|---|---|
| 连接中断、临时服务端错误 | 在剩余期限内考虑有限重试,先确认操作的副作用与接口契约。 |
| 429 限流 | 可按服务端 Retry-After 或接口文档等待;没有足够时间预算时直接返回。不能一概认定“429 不可重试”。 |
| 参数错误、鉴权失败等确定性错误 | 修正请求;原样重试通常无效。 |
| 写入请求超时,结果未知 | 先查询操作状态,或使用服务端支持的幂等键;不要生成新键反复写入。 |
HTTP 状态码只是初步线索。某些接口对 5xx、429 或业务错误有专门的重试规则,应以该接口文档为准。读取操作通常更容易安全重试,写入操作需要明确的幂等保障。
写入操作的幂等键
客户端为同一业务操作生成一个稳定的键,在所有重试中复用。服务端把键与操作结果关联起来,收到同键请求时返回已有结果或状态。实现还需约定键的有效期、请求参数不一致时的处理、并发写入的原子性,以及失败后的查询方式。仅在请求头里加一个随机字符串,并不能自动获得幂等性。
控制重试带来的额外负载
一次原始请求最多尝试几次,应受以下约束:
- 总期限与次数上限:每次等待和尝试都消耗剩余时间。
- 指数退避与上限:失败后逐步延长等待时间,并设置最大值。
- 随机抖动:让不同客户端错开重试时刻,避免同步冲击。
- 重试预算:依赖服务持续过载时降低或停止额外请求。
- 单一责任层:检查网关、SDK、业务服务和任务队列是否都在重试;多层叠加会放大请求量。
下面是策略伪代码,用于表达决策顺序;实际代码应使用客户端库提供的期限、取消和重试能力:
text
key = stable_idempotency_key_for_this_operation
deadline = operation_deadline
for attempt in bounded_attempts:
if deadline_expired(): return TIMEOUT_OR_UNKNOWN
result = call_once(key, timeout_within_remaining_budget())
if result.success: return result
if not retry_allowed_by_contract(result): return result
if retry_budget_exhausted(): return result
delay = bounded_exponential_backoff_with_jitter(attempt)
if delay >= remaining_budget(): return result
wait(delay)对于“结果未知”的写入操作,返回值应让上层知道需要查询状态,不能笼统表示“操作失败且未执行”。
上线前如何验证
- 记录原始请求量、额外重试量、各尝试结果、端到端延迟与下游负载,区分“重试后成功”和“直接成功”。
- 注入连接失败、延迟、限流和部分成功,检查是否遵守总期限、最大尝试次数与取消信号。
- 对写入接口并发发送相同幂等键,验证只出现一次业务副作用;再测试同键不同参数和键过期。
- 检查故障时是否发生多层重试,以及重试是否拖慢下游恢复。
没有测量数据时,不应把某个超时值、重试次数或“成功率提升”写成普遍结论。配置应根据调用链目标和实测数据调整。