预算给够了,时间花错了地方:三跳链路给了 27 秒,实际只用掉 9 秒

0 阅读10分钟

预算给够了,时间花错了地方:三跳链路给了 27 秒,实际只用掉 9 秒

一套超时配置通常就三行:单跳 3 秒、每跳重试 2 次、链路总预算 30 秒。这套配置在中小团队的三跳链路里出现频率很高,写的人大多觉得它稳,理由是重试两次等于给长尾留了余量,长尾再慢,三次里总有一次撞上快的。

这个理由只在「慢是抖动」的链路上成立。如果慢请求本身的耗时已经超过单跳超时,重试就是在同一堵墙上撞三次,检索和生成这两跳就是这种:一次慢就是八到十一秒。

我把这三个数原样搬进本地跑了一遍,顺手把之前那组「超时之后返回什么」的实验并进来一起看。跑完的结论有点反直觉:这套配置 90.5% 的成功率,把重试删干净、单跳从 3 秒放到 12 秒,变成 100%,p95 只多 900 毫秒。那 18 秒的重试余量,一秒都没花出去。

一、先看那 27 秒是怎么来的

3 秒单跳,1 次首试加 2 次重试,就是单跳最多等 9 秒。三跳串起来,9 乘 3 等于 27 秒。这是最坏情况下的等待上限,也是很多人心里那张「最坏也就 27 秒」的底牌。

实测里这 27 秒根本没走到。19 条失败的请求,全部死在第 9 到 10 秒。

为什么停在 9 秒?因为死在哪一跳都一样:任何一跳跑满 3 次尝试就是 9 秒,3 加 3 加 3。总预算 30 秒还剩 20 秒,但链路已经放弃了。

这就是最值得说的一点:额度还剩三分之二,链路已经举手投降。问题从来不在额度大小。

这 9 秒里,只有前 3 秒是有信息量的。第一次超时的时候,结果就已经定了:那一跳要 8 到 11 秒才返回,3 秒不可能等到。后面那 6 秒不是在等新结果,是拿同样的 3 秒再撞同一堵墙。换来的只有三样东西:失败晚到 6 秒、上游多占 6 秒、日志里多两条一模一样的超时记录。而并发一上来,这 6 秒乘 19 条就是实打实的连接占用。

二、五套配置并排,看两个指标怎么互相打脸

延迟矩阵 200 条,固定 seed,构成是 84% 正常(每跳 200 到 700 毫秒)、8% 单跳长尾(8 到 11 秒,随机落一跳)、8% 全链路偏慢(每跳 1.5 到 2.5 秒)。五套配置共用同一批延迟数据,不然比的不是配置,是运气。

配置单跳重试总预算成功成功率p95失败落在哪
A 基线3s230s181/20090.5%9466ms第 9 到 10 秒,19 条
B 不重试3s030s181/20090.5%5492ms第 3 到 4 秒,19 条
C 宽单跳12s030s200/200100%10366ms无
D 紧总预算3s29s181/20090.5%9000ms第 9 秒整,19 条
E 激进1s18s170/20085%2465ms第 2 到 3 秒,共 30 条(第一跳 18 条)

这张表里有三处打脸。

A 和 B 打脸重试。 同一批请求、同样 3 秒单跳,一个重试两次一个不重试,成功数都是 181。重试带来的增益是 0,代价是失败均耗时从 3463 毫秒涨到 9464 毫秒。六秒,换零条。

C 打脸「重试能救长尾」。 单跳放宽到 12 秒、重试 0,200 条全过。长尾上限 11 秒,12 秒能等到;偏慢那批每跳 1.5 到 2.5 秒,也能等到。它证明长尾该用「等」解决,不该用「再试一次」解决。代价是 p95 从 9466 涨到 10366,多了 900 毫秒。

E 打脸「超时设小更安全」。 单跳压到 1 秒,正常请求还能过,全链路偏慢那批全灭,18 条直接死在第一跳,成功率掉到 85%。压小超时不是保守,是把本来能恢复的慢请求判成失败。

D 那一行是另一个提醒:总预算压到 9 秒,19 条失败全死在第 9 秒整。总预算等于单跳乘跳数的时候,「重试 2 次」这个配置形同虚设,链路根本没机会重试第二次。配置里写着 2,实际跑起来是 0。

还有一个数值得单独看:五套配置的 p50 是 1392、1393、1395、1391、1392 毫秒,几乎一模一样。这一轮调整动的全是尾巴,中位数一分没动。所以你在本地随手抽十条看平均耗时,会觉得「改了跟没改一样」,变的都在第 90 个百分位之后。

这也解释了一个很常见的误判:调完超时压一轮,看平均耗时没变化,就认定这个参数不重要。平均耗时确实不会变,受影响的只是那 8% 长尾里的一部分。要判断这类参数有没有用,只能看分位数和失败分布,看平均等于没看。

三、什么情况下重试确实有用

