你那条三跳链路的单跳超时,是按压测的 p95 定的,还是拍出来的?

3 阅读11分钟

你那条三跳链路的单跳超时,是按压测的 p95 定的,还是拍出来的?

一条三跳链路,总预算 30 秒。第三跳在 9 秒里把三次尝试跑完,都没成;加上退避和收尾,整条链路第 11.3 秒才把部分结果返回。

预算还剩 18 秒多,一次都没用上。

这套配置我见过太多次:单跳 3 秒、每跳重试 2 次、总预算 30 秒。模板里就是这么写的。这两周我把它拆开压了一遍,200 条请求同一批延迟,每次只改一个参数。

结果是:动总预算,成功数不变;删重试,成功数不变;只有把单跳从 3 秒放到 12 秒,200 条才全过。三个参数里真正起作用的是最后一个。

所以这篇只讲顺序:先看分布,再定单跳,最后才动总预算。

一、那 27 秒是乘法算出来的

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

乘法假设每一跳都会跑满三次尝试。实际跑起来,任何一跳跑满 3 次,链路就举手了。实测那次,3 加 3 加 3 等于 9 秒,第三跳的三次机会用完;两次退避又吃掉 0.6 秒,加上前两跳的 0.7 秒,链路在第 11.3 秒把部分结果返回出去。总预算 30 秒,到这一刻还剩 18 秒多。

额度还剩七成,链路已经投降。这句话是我做完第一轮压测写在笔记里的。

二、第一步:先把分布打出来,p95 会骗你

200 条请求压完,同一台机器、同一份延迟数据:

指标实测值说明
p501079 ms一半请求在这个点之前结束
p955737 ms大部分请求的天花板
平均1810 ms被长尾拉上去的均值
超时次数33跑满 5 秒生成超时
总请求200固定种子的同一批延迟

单看 p95 是 5737 毫秒,很自然会觉得单跳定 6 秒够了。我第一次差点就这么定:乘个 1.2,6.9 秒,看着挺讲究。

但这 5737 毫秒是被截断过的。那 33 次超时的请求,耗时记的是 5.7 秒上下,因为生成跳在 5 秒就把它们掐了,剩下的是收尾开销。它们真实要多久,日志里根本没有这个数。

所以我单独把那一跳的放大超时又跑了一次:真实耗时 9 秒。这个 9 才是定参数要用的数,5737 不是。

这就是第一个坑:p95 描述的是大部分请求,定超时要看的是长尾上限,而在超时已经生效的系统里,长尾数据本身是被藏起来的。 只看 p95,你永远看不到那个 9。

顺手说个更常见的误判:调完超时压一轮,看平均耗时没变化,就认定这个参数不重要。平均耗时确实几乎不会变,五套配置的 p50 分别是 1392、1393、1395、1391、1392 毫秒,几乎一模一样。变化的全部在尾部那几条。

三、分跳埋点是这件事的前提

上面那几个数能拿到,靠的是一个很土的前提:每一跳的耗时单独埋点,带上跳名字段,并且把「被超时截断」和「真实跑完」分开记。

只埋整条链路的耗时,你只能得到一个总数。p95 九秒多,你分不清是检索慢还是生成慢,最后只能给三跳拍一个一样的数,也就是开头那套 3 秒乘三。

我的做法是每次调用打一行结构化日志,五个字段:链路 id、跳名、本次耗时、是否被超时截断、是否重试。落盘之后按跳名分组,各算一条分位数,截断的那批单独放一个桶。

这个桶很关键。混在一起算,长尾会被截断值压平,看起来一切正常;单独看,你才知道有多少请求根本没跑完。

同一份日志还能回答第二个问题:失败集中在某一跳,还是均匀散在三跳上。集中在某一跳,是单点慢节点,该换节点;三跳都出现过,是链路整体的容量问题,该先看并发和队列。这两种处置完全不同,光看失败率分不出来。

四、第二步:单跳超时按长尾上限定,不按分位数定

这一步才是真正决定成败的那一步。三跳延迟固定为 300 毫秒、400 毫秒、9 秒,同一份脚本,只改单跳超时和重试次数:

配置单跳重试结果耗时
三秒单跳两重试3s2部分结果,缺第三跳11277 ms
十二秒单跳不重试12s0成功9703 ms

同一条链路、同一批延迟,放宽超时之后成功了,而且这一次快了 1574 毫秒。

11277 是这么堆出来的:第三跳在 3 秒断,后两次重试还是撞同一堵墙,它要 9 秒,3 秒等不到。三次尝试 9 秒,两次退避 0.6 秒,前两跳 0.7 秒,理论上是 10.3 秒,实测 11.3 秒,多出来那 1 秒是每次尝试的连接建立和超时判定开销。12 秒单跳一次就过完了,300 加 400 加 9003,9.7 秒。

这里得说清楚,1574 是两个单次运行的差值,不是 200 条口径。放到 200 条那批配置上,12 秒档的 p95 是 10366 毫秒,比 3 秒档的 9466 还高 900 毫秒。成功率从 181/200 涨到 200/200,代价是尾部变长。这笔账要认,别只看那个 1574。

重试救不了「本来就是慢」的请求。 它只能救「第一次抽到坏样本」的请求:连接池刚扩容、DNS 刚刷新、对端限流返回 429。这三种第二次抽到的是新状态。

怎么分辨,不用新工具。把失败请求的耗时按秒做直方图:如果集中落在单跳超时值的整数倍附近(3 秒、6 秒、9 秒这样递进),说明每次都在跑满超时,重试没有增量信息;如果散在各秒数上,才像抖动。

