服务端说 20 毫秒,我们测出 300 毫秒
目标
把同一个调用在客户端和服务端两边测量,拆解差值;把连接池等待纳入跨度边界;区分被中断的调用和被取消的调用并分别记录;把目标标识属性设计成低基数,再把这套规则原样应用到第二个客户端。
为什么重要
下游团队的 p99 和我们的 p99 不一样,不是因为谁错了,而是因为测量的是不同区间。服务端测的是 handler 打开着的时间,在它前后还有排队、序列化和传输。更糟的是连接池等待——如果在拿到连接之后才开始跨度,这段等待就不在任何跨度里,于是会产生把子跨度全加起来也解释不了根跨度的跟踪。超时会让配对错位。客户端放弃了,服务端仍会干完,所以同一条跟踪里会同时留下短的 CLIENT 跨度和长的 SERVER 跨度,这种形态本身就是资源在泄漏的信号。反过来,把用户离开造成的取消升为错误,错误率就会随用户的行为起伏。最后,如果把发出调用的目标原样写成地址,ID 和 Pod 名称就会混进属性值,让聚合变得不可能。
步骤
- 创建
/root/tp-client/pair.py。转储路径读取TRACELAB_OUT,没有则为/root/tp-client/pair.jsonl。客户端服务名称是shop-api,下游是Backend("pricing", <덤프와 같은 디렉터리>/server.jsonl)(占位符为与转储文件相同的目录)。在 CLIENT 跨度POST /price内注入请求头并调用be.call("POST /price", 120, carrier),然后在与转储文件相同目录的pair.tsv中,用制表符分隔写一行<클라이언트 밀리초>、<서버 밀리초>、<차이>(占位符依次为客户端毫秒数、服务端毫秒数、差值)。保留到小数点后第三位。 - 创建
/root/tp-client/gap.py,把同一个调用做二十次(工作量为 30ms)。默认转储路径是/root/tp-client/gap.jsonl,服务端转储是同一目录下的gap-server.jsonl。每次调用,给 CLIENT 跨度POST /price加三个属性——gap.ms(客户端时间减去服务端时间)、gap.queue_ms(响应告知的排队时间)、gap.rest_ms(二者之差)。在与转储文件相同目录的gap.tsv中写二十行<번호>和<gap.ms>(占位符为序号),在gap-summary.txt中写median_gap_ms=(二十个值的中位数)和reason=(差值由什么构成,至少 100 个字)。 - 创建
/root/tp-client/pooled.py,把同一件事做两遍。默认转储路径是/root/tp-client/pooled.jsonl,服务端转储是同一目录下的pooled-server.jsonl。两遍都新建大小为 1 的ConnPool,由三个线程同时调用be.call("GET /stock", 80, carrier)。第一组在从池里拿到位置之后才打开 CLIENT 跨度narrow,第二组则先打开 CLIENT 跨度wide,再去拿位置,并留下属性pool.wait_ms和事件pool.acquired。在与转储文件相同目录的pool.tsv中,用narrow和wide两行,写下各组中打开时间最长的跨度的毫秒数。 - 创建
/root/tp-client/timeout.py。默认转储路径是/root/tp-client/timeout.jsonl,服务端转储是同一目录下的timeout-server.jsonl。在 CLIENT 跨度GET /stock内调用be.call("GET /stock", 400, carrier, timeout_ms=120),捕获抛出的超时,把跨度状态设为 ERROR,并把属性error.type写成timeout,rpc.timeout_ms写成120。用be.close()等待服务端工作结束后再flush(),然后重新读取两个转储文件,找出具有相同 trace_id 的配对,在与转储文件相同目录的orphan.tsv中,用制表符分隔写一行<trace_id>、<클라이언트 밀리초>、<서버 밀리초>、<판정>(占位符依次为 trace_id、客户端毫秒数、服务端毫秒数、判定)。服务端更长则判定为client-gave-up,否则为server-finished-first。 - 创建
/root/tp-client/cancel.py,留下两件事。默认转储路径是/root/tp-client/cancel.jsonl,服务端转储是同一目录下的cancel-server.jsonl。(1)在 CLIENT 跨度GET /recs中用be.submit("GET /recs", 300, carrier)发出调用,只等待 60ms 就放弃——不要动状态,把属性rpc.cancelled设为真,并留下事件rpc.cancelled。(2)在 CLIENT 跨度GET /promo中调用be.call("GET /promo", 20, carrier, fail=True),用record_exception记录抛出的异常,然后把状态设为 ERROR。在与转储文件相同目录的05-cancel.txt中写三行cancelled_status=、failed_status=、reason=(为什么要把两者区别开来记录,至少 100 个字)。 - 创建
/root/tp-client/target.py,发出/opt/app/tracelab/tp_client/plan.json中的cardinality调用八个。默认转储路径是/root/tp-client/target.jsonl,服务端转储是同一目录下的target-server.jsonl。CLIENT 跨度名称是<target>/<op>,加四个属性——rpc.service(target)、rpc.method(op)、server.address(target)、url.template(把 path 中只有订单 ID 的部分换成 plan.json 的placeholder)。地址中的host和订单 ID 不放进属性值。在与转储文件相同目录的cardinality.tsv中,按属性名字典序,用制表符分隔写四行,每行是四个属性的名称和不同值的个数。 - 创建
/root/tp-client/repeat.py,在一个 SERVER 跨度POST /checkout之下,发出/opt/app/tracelab/tp_client/plan.json中的checkout调用七个。默认转储路径是/root/tp-client/repeat.jsonl,服务端转储是同一目录下的repeat-server.jsonl。在根跨度上用属性rpc.client.calls写下发出的调用数,每个 CLIENT 跨度加上与第 6 步相同的四个属性。在与转储文件相同目录的repeat.tsv中,从调用数多的开始,用制表符分隔写<server.address>、<rpc.method>、<호출 수>、<걸린 시간 합(정수 밀리초)>(占位符依次为 server.address、rpc.method、调用数、耗时合计的整数毫秒数)。调用数相同的,按地址字典序。 - 先在
/root/tp-client/client-policy.json中写下到目前为止的判断——span_kind、boundary(池等待在跨度之内还是之外)、required_attributes(第 6 步的四个属性)、forbidden_value_sources(不能用作属性值的材料字段名称们)、timeout_status、cancel_status。然后创建/root/tp-client/second.py,读取该文件,把/opt/app/tracelab/tp_client/plan.json中的search调用四个通过第二个客户端发出。默认转储路径是/root/tp-client/second.jsonl,服务端转储是同一目录下的second-server.jsonl,使用大小为 1 的ConnPool,把等待的时间作为跨度内的pool.wait_ms留下。在与转储文件相同目录的second.tsv中,按操作名称字典序,每行三列写<rpc.method>、<호출 수>、<서로 다른 server.address 수>(占位符依次为 rpc.method、调用数、不同 server.address 的个数)。
参考
- 工作目录是
/root/tp-client。如果不存在,先创建。 - 带埋点的程序必须用
/opt/otel-lab/bin/python运行。系统python3中没有 OpenTelemetry。 - 转储路径始终先读取环境变量
TRACELAB_OUT,没有时才使用题目中写的默认路径。服务端转储文件和表格文件也要写在与转储文件相同的目录里——评分器会在自己的临时目录里把同一个程序再运行一次来比对。 - 转储文件是追加写入,所以在程序开始处用
open(OUT, "w").close()清空。 - 材料在
/opt/app/tracelab/tp_client/中——backend.py(模拟下游服务)、pool.py(连接池)、plan.json(发出的调用列表)。前两个文件只读,不要修改。 - 常见错误:没有
be.close()就调用flush()。如果工作线程还在运行,服务端跨度就不会留在转储文件里。 - 常见错误:在 CLIENT 跨度之外注入请求头。那样服务端跨度不会成为该跨度的子级。
- Semantic Conventions — RPC 跨度 · Trace API — 跨度状态 · OpenTelemetry — 跨度种类 · OpenTelemetry Python — Instrumentation · Semantic Conventions — 通用属性
在两边测量同一个调用
创建 /root/tp-client/pair.py。转储路径读取 TRACELAB_OUT,没有则为 /root/tp-client/pair.jsonl。客户端服务名称是 shop-api,下游是 Backend("pricing", <덤프와 같은 디렉터리>/server.jsonl)(占位符为与转储文件相同的目录)。在 CLIENT 跨度 POST /price 内注入请求头并调用 be.call("POST /price", 120, carrier),然后在与转储文件相同目录的 pair.tsv 中,用制表符分隔写一行 <클라이언트 밀리초>、<서버 밀리초>、<차이>(占位符依次为客户端毫秒数、服务端毫秒数、差值)。保留到小数点后第三位。
Backend.call 返回的 dict 中的 server_ms 就是服务端跨度打开着的时间。客户端时间只要在调用前后用 time.perf_counter() 测量即可。请求头必须在跨度内部注入,服务端跨度才会成为这个跨度的子级。最后调用 be.close() 之后再 flush()。
这个差值由什么构成
创建 /root/tp-client/gap.py,把同一个调用做二十次(工作量为 30ms)。默认转储路径是 /root/tp-client/gap.jsonl,服务端转储是同一目录下的 gap-server.jsonl。每次调用,给 CLIENT 跨度 POST /price 加三个属性——gap.ms(客户端时间减去服务端时间)、gap.queue_ms(响应告知的排队时间)、gap.rest_ms(二者之差)。在与转储文件相同目录的 gap.tsv 中写二十行 <번호> 和 <gap.ms>(占位符为序号),在 gap-summary.txt 中写 median_gap_ms=(二十个值的中位数)和 reason=(差值由什么构成,至少 100 个字)。
Backend.call 的响应 dict 中,除了 server_ms 还有 queue_ms——这是从请求到达到 handler 被占用为止的时间,在服务端跨度之外。如果是真正的服务,这个值会通过响应头返回。中位数是把二十个值排序,再把中间两个取平均。
把在连接池中等待的时间纳入跨度
创建 /root/tp-client/pooled.py,把同一件事做两遍。默认转储路径是 /root/tp-client/pooled.jsonl,服务端转储是同一目录下的 pooled-server.jsonl。两遍都新建大小为 1 的 ConnPool,由三个线程同时调用 be.call("GET /stock", 80, carrier)。第一组在从池里拿到位置之后才打开 CLIENT 跨度 narrow,第二组则先打开 CLIENT 跨度 wide,再去拿位置,并留下属性 pool.wait_ms 和事件 pool.acquired。在与转储文件相同目录的 pool.tsv 中,用 narrow 和 wide 两行,写下各组中打开时间最长的跨度的毫秒数。
ConnPool.lease() 会 yield 出等待的毫秒数——with pool.lease() as waited:。两组的差别只是两行代码的顺序,但在跟踪里看起来完全不同。如果两组共用一个池,结果会混在一起,所以每组都要新建。线程用 threading.Thread 创建,并全部 join()。
在转储文件中找出被中断的调用的配对
创建 /root/tp-client/timeout.py。默认转储路径是 /root/tp-client/timeout.jsonl,服务端转储是同一目录下的 timeout-server.jsonl。在 CLIENT 跨度 GET /stock 内调用 be.call("GET /stock", 400, carrier, timeout_ms=120),捕获抛出的超时,把跨度状态设为 ERROR,并把属性 error.type 写成 timeout,rpc.timeout_ms 写成 120。用 be.close() 等待服务端工作结束后再 flush(),然后重新读取两个转储文件,找出具有相同 trace_id 的配对,在与转储文件相同目录的 orphan.tsv 中,用制表符分隔写一行 <trace_id>、<클라이언트 밀리초>、<서버 밀리초>、<판정>(占位符依次为 trace_id、客户端毫秒数、服务端毫秒数、判定)。服务端更长则判定为 client-gave-up,否则为 server-finished-first。
Backend.call 的超时会以 concurrent.futures.TimeoutError 抛出。如果不调用 be.close() 就 flush(),服务端跨度不会留在转储文件里,就找不到配对。转储文件是每行一个 JSON,所以可以直接用 json.loads 读取。两个跨度的长度差,就是这一步的要点。
把被取消的调用与错误区别开来记录
创建 /root/tp-client/cancel.py,留下两件事。默认转储路径是 /root/tp-client/cancel.jsonl,服务端转储是同一目录下的 cancel-server.jsonl。(1)在 CLIENT 跨度 GET /recs 中用 be.submit("GET /recs", 300, carrier) 发出调用,只等待 60ms 就放弃——不要动状态,把属性 rpc.cancelled 设为真,并留下事件 rpc.cancelled。(2)在 CLIENT 跨度 GET /promo 中调用 be.call("GET /promo", 20, carrier, fail=True),用 record_exception 记录抛出的异常,然后把状态设为 ERROR。在与转储文件相同目录的 05-cancel.txt 中写三行 cancelled_status=、failed_status=、reason=(为什么要把两者区别开来记录,至少 100 个字)。
没有动状态的跨度,会在转储文件里留成 UNSET。两个状态值要打开转储文件确认之后再写进文件——凭空编造会与转储文件对不上。异常通过 from tracelab.tp_client.backend import BackendError 导入的类来捕获。
把目标标识属性定成低基数
创建 /root/tp-client/target.py,发出 /opt/app/tracelab/tp_client/plan.json 中的 cardinality 调用八个。默认转储路径是 /root/tp-client/target.jsonl,服务端转储是同一目录下的 target-server.jsonl。CLIENT 跨度名称是 <target>/<op>,加四个属性——rpc.service(target)、rpc.method(op)、server.address(target)、url.template(把 path 中只有订单 ID 的部分换成 plan.json 的 placeholder)。地址中的 host 和订单 ID 不放进属性值。在与转储文件相同目录的 cardinality.tsv 中,按属性名字典序,用制表符分隔写四行,每行是四个属性的名称和不同值的个数。
plan.json 的 limits 会告诉你每个属性可以有多少种值。host 里有 Pod 后缀,path 里有订单 ID,原样放进去,每次调用的值都会不同。目标有两个,所以 Backend 也要为每个目标各建一个并复用。
在客户端一侧显现出多次调用同一个目标
创建 /root/tp-client/repeat.py,在一个 SERVER 跨度 POST /checkout 之下,发出 /opt/app/tracelab/tp_client/plan.json 中的 checkout 调用七个。默认转储路径是 /root/tp-client/repeat.jsonl,服务端转储是同一目录下的 repeat-server.jsonl。在根跨度上用属性 rpc.client.calls 写下发出的调用数,每个 CLIENT 跨度加上与第 6 步相同的四个属性。在与转储文件相同目录的 repeat.tsv 中,从调用数多的开始,用制表符分隔写 <server.address>、<rpc.method>、<호출 수>、<걸린 시간 합(정수 밀리초)>(占位符依次为 server.address、rpc.method、调用数、耗时合计的整数毫秒数)。调用数相同的,按地址字典序。
分组的键有两个,目标和操作——这就是需要低基数属性的原因。在一个根跨度上写下调用数,不用展开子级,就能筛出扇出很宽的请求。耗时合计是把每个包住 CLIENT 调用的区间相加后四舍五入得到的整数。
把规则写成文件,应用到第二个客户端
先在 /root/tp-client/client-policy.json 中写下到目前为止的判断——span_kind、boundary(池等待在跨度之内还是之外)、required_attributes(第 6 步的四个属性)、forbidden_value_sources(不能用作属性值的材料字段名称们)、timeout_status、cancel_status。然后创建 /root/tp-client/second.py,读取该文件,把 /opt/app/tracelab/tp_client/plan.json 中的 search 调用四个通过第二个客户端发出。默认转储路径是 /root/tp-client/second.jsonl,服务端转储是同一目录下的 second-server.jsonl,使用大小为 1 的 ConnPool,把等待的时间作为跨度内的 pool.wait_ms 留下。在与转储文件相同目录的 second.tsv 中,按操作名称字典序,每行三列写 <rpc.method>、<호출 수>、<서로 다른 server.address 수>(占位符依次为 rpc.method、调用数、不同 server.address 的个数)。
属性名称不要在程序里重新写,要遍历规则文件的 required_attributes 来加上——这就是“应用规则”的含义。forbidden_value_sources 里写 plan.json 中不能原样使用的字段名称。两个状态是在第 4、5 步用转储文件确认过的值。