写到这儿得说清楚,我不是在劝所有人把重试删掉。重试本身没错,错的是用它去解决长尾。

重试有效的场景有一个共同特征:失败是瞬时的,第二次抽到的是新状态。典型三种:

连接池刚扩容,第一批请求撞上还没建好的连接,退避 200 毫秒再试就好了;DNS 刚刷新或服务刚摘挂,第一次解析到旧地址,第二次就正常;对端做了限流,返回 429,等一会儿再来确实能过。这三种的重试间隔是有意义的等待,不是重复撞墙。

重试无效的场景也有共同特征:单次请求的耗时本身就超过了超时值。这种时候第二次、第三次抽到的延迟分布和第一次一样,因为慢的原因是那一跳的处理能力或者数据规模,不是网络抖动。

区分方法不用新工具。把失败请求的耗时按秒做直方图:如果集中在超时值附近一个窄区间,说明每次都跑满了超时,属于第二种;如果散在各个秒数上,更像第一种。这个图我在两套配置上各画了一次,A 配置的 19 条失败全落在 9 到 10 秒,B 配置全落在 3 到 4 秒,形态一模一样,只是整体平移,这就是典型的「跑满超时才死」。

四、失败那一刻返回什么,比失败本身更贵

第二组实验:同样 200 条,16% 的概率在生成跳注入 8 到 11 秒长尾,必然击穿 5 秒的生成超时,固定 seed 命中 33 条。三档返回共用同一份延迟矩阵,唯一变量就是超时那一刻返回什么。读取端是本地确定性替身,不是真模型,规则固定:一条答案要覆盖 6 个业务字段,上游没有的字段它从固定先验表补,补一个记一次。

  • 返回字符串「执行失败」:33 条超时,编造 231 个字段,平均每条 7 个,用户可见的缺数据提示 0 条。
  • 返回空结构 {}:编造 198 个,每条 6 个,提示 0 条。
  • 结构化降级(已拿到的部分、缺哪一段、还能不能重试):编造 0 个,提示 33 条。

231 等于 33 乘 7,198 等于 33 乘 6,都是替身规则下的常数,不要当成编造率。要看的是每条例几个、有没有把缺口说出来。

A 比 B 多的那 33 个,多的是「失败原因」。错误串本身是一句可以续写的话,读取端顺着往下写,就写出了一个不存在的原因。空结构至少没给这个口子。

三档的 p95 全是 5737 毫秒。返回什么不影响耗时,超时点该多晚还是多晚,所以这个改动是纯收益,不拿性能换。

五、按这三步改,别按感觉改

第一步,把最慢那一跳的 P99 打出来。 这是所有判断的起点。只埋整条链路的耗时是不够的:p95 九秒多,你分不清是检索慢还是生成慢,最后只能给三跳拍一个一样的数。每一跳都要有独立的耗时直方图,按跳名字段分桶,这一步做完才有讨论参数的基础。

第二步,看失败分布,不看失败率。 A 配置那 19 条失败全落在第 9 到 10 秒这个窄区间,说明它们是同一类,每次都跑满单跳超时才死。如果失败散在 1 秒、3 秒、7 秒各处,那更像抖动或对端限流,重试就有意义。判断长尾形态不用新工具,日志按失败筛一遍、耗时排序就能看出来。

第三步,重试加一个前置条件:剩余预算够一次完整尝试。 写法就两行:

if b.Left() < hopTO {
    break // 预算不够一次完整尝试,重试只会把失败拖长
}

这两行砍掉的就是那 6 秒的无效等待。退避也要从预算里扣,退避之后还得剩一次完整尝试,否则直接走降级。很多人写退避只看次数不看时间,退避 200 毫秒、400 毫秒,退完发现预算没了,还是硬发一次,超时再等 3 秒。

顺带说一个容易忽略的点:降级返回里的 missing 别塞原始错误串。塞进去,下游还是会顺着那句话往下编,跟返回「执行失败」没本质区别。写成人话,写清楚缺哪一段,不写为什么缺。

六、这套结论的边界

第一,12 秒不是通用值。它在这套矩阵里成立,是因为长尾上限是 11 秒。换一条链路,这个数从你自己的 P99 推,往上留一点余量。第二,三跳可以给不同的超时。转发那一跳本来就是几百毫秒的量级,给它 12 秒纯属浪费;最慢最贵的那一跳才该拿宽超时。分段之后总预算反而更松,不过这套分段我没单独跑过对照,只能当方向看。第三,如果长尾是同一个慢节点,任何重试都是浪费;如果长尾是抖动,重试有价值。区别在形态,不在次数。

七、最后

预算和重试这两个东西不该放在一起调。重试是在时间维度上重复同一个动作,单跳超时是给这个动作定一个够不够的边界。边界错了,重复多少次都是错的。


你那条链路的超时返回,是「执行失败」这种字符串,还是空结构?评论区说一句就行。