200 条那批的五套配置里,A 档(3 秒两重试)的失败落在 9 到 10 秒,是三次 3 秒尝试叠出来的位置;B 档(3 秒不重试)的失败落在 3 到 4 秒,是单次跑满的位置。两档形态属于同一类,都是跑满才死,区别只在撞了几次墙。

五、第三步:总预算最后动,而且只动一次

单跳定完,总预算才有得算:最长一跳的超时,加其余每跳一次机会,加一次重试的余量。

最长一跳 12 秒,另外两跳各给 1.5 秒(它们实测是 300 和 400 毫秒,留三四倍余量),重试余量 12 秒,加起来 27 秒。

这个 27 和开头那个 27 数字一样,来源完全不同。开头那个是 9 乘 3 乘出来的,假设每跳都要跑满三次;这个是按每跳实测上限加起来的。同样一个数,一个能扛住长尾,一个扛不住。

预算感知的重试,判断就两行,其余都是常规写法:

type Budget struct{ end time.Time }

func NewBudget(d time.Duration) Budget { return Budget{end: time.Now().Add(d)} }
func (b Budget) Left() time.Duration   { return time.Until(b.end) }

// maxAttempt 含首试,传 3 表示首试加两次重试
func CallHop(ctx context.Context, url string, hopTO time.Duration, maxAttempt int, b Budget) (string, error) {
	var lastErr error
	for i := 0; i < maxAttempt; i++ {
		if b.Left() < hopTO {
			break // 剩余预算不够一次完整尝试,重试只会把失败拖长
		}
		cctx, cancel := context.WithTimeout(ctx, hopTO)
		req, err := http.NewRequestWithContext(cctx, "GET", url, nil)
		if err != nil {
			cancel()
			return "partial", err
		}
		resp, err := http.DefaultClient.Do(req)
		cancel()
		if err == nil {
			resp.Body.Close()
			return "ok", nil
		}
		lastErr = err
		back := time.Duration(200<<uint(i)) * time.Millisecond
		if b.Left() < back+hopTO {
			break // 退避之后还得剩一次完整尝试,否则这次重试注定失败
		}
		select {
		case <-time.After(back):
		case <-ctx.Done():
			return "partial", lastErr
		}
	}
	return "partial", lastErr
}

那两行 b.Left() < hopTO 砍掉的就是干等的那几秒。很多人写重试只看次数不看时间:退避 200 毫秒、400 毫秒,退完发现预算没了,还是硬发一次,超时再等 3 秒。并发一上来,这些必然失败的尝试全是实打实的连接占用。

六、顺序错了的代价

三个数一起改,是另一种常见做法,代价我也量过。同一批 200 条延迟,五套配置每次只差一个参数:

总预算从 30 秒压到 9 秒那一档,成功数 181,跟基线一模一样,变化只有一处:失败从第 9 到 10 秒提前到第 9 秒整。压缩预算买到的是更早的失败,不是更高的成功率。

重试从 2 次改到 0 那一档,成功数还是 181,全量 p95 从 9466 毫秒降到 5492 毫秒,成功率一分没动。

三个数里真正救得回长尾的是单跳超时,12 秒那一档 200 条全过。

反方向也试过:单跳压到 1 秒、重试 1 次、总预算 8 秒,成功率掉到 85%,第一跳就死了 18 条。超时不是越小越安全,压小了是把本来能恢复的慢请求直接判死。

还有一个更隐蔽的坑:总预算等于单跳乘跳数的时候,「重试 2 次」这个配置形同虚设。3 秒乘 3 跳等于 9 秒,总预算也压到 9 秒,链路根本没机会重试第二次。配置文件里写着 2,实际跑起来是 0。

七、上线怎么落地,别一次全改

这套东西不适合一次上完。我的顺序是三步,每步之间隔一天,看数据再走下一步。

第一步只加埋点,不改任何参数。分跳耗时、是否被截断、是否重试,先落一周日志,把每跳的长尾上限拿到手。这一步不改变线上行为。

第二步只改单跳超时,重试次数和总预算都不动。改完观察两天,看失败是否下降、p95 是否变化。如果失败没降,说明长尾不在这一跳,回第一步重看分布。

第三步才动重试和总预算。到这一步,预算感知的那两行判断要一起加上,否则总预算一调松,重试又退回到无限撞墙。

一次全改的问题不是风险大,是改完你不知道是哪个数起了作用,下次还得重来一遍。

八、这套结论的边界

第一,12 秒不是通用值。它在这套数据里成立,是因为那一跳的真实长尾是 9 秒。换一条链路,这个数从自己的长尾上限往上推,不要照抄。

第二,三跳可以给不同的超时。转发那一跳几百毫秒的量级,给它 12 秒纯属浪费。分段之后总预算反而更松,不过分段这套我没单独跑过对照,只能当方向看。

第三,如果长尾来自同一个慢节点,任何重试都是浪费;如果长尾是抖动,重试有价值。区别在形态,不在次数。

第四,这套实验的上游是我本地起的三个 httptest 服务,延迟是设的,不是生产流量的真实分布。200 条只够看方向,换个种子数值会动。真实的分布要自己去线上日志里分跳分桶拿。

九、最后

p95 是用来看大部分请求的,长尾上限是用来给参数定边界的,总预算一般放在最后动。三个数按这个顺序摆,一趟压测就能定完;顺序反过来,改一晚上也不动。


你那三个超时参数,当初是按什么顺序定的?评论区说说顺序,我按你的顺序说会在哪一步白花时间。