线上故障复盘里出镜率最高的两个诱因,一个是下游变慢而调用方没有超时,线程被活活拖死;另一个是下游刚抖动,四面八方涌来的重试流量就把它彻底压垮。超时和重试是最便宜的两个弹性手段,但用错的代价比不用更大。本文讲透四件事:超时时间怎么定、重试如何放大故障、退避和抖动怎么写、为什么幂等是重试的前提。

超时不设等于自杀

同步调用的链路里,每个请求都占着一个工作线程和一条连接。下游响应时间从 50ms 恶化到 10s,在吞吐量不变的前提下,按 Little 定律,系统内堆积的在途请求会膨胀两百倍:线程池打满、队列塞死、新请求全部排队等待,最终调用方先于下游倒下。下游慢,最后变成自己挂,这就是最经典的级联雪崩。

超时的本质,是给自己占用的资源设一个释放上界:等待超过阈值就主动放弃,把线程和连接还回去。真实的生产事故里,超时缺失叠加客户端异常重连,曾把服务端连接数堆到几十万级别,直接拖垮整机。

日常配置至少覆盖两个阶段,但要意识到完整链路远不止这两处:

超时类型 约束的阶段 经验起点
连接超时(Connect Timeout) TCP 建连的最长等待 内网 1s 左右,跨地域或 TLS 冷连接放宽到 3~5s
读取超时(Read Timeout) 连接建立后等响应的最长等待 参考下游分位延迟,见下节

除此之外还有 DNS 解析、从连接池借连接、TLS 握手、写请求体、整个调用的总 Deadline 等阶段。不同 HTTP/RPC 客户端对这些阶段的覆盖口径差异很大,配置前必须对照所用客户端的文档——以为设了总超时,实际只约束了读取,是常见配置事故。

超时时间怎么定

看下游的 TP99/TP999,而不是平均响应时间。 平均值会被大量快请求拉低、掩盖长尾,而超时恰恰是为长尾准备的。一个可操作的方法:先确定能接受的误超时率,比如千分之一,再把下游对应的分位延迟(P99.9)作为读取超时的起点,随后用压测校准。

两个方向都有代价:超时设得太长,慢请求照样堆积,超时形同虚设;设得太短,下游稍有波动就大批误超时,用户看到的错误率上升,叠加各层重试反而加重下游负担。

调用链上还有一条硬规则:下游的超时必须小于上游剩余的预算。更规范的做法是入口请求携带一个总 Deadline,每经过一层就扣掉本地处理、排队和网络耗时,把剩余预算传给下游。如果每层拍脑袋定 3s、2s、1s,很可能上游已经超时返回,下游还在认真执行一个注定被丢弃的请求,纯属浪费资源。超时值最好放进配置中心支持动态调整,调整时灰度发布,盯住 P99、超时率、线程池队列和重试量再推全量。

重试的放大效应

重试只对一类故障有效:瞬态故障——网络抖动、偶发的连接重置、下游短暂过载。对持续性故障,重试不仅无效,还会把流量成倍放大。

放大是乘法而不是加法。调用链 A→B→C→D 四层,每层各重试 3 次,最坏情况下 D 收到的请求量约是入口的 27 倍。下游本来就在垂死挣扎,各层潮水般的重试就是压垮它的最后几根稻草,这就是重试风暴;重试流量再反噬上游造成连锁超时,形成雪崩。

控制手段有四条:

  1. 次数克制:在线同步调用一般只重试 1~3 次。算一笔账:读超时 1.5s、重试 3 次、退避 1s/2s/4s,最坏尾部延迟约 1.5s×4 + 7s = 13s,多数在线接口根本等不起。异步任务和 MQ 消费可以放宽,但必须配最大次数、死信队列和人工补偿入口。
  2. 只重试可重试的错误:连接超时、读超时、连接重置、临时 502/503/504、带 Retry-After 的 429 可以重试;参数错误(400)、鉴权失败(401/403)、业务规则失败(余额不足、库存不足)直接返回,重试一万次也不会成功。
  3. 重试预算(Retry Budget):限制重试流量占总请求量的比例,比如 10%。超过预算后只允许首试、快速失败,防止故障期重试流量淹没下游。
  4. 同层只重试一次:网关、RPC 框架、业务代码各自重试会叠乘,要约定只在链条的某一层做。

