第三次才成功的请求算成功还是失败
一句话总结
如果不先确定一个重试的请求要画成几个跨度、错误要挂在哪个跨度上,转储文件就会悄悄地撒谎。
为什么需要它
支付负责人问道:“试了三次,第三次才成功,这算成功还是失败?”答案是两者都是。对用户来说是成功,对上游服务来说是两次失败。问题在于,跟踪被画成了只能说出其中一种。
当时的代码把重试循环包进了一个跨度。画面上只显示一个 184ms 的 charge 跨度,状态正常。这 184ms 里包含一次 60ms 的超时、一次 20ms 的 503,以及两次等待,而这个事实在任何地方都没有留下。对询问变慢原因的人,能展示的只有“这个跨度花了很长时间”。
也有团队走向了另一边。每次尝试都创建跨度,并给失败的尝试标上错误状态,这次仪表板上的错误率一下子翻了一倍。用户遇到的失败并没有增加,只是数字上去了。没有人事先说过,按跨度来数,重试会被重复计算。
工作原理
归纳起来,需要决定的有四件事。
| 决定 | 选项 | 本实验选择的一边 |
|---|---|---|
| 如何划分跨度 | 整个重试做成一个 / 每次尝试一个 | 1 个外层跨度 + N 个尝试跨度 |
| 错误挂在哪里 | 外层跨度 / 失败的尝试跨度 | 只挂在失败的尝试上 |
| 最终成功时的状态 | 保持原样 / 明确设为正常 | 明确写成正常 |
| 计数单位 | 跨度 / 逻辑请求(根) | 逻辑请求 |
外层跨度是用户经历的那一件事。尝试了三次终于成功,用户经历的就是成功,所以这个跨度是正常的。尝试跨度是真正发往上游的一次调用。失败的调用必须留作失败,才能看出上游的健康状况。两层回答的是不同的问题,所以状态也各走各的。
这个区分一旦确立,错误率该在哪里计数就自然确定了。把跨度当分母,重试越多的请求,分母和分子会一起变大,失败就被放大;只数根跨度,则会原样得到用户经历的失败。同样的数据里会真的算出 53.8% 和 25.0%——实验第 4 步会亲手算出这两个数字。
等待时间也需要一个去处。尝试与尝试之间的退避(backoff)不在任何一个尝试跨度里,所以从外层跨度的区间中减去子区间后,它只表现为剩下的空隙。不要让人去猜这个空隙是什么,要用事件或属性写下来。实验第 5 步会亲手测量这个空隙,并与记录核对。
属性只有定成规则才有用。重试次数和最后一次失败原因是一个逻辑请求的性质,所以放在外层跨度;第几次尝试因每次尝试而异,所以放在尝试跨度。幂等键在两边放相同的值——只有能看出同一个键发出了多次,才能分辨出重复处理的事故。HTTP 埋点中已经定义了含义相同的标准属性 http.request.resend_count,自己起名之前,最好先看看 HTTP 跨度语义约定。用于失败分类的 error.type,其取值规则也写在 错误属性注册表 中。
这里要明确说明一件不讨论的事。用哪个 API 把捕获的异常记到跨度上、状态码怎样接线,由 SDK 生命周期模块讲解。本模块的问题在此之前——把多次尝试的一项工作表示成几个跨度,又把错误挂在其中哪一个上。设置状态的方法见 跟踪 API 规范。
也写一下这个实验环境无法判定的事项。Pod 里既没有 OpenTelemetry Collector,也没有跟踪后端。所以尾部采样如何挑出失败的尝试、重试在后端界面里会折叠成什么样子,在这里都无法确认。我们能看到的只有把 SDK 导出的跨度原样记下来的 JSONL 转储文件,判定全部依据该转储文件的结构和属性。耗时会因机器状况相差几毫秒,所以不看绝对数值,只看关系。
在现场相遇的样子
事故复盘中最常出现的一句话是“多亏重试,用户什么都没感觉到”,但在很多情况下,无法确认这句话是否属实。没有尝试跨度,就数不出上游跌倒了多少次;没有外层跨度,就数不出用户实际有没有遇到失败。两层都有,才能用数字说出“上游很糟,但用户没事”。
反过来的事故也很常见。有个团队把重试放了三层——客户端库三次,其上的服务三次,网关两次。上游一次跌倒,实际上发出了十八次调用,但跟踪里只看到一个外层跨度。一创建尝试跨度,这十八个就看得一清二楚,当天就把重试层减成了一层。是埋点暴露了设计缺陷。
下一项实验要做什么
面对一个确定地失败两次、第三次成功的上游,先看把重试装进一个跨度时转储文件会丢掉什么。接着每次尝试创建一个跨度,只给失败的尝试挂上错误,在同一份数据上分别计算按跨度和按请求的错误率,确认相差多少。把等待时间记成事件,与空隙核对,用表格定下属性规则之后,把同样的规则以钩子的形式接到我们改不了循环的第二个服务上。最后把这套规则固化成 linter,真正抓出违反规则的转储文件。