一份交接文档里躺着这么一段配置:三跳链路,每跳超时 3 秒,重试 2 次,总预算 30 秒。文档里对重试只有一行注释,写着稳一点。
我对「稳一点」这三个字特别敏感,因为写这三个字的人通常没算过账。所以回去把同一批 200 条请求按「重试 2 次」和「不重试」各跑了一遍。延迟矩阵固定 seed,40 并发,唯一动的就是重试次数。
跑完把两组数放一起:成功 181 条,成功率 90.5%,两组一个数都不差。
那两次重试等于原地又撞了两回墙。
代价
成功数一样,钱不一样。
失败请求的平均端到端耗时,带重试 9464 毫秒,不带重试 3463 毫秒,每条失败平均多烧 6001 毫秒。19 条失败乘下来,这一批在失败上白烧了约 114 秒。p95 从 5492 涨到 9466,多了将近 4 秒。平均尝试次数 3.105 对 2.915,看着只差 0.19,乘上 200 条就是多打了 38 次请求。
失败请求占着连接、占着 goroutine、占着下游配额,你自己的 p95 和 p99 也会被这批失败拖上去。看板上那条抬头向上的曲线,有时候就是重试自己画出来的。
先说一句我不打算含糊的话
这个 181 对 181,是这套实验构造出来的必然,不是我测出来的经验规律。
那批延迟矩阵是固定的,同一请求的每一跳读同一个值,重试读的也是它。所以只要那一跳的真实耗时超过超时,重试撞的必然是同一堵墙。我把这段话放在结论后面,是因为它决定了下面所有判断的适用范围:这批数只能证明「对确定性慢,重试的收益是零」,证明不了「重试普遍没用」。
真实流量里失败有两类。抖动型是丢包、GC 停顿、对端扩缩容,再打一次有概率过去。确定性型是慢查询、没走索引的 SQL、一直在排队的下游队列,每次都在同一个地方慢。这两类混在一起的比例,这批数给不出来,只能拿你自己那条链路的日志去测。
看它死在哪儿
带重试那组的 19 条失败,全部落在 9 秒和 10 秒:10 条死在 9 秒,9 条死在 10 秒。不带重试那组,19 条落在 3 秒和 4 秒:10 条死在 3 秒,9 条死在 4 秒。
9 秒等于 3 秒乘 3 次尝试。两个桶的差别只是框子大小,框子是被参数画出来的,桶里装的是同一批请求。换成 9 秒总预算那套配置,这 19 条全部显示 9 秒整,框子又被重画了一次。
失败死在第几跳,三套配置都一样:第一跳 7 条、第二跳 3 条、第三跳 9 条。三套配置换着花样改超时和重试,这 19 条一条没换。
它们撞的是某一跳的 8 到 11 秒长尾,第三跳那 9 条最典型。试了三次(首试加 2 次重试),每次读到的还是同一个 8 到 11 秒。
超时、重试、总预算,是三个相乘的东西
这三件事平时被当成三个独立参数在调,其实它们乘在一起才是你那条链路的失败预算。
最坏耗时约等于单跳超时乘尝试次数再乘跳数。单跳 3 秒、重试 2 次、三跳,名义上是 27 秒,总预算 30 秒刚好罩住,所以基线配置看起来「稳」。但罩住不等于救回,罩住只是允许它跑满 27 秒然后失败。
| 你动哪个参数 | 最坏耗时 | 成功率 | 代价 |
|---|---|---|---|
| 重试 2 次到 0 次 | 27 秒降到 9 秒 | 181 条不动 | 抖动型失败失去自愈机会 |
| 总预算 30 秒到 9 秒 | 封顶 9 秒 | 181 条不动 | 失败形状被削平,判断力一起没了 |
| 单跳 3 秒到 1 秒 | 6 秒 | 181 掉到 170 | 紧掉的是偏慢的中间层 |
| 单跳 3 秒到 12 秒 | 27 秒涨到 30 秒封顶 | 181 涨到 200 | p95 比不重试那组多近 5 秒 |
最后一行值得多说一句:名义最坏耗时是 36 秒,实际被 30 秒的总预算截住了,所以真实代价是多 3 秒,不是多 9 秒。这两条参数是互相咬着的,单改一个数算不出代价。
p95 也一样,要看跟谁比。跟删掉重试的 5492 比,10366 多了将近 5 秒;跟原来那套带重试的 9466 比,只多 900 毫秒。同一件事,两个参照系,说出来的数差五倍。
一个字段就能判
想自己判一批失败是哪种形状,日志里留这些:
go
type HopLog struct {
ReqID string // 同一请求的重试必须共用这个值
Hop int // 第几跳
Attempt int // 第几次尝试
ClientCost time.Duration // 客户端看到的耗时
ServerCost time.Duration // 下游服务端自记的实测耗时
Timeout time.Duration // 本次生效的超时
Truncated bool // true 表示 ClientCost 是超时上限,不是真实耗时
Err string
}
判据得用两条,光看客户端判不出来,这是我自己踩过的坑。
看 ServerCost:同一 ReqID 的多次尝试,服务端实测耗时都稳定超过超时值,那是确定性慢。看客户端没用,超时的那些 Attempt,ClientCost 必然等于 Timeout,永远「贴着超时值」,这个信号没有分辨力。
再看有没有成功的 Attempt:至少一次成功、而且成功那次远快于超时值,那是抖动。全部失败只能说明这一跳没过去,还得回到 ServerCost 去分辨是慢还是堵。
两个反向的坑
第一个在总预算上。压到 9 秒,成功数还是 181,但 19 条失败全部显示 9 秒整。你看到的是预算,不是故障。形状被削平之后,你连该放宽哪一跳都判不出来。
第二个在收紧超时上。单跳压到 1 秒,成功率掉到 85%,多出的 11 条失败全在第一跳。这 11 条不是正常请求,是矩阵里刻意注入的「全链路偏慢」组,每跳 1.5 到 2.5 秒,3 秒下能过,1 秒下必死。而那 170 条正常请求一条都没事,E 的 170 条成功恰好就是它们。
收紧超时杀掉的是偏慢但还能用的中间层。这类请求在监控上不显眼,在成功率上是实实在在的 11 条。
真正能提成功率的那个动作
这批数据里,把单跳从 3 秒放到 12 秒、删掉重试,成功数从 181 到 200,p95 到 10366 毫秒,最慢的一条端到端 11.8 秒。
注意这个对照是 B 对 C,两边都没有重试,只动了单跳超时一个变量。用 A 对 C 说事是不成立的,A 到 C 同时动了两个参数,成功了也归不了因。
顺序上三步,别同时动。第一步用 ServerCost 判失败形状。第二步对确定性慢的那一跳放宽超时,这是唯一真正提成功率的动作。第三步对抖动型保留重试但压到 1 次,B 比 A 省下来的那 6001 毫秒就是这么来的。
之后还有一件容易忽略的事:放宽超时会让延迟分布变长,接口契约里那个 SLA 得跟着核一遍。参数是链条,不是单点。
我原来也信「加个重试稳一点」,现在会先问一句:你这批失败,值不值得重试。
你那条链路,删掉重试再跑一遍,成功数掉不掉?评论区说一下结果,我按你的形状回该动超时还是该动重试。