退避策略:指数退避加抖动

失败后立刻重试,大概率还是失败——下游恢复需要时间。退避策略就是给重试间隔加一段递增的等待:

策略 间隔形态 评价
固定间隔 1s、1s、1s 实现最简单,容易撞出重试风暴
线性退避 1s、2s、3s 温和但增长慢
指数退避 1s、2s、4s、8s 给下游足够恢复时间
指数退避 + 抖动 在指数上限内随机取值 分布式场景的标准答案

为什么必须加抖动(Jitter):下游故障恢复的瞬间,所有客户端的超时几乎在同一时刻触发,如果都按 1s、2s、4s 的节奏重试,大家又会在完全相同的时刻撞上去,形成二次冲击。抖动把每个客户端的重试时刻随机打散。一个 Full Jitter 的手写示例:

long baseMs = 1000;
int maxAttempts = 3;
for (int attempt = 1; ; attempt++) {
try {
return remoteClient.call(request);
} catch (RetryableException e) {
if (attempt >= maxAttempts) {
throw e;
}
long cap = baseMs * (1L << (attempt - 1)); // 上限: 1s、2s
long sleepMs = ThreadLocalRandom.current().nextLong(cap); // [0, 上限) 随机
Thread.sleep(sleepMs);
}
}

工程上不建议手写,用现成框架:Spring Retry 的 @Retryable 配 @Backoff(delay = 1000, multiplier = 2),或 Resilience4j 的 IntervalFunction.ofExponentialRandomBackoff(...)。两个坑要留意:@Retryable 基于 Spring AOP 代理,同类内部 this.method() 自调用不会生效;Retry 与 CircuitBreaker 组合时装饰顺序影响统计口径——重试包在熔断外面时,重试成功不计入熔断统计。

幂等是重试的前提

超时只说明调用方没在期限内等到响应,不能证明服务端没执行。最典型的场景:服务端已经扣款成功,响应包丢在了回程网络上,客户端超时后重试,用户被扣了两次钱。所以任何有副作用的操作,在挂上重试之前必须先回答:同一个请求到达两次,系统状态是否和到达一次完全一样。

常见的幂等实现:每个请求携带唯一请求 ID,服务端记录后去重;创建类操作靠数据库唯一索引硬约束;更新类操作用版本号乐观锁;订单、支付这类有明确生命周期的业务用状态机,已终态的重复流转直接拒绝。注意区分去重和幂等:去重是挡住重复请求不让执行,幂等是允许重复到达但保证结果一致,二者常配合使用但不能互相替代。HTTP 语义上 GET/PUT/DELETE 是幂等方法,POST 默认不是——协议语义只是起点,涉及创建订单、扣款、发券这类副作用,业务侧必须显式设计。一句话经验:读操作可以积极重试,写操作没有幂等宁可快速失败。

上线后看什么指标

超时和重试策略上线只是开始,必须用指标验证它没有在帮倒忙:

指标 关注点
重试 QPS 与重试比例 是否超过 Retry Budget
首次成功率 vs 最终成功率 首次成功率降、最终成功率不变,说明在用重试掩盖下游故障
P99/P999 与超时率 超时阈值是否合理
线程池队列、连接池等待 资源是否仍有堆积风险
下游 5xx、熔断器状态 重试是否还在打向已经熔断的下游

小结

  • 超时的本质是给占用的资源设释放上界,不设超时等于把生死交给下游。
  • 超时时间按下游分位延迟(TP99 起步)定,调用链上传递 Deadline,下游超时小于上游剩余预算。
  • 重试只治瞬态故障;次数克制、只重试可重试错误、配 Retry Budget,三者缺一不可。
  • 退避选指数退避加抖动,打散多客户端的同步重试。
  • 幂等是写操作重试的入场券,没有幂等就快速失败。