一个重试过的请求要画成几个跨度
目标
亲眼看到把重试装进一个跨度时转储文件会丢掉什么,改成每次尝试一个跨度并把错误挂在正确的位置,然后在同一份数据上计算按跨度和按请求的错误率相差多少。最后把这些规则固化成 linter,抓出违反规则的转储文件。
为什么重要
几乎每个服务里都有重试,但跟踪里怎么画,几乎没有人定过。在一个跨度里悄悄重复,哪次尝试花了多久、因为什么失败,就会整个消失,剩下的只有“这个跨度花了很长时间”。反过来,每次尝试都创建跨度,却把错误随便挂在哪里,按跨度算出的错误率就会比用户遇到的失败虚高两倍多,仪表板就会撒谎。把外层跨度是用户经历的一件事、尝试跨度是真正发往上游的一次调用,这两层分开,该在哪里数哪个数字就自然确定了。用哪个 API 记录捕获的异常是 SDK 生命周期模块的事,这里要确定的是把跨度分成几个、错误挂在哪里。
步骤
- 创建
/root/tp-retry/one_span.py。转储路径优先读取环境变量TRACELAB_OUT,没有则使用/root/tp-retry/01-one.jsonl。用provider("shop-api", OUT)获取 tracer,只创建一个charge跨度,在其中重复调用upstream.call("charge", 시도번호, idempotency_key="ord-7781")直到成功,失败时休息upstream.backoff_s(시도번호)秒(两处占位符均为尝试序号)。程序结尾调用flush()。然后在/root/tp-retry/01-lost.txt中写两行——attempts=后面写实际尝试了几次(整数),lost=后面写从这个转储文件里已无从得知的东西是什么(至少 40 个字)。 - 创建
/root/tp-retry/attempts.py。默认转储路径为/root/tp-retry/02-attempts.jsonl。外层跨度charge保持不变,每次尝试创建一个子跨度charge.attempt,在整数属性retry.attempt中写下这是第几次尝试(从 1 开始)。等待要在尝试跨度之外进行。运行之后,转储文件里应有 4 个跨度(外层 1 个 + 尝试 3 个)。 - 创建
/root/tp-retry/error_place.py。默认转储路径为/root/tp-retry/03-error.jsonl。对失败的尝试跨度,把状态设为ERROR,并在字符串属性error.type中写入exc.kind。成功的尝试跨度,不动它的状态。外层charge跨度最后把状态设为OK——因为用户经历的结果是成功。 - 创建
/root/tp-retry/rates.py。默认转储路径为/root/tp-retry/04-rates.jsonl,依次处理charge、quote、ship、notify四项作业。每项作业创建外层跨度<작업>.request和尝试跨度<작업>.attempt(占位符为作业名称),尝试最多只做 3 次。最终失败的作业,其外层跨度为ERROR,成功的作业为OK。然后在/root/tp-retry/04-rates.tsv中用制表符分成四列,写两行——第一行是span_level,第二行是request_level,各列为<id> <오류 수> <전체 수> <비율>(其中 id 就是该行开头的名称,其余占位符依次为错误数、总数、比率)。span_level统计转储文件中的所有跨度,request_level只统计没有父级的跨度。比率保留到小数点后第四位。 - 创建
/root/tp-retry/backoff.py(默认转储路径/root/tp-retry/05-backoff.jsonl)。只重新处理一个charge,每次等待结束时,给外层跨度添加事件retry.backoff,并在该事件上加retry.attempt(整数)和backoff.ms(毫秒)。最后在外层跨度的属性retry.backoff_ms_total中以毫秒为单位写下等待时间的合计。然后在/root/tp-retry/05-gap.txt中写两行——backoff_total_ms=后面写记录的合计,uncovered_ms=后面写外层跨度的长度减去尝试跨度所覆盖的区间之后的值(保留到小数点后第一位)。 - 创建
/root/tp-retry/attrs.py(默认转储路径/root/tp-retry/06-attrs.jsonl)。重新处理charge,在外层跨度上加retry.count(整数,实际尝试次数)、retry.last_error(最后一次失败的种类)、idempotency.key(ord-7781),在每个尝试跨度上加retry.attempt以及值相同的idempotency.key。失败的尝试和第 3 步一样,保留error.type和ERROR状态。然后在/root/tp-retry/06-rules.tsv中用制表符分成三列,写四行——第一列是属性名,依次为retry.count、retry.last_error、idempotency.key、retry.attempt,第二列是该属性所挂的位置,写root或attempt,第三列写为什么挂在那个位置(至少 20 个字)。 - 创建
/root/tp-retry/ship_spans.py(默认转储路径/root/tp-retry/07-ship.jsonl)。材料tracelab.tp_retry.shipping的dispatch(order_id, hooks)已经自带循环,只开放了attempt_begin、attempt_end、waited三处。用继承shipping.Hooks的类在这三处创建跨度,以服务名称shipping-api留下外层跨度ship.dispatch和尝试跨度ship.attempt,并采用与第 6 步相同的属性规则。外层跨度上也要加retry.backoff_ms_total。幂等键是ord-7781。 - 创建
/root/tp-retry/retry_lint.py。用python3 retry_lint.py <덤프경로>(占位符为转储文件路径)运行时,每个违反规则的地方输出一行<규칙이름><탭><스팬아이디>(占位符依次为规则名称、制表符、跨度 ID)并以退出码 1 结束;没有违反时,输出一行ok<탭><루트 스팬 수>(占位符依次为制表符、根跨度数)并以 0 结束。规则名称恰好是四种——root-status(最后一次尝试不是失败,外层跨度却是 ERROR)、retry-count(外层跨度的retry.count与子级数量不同)、attempt-error-type(状态为 ERROR 的尝试跨度没有error.type)、idem-key(尝试跨度的idempotency.key与外层跨度不同)。把做好的 linter 在/root/tp-retry/06-attrs.jsonl上运行,看看是否通过,并把在/opt/app/tracelab/tp_retry/broken.jsonl上运行的输出保存到/root/tp-retry/08-lint.txt。
参考
- 工作目录是
/root/tp-retry。如果不存在,先创建。 - 带埋点的程序必须用
/opt/otel-lab/bin/python <파일>(占位符为文件)运行。系统python3中没有 OpenTelemetry SDK。反过来,只读取转储文件的程序用系统python3运行。 - 材料是
/opt/app/tracelab/tp_retry/upstream.py(确定地失败的上游)和/opt/app/tracelab/tp_retry/shipping.py(只开放了钩子的第二个服务),以及反例转储文件/opt/app/tracelab/tp_retry/broken.jsonl。公共接线是/opt/app/tracelab/dump.py,读取转储文件的辅助模块是/opt/lab/checks/_tplib.py。 - 常见错误:不删除转储文件就把程序运行两次。转储是追加的,所以跨度会变成两倍。
- 常见错误:在尝试跨度内部做等待(backoff)。那样这次尝试会被记录得比实际更久。
- Traces (OpenTelemetry Concepts) · Tracing API 规范 · HTTP 跨度语义约定 · error.type 属性注册表 · Python 埋点文档
把重试装进一个跨度,转储文件会丢掉什么
创建 /root/tp-retry/one_span.py。转储路径优先读取环境变量 TRACELAB_OUT,没有则使用 /root/tp-retry/01-one.jsonl。用 provider("shop-api", OUT) 获取 tracer,只创建一个 charge 跨度,在其中重复调用 upstream.call("charge", 시도번호, idempotency_key="ord-7781") 直到成功,失败时休息 upstream.backoff_s(시도번호) 秒(两处占位符均为尝试序号)。程序结尾调用 flush()。然后在 /root/tp-retry/01-lost.txt 中写两行——attempts= 后面写实际尝试了几次(整数),lost= 后面写从这个转储文件里已无从得知的东西是什么(至少 40 个字)。
材料是 /opt/app/tracelab/tp_retry/upstream.py。打开 PLAN 就能看到 charge 在第几次尝试成功。重新生成转储文件之前要先删除该文件——转储是追加的。程序必须用 /opt/otel-lab/bin/python 运行(系统 python3 中没有 otel)。
每次尝试一个跨度,让时间显现出来
创建 /root/tp-retry/attempts.py。默认转储路径为 /root/tp-retry/02-attempts.jsonl。外层跨度 charge 保持不变,每次尝试创建一个子跨度 charge.attempt,在整数属性 retry.attempt 中写下这是第几次尝试(从 1 开始)。等待要在尝试跨度之外进行。运行之后,转储文件里应有 4 个跨度(外层 1 个 + 尝试 3 个)。
嵌套使用 with tracer.start_as_current_span(...),内层跨度的父级会自动变成外层跨度。如果在尝试跨度内部等待,这次尝试看起来就比实际更久,所以要注意位置。用 python3 /opt/lab/checks/_tplib.py summary <덤프>(占位符为转储文件)可以查看跨度列表。
只有失败的尝试算错误,外层跨度保持正常
创建 /root/tp-retry/error_place.py。默认转储路径为 /root/tp-retry/03-error.jsonl。对失败的尝试跨度,把状态设为 ERROR,并在字符串属性 error.type 中写入 exc.kind。成功的尝试跨度,不动它的状态。外层 charge 跨度最后把状态设为 OK——因为用户经历的结果是成功。
用 from opentelemetry.trace import Status, StatusCode,就可以用 set_status(Status(StatusCode.ERROR, 설명))(占位符为说明)设置状态。什么状态都没设置的跨度,会在转储文件里留成 UNSET——OK 和 UNSET 是不同的值,这一步要求区分这两者。
用跨度算错误率,重试会被数两次
创建 /root/tp-retry/rates.py。默认转储路径为 /root/tp-retry/04-rates.jsonl,依次处理 charge、quote、ship、notify 四项作业。每项作业创建外层跨度 <작업>.request 和尝试跨度 <작업>.attempt(占位符为作业名称),尝试最多只做 3 次。最终失败的作业,其外层跨度为 ERROR,成功的作业为 OK。然后在 /root/tp-retry/04-rates.tsv 中用制表符分成四列,写两行——第一行是 span_level,第二行是 request_level,各列为 <id> <오류 수> <전체 수> <비율>(其中 id 就是该行开头的名称,其余占位符依次为错误数、总数、比率)。span_level 统计转储文件中的所有跨度,request_level 只统计没有父级的跨度。比率保留到小数点后第四位。
notify 在任何一次尝试中都不会成功——看看 upstream.PLAN。两个比率的分母不同,这就是这一步的全部。统计只需要用 Python 读取转储文件,看 status 和 parent_id 即可,用 /opt/lab/checks/_tplib.py 的 load 一行就能读入。
用在等待上的时间,不在任何一个跨度里
创建 /root/tp-retry/backoff.py(默认转储路径 /root/tp-retry/05-backoff.jsonl)。只重新处理一个 charge,每次等待结束时,给外层跨度添加事件 retry.backoff,并在该事件上加 retry.attempt(整数)和 backoff.ms(毫秒)。最后在外层跨度的属性 retry.backoff_ms_total 中以毫秒为单位写下等待时间的合计。然后在 /root/tp-retry/05-gap.txt 中写两行——backoff_total_ms= 后面写记录的合计,uncovered_ms= 后面写外层跨度的长度减去尝试跨度所覆盖的区间之后的值(保留到小数点后第一位)。
用 span.add_event(이름, {속성})(占位符依次为事件名称、属性)添加事件。没被覆盖的区间,用 /opt/lab/checks/_tplib.py 的 covered_ns(부모, 자식들)(占位符依次为父级、子级们)求出之后,再从父级长度中减去即可——这个函数不会把重叠的子级数两次。两个数字应该接近,但不会完全一样。想一想为什么。
确定属性规则并照此埋点
创建 /root/tp-retry/attrs.py(默认转储路径 /root/tp-retry/06-attrs.jsonl)。重新处理 charge,在外层跨度上加 retry.count(整数,实际尝试次数)、retry.last_error(最后一次失败的种类)、idempotency.key(ord-7781),在每个尝试跨度上加 retry.attempt 以及值相同的 idempotency.key。失败的尝试和第 3 步一样,保留 error.type 和 ERROR 状态。然后在 /root/tp-retry/06-rules.tsv 中用制表符分成三列,写四行——第一列是属性名,依次为 retry.count、retry.last_error、idempotency.key、retry.attempt,第二列是该属性所挂的位置,写 root 或 attempt,第三列写为什么挂在那个位置(至少 20 个字)。
选择位置的标准只有一个——这个值是每个逻辑请求确定一次,还是每次尝试都不同。幂等键在两边放相同的值。因为同一个键发出了多次这一事实本身,就是分辨重复处理事故的线索。
把同样的规则接入改不了的别人的循环
创建 /root/tp-retry/ship_spans.py(默认转储路径 /root/tp-retry/07-ship.jsonl)。材料 tracelab.tp_retry.shipping 的 dispatch(order_id, hooks) 已经自带循环,只开放了 attempt_begin、attempt_end、waited 三处。用继承 shipping.Hooks 的类在这三处创建跨度,以服务名称 shipping-api 留下外层跨度 ship.dispatch 和尝试跨度 ship.attempt,并采用与第 6 步相同的属性规则。外层跨度上也要加 retry.backoff_ms_total。幂等键是 ord-7781。
用 provider("shipping-api", OUT, set_global=False) 创建第二个服务的 provider。钩子里不能用 with,所以用 tracer.start_span(...) 创建,并在 attempt_end 中调用 end()。如果外层跨度是当前跨度,尝试跨度的父级会自动设好。
把规则固化成 linter,抓出违反规则的转储文件
创建 /root/tp-retry/retry_lint.py。用 python3 retry_lint.py <덤프경로>(占位符为转储文件路径)运行时,每个违反规则的地方输出一行 <규칙이름><탭><스팬아이디>(占位符依次为规则名称、制表符、跨度 ID)并以退出码 1 结束;没有违反时,输出一行 ok<탭><루트 스팬 수>(占位符依次为制表符、根跨度数)并以 0 结束。规则名称恰好是四种——root-status(最后一次尝试不是失败,外层跨度却是 ERROR)、retry-count(外层跨度的 retry.count 与子级数量不同)、attempt-error-type(状态为 ERROR 的尝试跨度没有 error.type)、idem-key(尝试跨度的 idempotency.key 与外层跨度不同)。把做好的 linter 在 /root/tp-retry/06-attrs.jsonl 上运行,看看是否通过,并把在 /opt/app/tracelab/tp_retry/broken.jsonl 上运行的输出保存到 /root/tp-retry/08-lint.txt。
linter 不需要 otel——只用标准库读取 JSONL 即可,所以写成能在系统 python3 上运行的样子。子跨度是 parent_id 等于父级 span_id 的那一行。最后一次尝试是 start_ns 最大的子级。评分器会把你的 linter 也在其他转储文件上运行,所以不能按文件名或特定 ID 来判定。