TT Lab
开始
学习 学习路径 课程

分布式链路断掉的地方

服务端说 20 毫秒,我们测出 300 毫秒

在 TT Lab 中继续学习

目标

把同一个调用在客户端和服务端两边测量,拆解差值;把连接池等待纳入跨度边界;区分被中断的调用和被取消的调用并分别记录;把目标标识属性设计成低基数,再把这套规则原样应用到第二个客户端。

为什么重要

下游团队的 p99 和我们的 p99 不一样,不是因为谁错了,而是因为测量的是不同区间。服务端测的是 handler 打开着的时间,在它前后还有排队、序列化和传输。更糟的是连接池等待——如果在拿到连接之后才开始跨度,这段等待就不在任何跨度里,于是会产生把子跨度全加起来也解释不了根跨度的跟踪。超时会让配对错位。客户端放弃了,服务端仍会干完,所以同一条跟踪里会同时留下短的 CLIENT 跨度和长的 SERVER 跨度,这种形态本身就是资源在泄漏的信号。反过来,把用户离开造成的取消升为错误,错误率就会随用户的行为起伏。最后,如果把发出调用的目标原样写成地址,ID 和 Pod 名称就会混进属性值,让聚合变得不可能。

步骤

  1. 创建 /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 中,用制表符分隔写一行 <클라이언트 밀리초>、<서버 밀리초>、<차이>(占位符依次为客户端毫秒数、服务端毫秒数、差值)。保留到小数点后第三位。
  2. 创建 /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 个字)。
  3. 创建 /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 两行,写下各组中打开时间最长的跨度的毫秒数。
  4. 创建 /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。
  5. 创建 /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 个字)。
  6. 创建 /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 中,按属性名字典序,用制表符分隔写四行,每行是四个属性的名称和不同值的个数。
  7. 创建 /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、调用数、耗时合计的整数毫秒数)。调用数相同的,按地址字典序。
  8. 先在 /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/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 步用转储文件确认过的值。