只有一个跨度什么都看不出,三十个又没人愿意看
目标
在一段完全没有埋点的订单处理代码里亲手放入跨度,学会用数字来确定边界该划在哪里。测量埋点空白,把循环折叠为属性,把这些判断写成规则文件,并原样应用到第二个 handler 上。
为什么重要
只放一个跨度,这条跟踪除了“花了 211ms”什么也不会说。在每个循环里都创建跨度,这次又会是三十个同样形状的条形连在一起,没有人看到最后。两种失败都是跳过了同一个问题的结果——以后要用这条跟踪问什么。找到能回答这个问题的位置的工具,就是埋点空白。父区间中没有被任何子跨度覆盖的时间,就是“还不知道的时间”,那个位置就是下一个跨度该划的地方。反过来,循环要用次数、总耗时、最大值折叠起来,而不是做成跨度,跟踪才能保持在可读的大小。最后,如果把这个判断留给个人喜好,每次评审都会重新争论,所以要把每个请求的跨度数上限和允许的空白写成文件,让机器去读。
步骤
- 创建
/root/tp-boundary/01_root.py。用/opt/app/tracelab/dump.py的provider创建服务名称为shop-api的 provider,转储路径优先读取环境变量TRACELAB_OUT,没有则使用/root/tp-boundary/01-root.jsonl。在名为POST /checkout的一个SERVER跨度中,依次调用tracelab.tp_boundary.shop的validate→load_cart→price_of(全部商品)→charge→write_receipt,最后调用flush()。然后用/opt/otel-lab/bin/python运行以生成转储文件,并在/root/tp-boundary/01-root.txt中写下spans=和root_ms=两行(原样使用从转储文件读到的值)。 - 创建
/root/tp-boundary/02_split.py。与第 1 步相同,但把数据库往返load_cart包进cart.load跨度,把发往外部的charge包进payment.charge跨度(自动埋点会替你创建的位置就是这两处)。转储默认路径为/root/tp-boundary/02-split.jsonl。运行之后,在/root/tp-boundary/02-gap.txt中写下四行root_ms=、covered_ms=、gap_ms=、gap_ratio=。covered_ms是把根的子区间按并集相加得到的长度,gap_ratio是(根 − 被覆盖的时间)÷ 根,写到小数点后第四位。 - 创建
/root/tp-boundary/03_close.py。为消除第 2 步剩下的空白,把validate包进order.validate,把商品价格查询的整个循环包进price.lookup,把write_receipt包进receipt.write跨度。跨度名称要与这五个(order.validate、cart.load、price.lookup、payment.charge、receipt.write)和根POST /checkout完全一致。转储默认路径为/root/tp-boundary/03-close.jsonl。运行之后,在/root/tp-boundary/03-gap.txt中写下与第 2 步相同的四行,并使gap_ratio小于 0.05。 - 创建
/root/tp-boundary/04_peritem.py。与第 3 步相同,但在price.lookup内,每查询一件商品就创建一个price.item跨度。转储默认路径为/root/tp-boundary/04-peritem.jsonl。运行之后,在/root/tp-boundary/04-count.txt中写下三行spans_per_request=、item_spans=、spans_per_1000_requests=。最后一行是每个请求的跨度数乘以 1000 得到的整数。 - 创建
/root/tp-boundary/05_fold.py。去掉price.item跨度,改为给price.lookup跨度加上三个属性:price.lookup.count(查询次数)、price.lookup.total_ms(合计毫秒数)、price.lookup.max_ms(耗时最长的一次的毫秒数)。然后把耗时最长的商品记为名为price.lookup.slowest的事件,并在事件属性中放入sku。转储默认路径为/root/tp-boundary/05-fold.jsonl,跨度总数不得超过 8 个。 - 创建
/root/tp-boundary/06_kind.py。与第 5 步相同,但给所有跨度明确指定SpanKind——进来的请求是SERVER,发往进程之外的调用(cart.load、payment.charge、receipt.write)是CLIENT,在进程内部划分出来的区间(order.validate、price.lookup)是INTERNAL。转储默认路径为/root/tp-boundary/06-kind.jsonl,运行之后,按开始时间顺序在/root/tp-boundary/06-kinds.tsv中为每个跨度写一行<스팬이름><탭><kind>(占位符依次为跨度名称、制表符、kind),不要表头,共六行。 - 在
/root/tp-boundary/budget.txt中写三行——max_spans_per_request=8、max_gap_ratio=0.10、root_kind=SERVER。然后创建/root/tp-boundary/check_budget.py。用python3 check_budget.py <규칙파일> <덤프>(占位符依次为规则文件、转储文件)调用时,每违反一条规则就输出一行VIOLATION <규칙키> <지금 값>(占位符依次为规则键、当前值)并以退出码 1 退出;全部遵守时,输出一行以OK开头的内容,并以退出码 0 退出。最后在/root/tp-boundary/07-verdict.tsv中写三行,每行是<덤프파일이름><탭><pass|fail><탭><깨진 규칙키 또는 ->(占位符依次为转储文件名、制表符、pass 或 fail、制表符、被违反的规则键或连字符),内容是按02-split.jsonl、04-peritem.jsonl、06-kind.jsonl的顺序检查的结果。 - 创建
/root/tp-boundary/08_search.py,从头给GET /searchhandler 埋点。按shop.parse_query→shop.search_index→ 对每个结果调用shop.hydrate→shop.render的顺序调用,根GET /search(SERVER)之下有四个跨度:query.parse(INTERNAL)、index.search(CLIENT)、result.hydrate(INTERNAL)、response.render(INTERNAL)。循环要折叠起来,把result.hydrate.count、result.hydrate.total_ms、result.hydrate.max_ms作为属性加到result.hydrate上。转储默认路径为/root/tp-boundary/08-search.jsonl,用第 7 步的检查器在这个转储文件上运行时,必须输出OK。
参考
- 工作目录是
/root/tp-boundary。如果不存在,先创建。 - 带埋点的程序必须用
/opt/otel-lab/bin/python运行。系统python3中没有 OpenTelemetry。读取并统计转储文件的小脚本,用系统python3就够了。 - 公共接线是
/opt/app/tracelab/dump.py(provider、flush),材料是/opt/app/tracelab/tp_boundary/shop.py(没有埋点的订单处理代码)和/opt/app/tracelab/tp_boundary/gapstat.py(读取转储文件、计算并集、输出tree)。 - 常见错误:不删除转储文件就重新运行程序。转储是追加写入,跨度会累积,跟踪就变成两条。
- 常见错误:把空白读成“慢的区间”。空白是还没有跨度的区间,与自身耗时、关键路径是不同的问题。
- 这个 Pod 里既没有 Collector,也没有跟踪界面。判定只依据转储文件的结构,看比例而不是毫秒绝对值。
- OpenTelemetry — Traces · Tracing API 规范 · OpenTelemetry Python 埋点 · 库埋点指南 · W3C Trace Context
从只有一个跨度的跟踪开始
创建 /root/tp-boundary/01_root.py。用 /opt/app/tracelab/dump.py 的 provider 创建服务名称为 shop-api 的 provider,转储路径优先读取环境变量 TRACELAB_OUT,没有则使用 /root/tp-boundary/01-root.jsonl。在名为 POST /checkout 的一个 SERVER 跨度中,依次调用 tracelab.tp_boundary.shop 的 validate → load_cart → price_of(全部商品)→ charge → write_receipt,最后调用 flush()。然后用 /opt/otel-lab/bin/python 运行以生成转储文件,并在 /root/tp-boundary/01-root.txt 中写下 spans= 和 root_ms= 两行(原样使用从转储文件读到的值)。
系统 python3 中没有 OpenTelemetry。带埋点的程序必须用装有 otel 的 Python 运行。转储是追加写入(append),所以对同一个文件运行两次,跨度就会累积——重新运行之前先把它删除。想用肉眼查看转储文件,用 python3 /opt/app/tracelab/tp_boundary/gapstat.py tree <덤프>(占位符为转储文件)会很方便。
只放入自动埋点会提供的两个跨度,测量空白
创建 /root/tp-boundary/02_split.py。与第 1 步相同,但把数据库往返 load_cart 包进 cart.load 跨度,把发往外部的 charge 包进 payment.charge 跨度(自动埋点会替你创建的位置就是这两处)。转储默认路径为 /root/tp-boundary/02-split.jsonl。运行之后,在 /root/tp-boundary/02-gap.txt 中写下四行 root_ms=、covered_ms=、gap_ms=、gap_ratio=。covered_ms 是把根的子区间按并集相加得到的长度,gap_ratio 是(根 − 被覆盖的时间)÷ 根,写到小数点后第四位。
/opt/app/tracelab/tp_boundary/gapstat.py 中有 load、root_span、children、union_ns、ms。之所以用并集,是因为子跨度之间可能重叠——把重叠的区间数两次,被覆盖的时间就会比父跨度还长。在这一步,空白接近一半是正常的。
拆分空白大的区间,使其降到 5% 以下
创建 /root/tp-boundary/03_close.py。为消除第 2 步剩下的空白,把 validate 包进 order.validate,把商品价格查询的整个循环包进 price.lookup,把 write_receipt 包进 receipt.write 跨度。跨度名称要与这五个(order.validate、cart.load、price.lookup、payment.charge、receipt.write)和根 POST /checkout 完全一致。转储默认路径为 /root/tp-boundary/03-close.jsonl。运行之后,在 /root/tp-boundary/03-gap.txt 中写下与第 2 步相同的四行,并使 gap_ratio 小于 0.05。
关键是把二十四次循环包进一个跨度——这一步还不要给每次循环创建跨度。空白不会变成 0,因为启动和结束跨度本身就要花几微秒。按比例来看,这个大小可以忽略。
数一数每次循环都创建跨度会有多少个
创建 /root/tp-boundary/04_peritem.py。与第 3 步相同,但在 price.lookup 内,每查询一件商品就创建一个 price.item 跨度。转储默认路径为 /root/tp-boundary/04-peritem.jsonl。运行之后,在 /root/tp-boundary/04-count.txt 中写下三行 spans_per_request=、item_spans=、spans_per_1000_requests=。最后一行是每个请求的跨度数乘以 1000 得到的整数。
三行都要直接从转储文件里数出来填写。购物车大小由 /opt/app/tracelab/tp_boundary/shop.py 的 CART_SIZE 决定。商品有二十四件时是这个数,那么商品有两百件的订单会是多少个,在心里乘一乘——这就是下一步的原因。
把循环折叠为属性和事件,而不是跨度
创建 /root/tp-boundary/05_fold.py。去掉 price.item 跨度,改为给 price.lookup 跨度加上三个属性:price.lookup.count(查询次数)、price.lookup.total_ms(合计毫秒数)、price.lookup.max_ms(耗时最长的一次的毫秒数)。然后把耗时最长的商品记为名为 price.lookup.slowest 的事件,并在事件属性中放入 sku。转储默认路径为 /root/tp-boundary/05-fold.jsonl,跨度总数不得超过 8 个。
单次的耗时用 time.perf_counter() 自己测量。只留平均值,“二十四次里有一次慢了十倍”这个事实就消失了——所以要另外留下最大值,而那一次具体是什么,则用带有时间点的记录,也就是事件,写下来。事件用 span.add_event(이름, {속성})(占位符依次为事件名称、属性)添加。
用 SpanKind 标出边界
创建 /root/tp-boundary/06_kind.py。与第 5 步相同,但给所有跨度明确指定 SpanKind——进来的请求是 SERVER,发往进程之外的调用(cart.load、payment.charge、receipt.write)是 CLIENT,在进程内部划分出来的区间(order.validate、price.lookup)是 INTERNAL。转储默认路径为 /root/tp-boundary/06-kind.jsonl,运行之后,按开始时间顺序在 /root/tp-boundary/06-kinds.tsv 中为每个跨度写一行 <스팬이름><탭><kind>(占位符依次为跨度名称、制表符、kind),不要表头,共六行。
以后要区分“我们等待别人的时间”和“我们自己干活的时间”,依据就是 CLIENT 和 INTERNAL。数据库往返也是发往进程之外的调用,所以是 CLIENT。tsv 直接从转储文件中提取生成,就不会出现手写写错的情况。
把边界规则写成文件并编写检查器
在 /root/tp-boundary/budget.txt 中写三行——max_spans_per_request=8、max_gap_ratio=0.10、root_kind=SERVER。然后创建 /root/tp-boundary/check_budget.py。用 python3 check_budget.py <규칙파일> <덤프>(占位符依次为规则文件、转储文件)调用时,每违反一条规则就输出一行 VIOLATION <규칙키> <지금 값>(占位符依次为规则键、当前值)并以退出码 1 退出;全部遵守时,输出一行以 OK 开头的内容,并以退出码 0 退出。最后在 /root/tp-boundary/07-verdict.tsv 中写三行,每行是 <덤프파일이름><탭><pass|fail><탭><깨진 규칙키 또는 ->(占位符依次为转储文件名、制表符、pass 或 fail、制表符、被违反的规则键或连字符),内容是按 02-split.jsonl、04-peritem.jsonl、06-kind.jsonl 的顺序检查的结果。
计算空白直接用 /opt/app/tracelab/tp_boundary/gapstat.py 中的函数即可。之所以把规则单独放进文件,是因为上限一旦写死在代码里,每次评审都会被个人喜好重新争论。三个转储文件中有两个违反的是不同的规则——先猜一猜哪个违反了什么,再运行看看。
用同一套规则给第二个 handler 埋点
创建 /root/tp-boundary/08_search.py,从头给 GET /search handler 埋点。按 shop.parse_query → shop.search_index → 对每个结果调用 shop.hydrate → shop.render 的顺序调用,根 GET /search(SERVER)之下有四个跨度:query.parse(INTERNAL)、index.search(CLIENT)、result.hydrate(INTERNAL)、response.render(INTERNAL)。循环要折叠起来,把 result.hydrate.count、result.hydrate.total_ms、result.hydrate.max_ms 作为属性加到 result.hydrate 上。转储默认路径为 /root/tp-boundary/08-search.jsonl,用第 7 步的检查器在这个转储文件上运行时,必须输出 OK。
沿用前面七个步骤学到的顺序——每个进程边界都要有跨度,拆分空白大的区间,循环要折叠。搜索结果的数量由 shop.py 的 SEARCH_HITS 决定。检查器给出 VIOLATION 时,不要改规则,要改埋点。