☰
跨服务排查总靠猜?用 W3C Trace Context 串起一次请求,cpolar 给外部同学开只读链路视图
2026/10/14 15:09:13 网站建设 项目流程

跨服务排查总靠猜?用 W3C Trace Context 串起一次请求,cpolar 给外部同学开只读链路视图

线上出问题,最怕的不是报错,而是「每个服务的日志都正常,但拼起来就是不对」。

一个请求进来,网关转给 A 服务,A 服务调 B 服务,B 服务又去读写缓存和数据库。等你去查的时候,四五个服务各自有各自的日志文件,各有各的时间戳,靠时间先后硬猜是哪条请求对应哪一步。猜错一次,排查方向就跑偏,来回折腾半天。

我最近在本地联调一个两跳的服务链路时就被这个折腾过。后来用 W3C Trace Context 标准把一次请求串起来:请求进来带一个traceparent头,每个服务把同一个 trace id 往下传,日志里就能按 trace id 把所有环节对出来。

这篇文章写的是一个隔离环境里的最小实验提纲:用两个最小 HTTP 服务,手动串一次 trace 透传,再把链路视图做成只读页,最后用 cpolar 把这个只读页开给不在内网的同学核对。

说明:本文是可执行实验提纲,步骤和头部格式都是按规范写的。本轮没有实际安装、没有实际启动服务,所以文中不出现任何实测延迟或吞吐数值。你自己照步骤跑出来的结果才是真实结果。

图1:日志各写各的,缺少统一字段把同一次请求绑定在一起,只能靠时间戳硬猜。

1 为什么两个服务就能把排查搞崩

先别急着上 APM 平台。绝大多数「日志对不上」的场景,本质就一句话:没有一个稳定的字段能把同一次请求的所有环节绑定在一起。

常见的三种做法,各有各的难受:

  • 靠时间戳对。两个服务的机器时间不一定同步,几十毫秒的偏差就够你把两条不同的请求对到一起。
  • 靠请求参数对。用户的请求参数未必唯一,批量任务里重复率更高。
  • 靠前端发一个自增 ID。自增 ID 只覆盖同步调用的第一跳,往下传就容易断。

W3C Trace Context 要解决的就是这个问题。它定义了traceparent和tracestate两个 HTTP 头,把「这次请求的追踪上下文」标准化,任何实现了规范的服务都能接住并往下传。

我选它而不是某个厂商的 SDK,是因为它足够小、足够清楚。你不需要装一整套平台,只要两个服务都懂traceparent,日志就能对上。

2 traceparent 头到底长什么样

这是这篇文章最需要写准的一部分,格式错一个字符就对不上。按 W3C Trace Context 规范,traceparent的结构是:

traceparent: <version>-<trace-id>-<parent-id>-<trace-flags>

拆开看,四段用短横线连接:

  • version:版本号,2 位十六进制。当前规范是00。
  • trace-id:整条链路的 ID,32 位十六进制(16 字节)。这是跨服务对齐的关键字段,全链路共享同一个值。
  • parent-id:当前这一跳的 span ID,16 位十六进制(8 字节)。注意它指的是「发起本次调用的上游」的 span id。
  • trace-flags:追踪标志,2 位十六进制。01表示采样,00表示不采样。

举一个格式正确的例子:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

这里00是版本,4bf92f3577b34da6a3ce929d0e0e4736是 trace id,00f067aa0ba902b7是 parent id,01表示采样。

划重点:trace id 是 32 位、span/parent id 是 16 位,这两个长度写反了接收方直接丢弃,链路就断在那里。校验时优先看长度和十六进制字符。

图2:traceparent 四段结构,trace-id 32 位是跨服务对齐的关键,parent-id 16 位。

3 环境准备:两个最小 HTTP 服务

实验只需要 Python 3 和标准库,不引入框架也行;为了写起来清楚,这里用http.server起两个最小服务。先建一个目录:

mkdir -p trace-demo && cd trace-demo

接下来就是两个文件:一个是下游服务service-b.py,一个是上游服务service-a.py。

先想清楚谁调谁:A 收到的请求里如果带了traceparent,就沿用它并把当前 span 作为新的 parent 生成下游头;如果没带,就新建一条链路。B 收到后只负责把 trace 信息记进日志。

这一步的验证标准很简单:两个服务各自的日志里,出现同一串 trace id。

4 写下游服务:只负责接住并记录 trace

先写service-b.py。它的职责很单纯:收到请求,把traceparent解析出来,打印 trace id 和本跳 span id。

# service-b.py import json from http.server import BaseHTTPRequestHandler, HTTPServer def parse_traceparent(value): # 结构:version-traceid-parentid-flags parts = value.split("-") if len(parts) != 4: return None version, trace_id, parent_id, flags = parts # traceid 必须 32 位,parentid 必须 16 位 if len(trace_id) != 32 or len(parent_id) != 16: return None return { "version": version, "trace_id": trace_id, "parent_id": parent_id, "flags": flags, } class Handler(BaseHTTPRequestHandler): def do_GET(self): tp = self.headers.get("traceparent", "") parsed = parse_traceparent(tp) if parsed is None: # 没有有效 traceparent 时,明确记录缺失,便于排查 print("[service-b] traceparent 缺失或格式错误:", repr(tp), flush=True) self.send_response(400) self.end_headers() self.wfile.write(b"bad traceparent") return print("[service-b] 收到 trace_id=", parsed["trace_id"], " parent_id=", parsed["parent_id"], flush=True) self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps({ "service": "b", "trace_id": parsed["trace_id"], "parent_id": parsed["parent_id"], }).encode("utf-8")) if __name__ == "__main__": # 监听本机 9002,仅本机可见 HTTPServer(("127.0.0.1", 9002), Handler).serve_forever()

这里有个容易写错的地方:trace_id的校验是 32 位、parent_id是 16 位,别顺手都写成 32。如果 B 收到请求直接返回 400,先看打印出来的repr(tp),多半是上游生成格式不对,而不是 B 的问题。

5 写上游服务:生成并透传 traceparent

再写service-a.py。它要干两件事:如果没有traceparent就新建一条链路,然后把请求转发给 B,转发时必须带上traceparent。

# service-a.py import json import os import secrets import urllib.request from http.server import BaseHTTPRequestHandler, HTTPServer def new_traceparent(): # version=00, trace-id 16 字节, parent-id 8 字节, flags=01(采样) version = "00" trace_id = secrets.token_hex(16) # 32 位十六进制 span_id = secrets.token_hex(8) # 16 位十六进制 flags = "01" return f"{version}-{trace_id}-{span_id}-{flags}" def call_downstream(traceparent): req = urllib.request.Request( "http://127.0.0.1:9002/", headers={"traceparent": traceparent}, ) with urllib.request.urlopen(req, timeout=5) as resp: return json.loads(resp.read().decode("utf-8")) class Handler(BaseHTTPRequestHandler): def do_GET(self): # 上游如果已有 traceparent 就延续,否则新建一条 incoming = self.headers.get("traceparent") traceparent = incoming if incoming else new_traceparent() print("[service-a] 透传 traceparent=", traceparent, flush=True) try: downstream = call_downstream(traceparent) except Exception as exc: self.send_response(502) self.end_headers() self.wfile.write(str(exc).encode("utf-8")) return self.send_response(200) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps({ "service": "a", "traceparent": traceparent, "downstream": downstream, }).encode("utf-8")) if __name__ == "__main__": HTTPServer(("127.0.0.1", 9001), Handler).serve_forever()

注意 A 转发时用的是同一个 traceparent,也就是把当前这一跳的 span 作为下游看到的 parent。真正严格的实现里,每跳应该生成新的 span id 并把当前 span 保留在tracestate,但作为最小实验,先保证 trace id 一路不变,链路就能对上。

6 实测验证:一次请求串起两个服务

开两个终端,先把 B 跑起来,再跑 A:

# 终端 1 python3 service-b.py # 终端 2 python3 service-a.py

然后在第三个终端发一次请求:

curl -s http://127.0.0.1:9001/

你会看到 A 返回一段 JSON,里面带有它生成的traceparent。同时两个终端里会出现类似这样的日志:

[service-a] 透传 traceparent= 00-<32位>-<16位>-01 [service-b] 收到 trace_id= <32位> parent_id= <16位>

这里的关键是:A 日志里的 trace id 和 B 日志里的 trace id 必须是同一串。如果不一样,基本是转发时没把traceparent带上,检查call_downstream里 headers 那行。

图3:一次请求穿过两个服务,trace id 一路不变,两跳日志即可对齐。

再验证一遍「接得上」的链路:手动指定一个 traceparent 发过去,看两个服务是否沿用同一串。

curl -s -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \ http://127.0.0.1:9001/

如果 A 返回的traceparent里那串 trace id 还是4bf92f3577b34da6a3ce929d0e0e4736,说明透传生效了。这一步不是为了测试而测试,而是确认「外部传进来的上下文没有被覆盖」,这正是跨服务排查能对齐的前提。

7 把链路视图做成只读页

排查时把日志甩给同事看不方便,做一个只读页更直观。新增一个view.py,把最近几次请求的 trace 信息渲染成一个静态页面,并且只做成只读——只展示,不提供任何写入接口。

# view.py import json from http.server import BaseHTTPRequestHandler, HTTPServer # 演示用的静态数据;真实场景里由上面两个服务写入 RECORDS = [ {"hop": "a", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "00f067aa0ba902b7"}, {"hop": "b", "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736", "span_id": "a1b2c3d4e5f60718"}, ] PAGE = """<!doctype html> <html lang="zh-CN"> <head><meta charset="utf-8"><title>Trace 只读视图</title></head> <body> <h1>Trace 只读视图</h1> <p>仅展示,无写入能力。</p> <table border="1" cellpadding="6"> <tr><th>hop</th><th>trace_id</th><th>span_id</th></tr> {rows} </table> </body> </html>""" class Handler(BaseHTTPRequestHandler): def do_GET(self): rows = "".join( f"<tr><td>{r['hop']}</td><td>{r['trace_id']}</td>" f"<td>{r['span_id']}</td></tr>" for r in RECORDS ) html = PAGE.format(rows=rows) self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.end_headers() self.wfile.write(html.encode("utf-8")) def do_POST(self): # 只读页:显式拒绝任何写入 self.send_response(405) self.end_headers() if __name__ == "__main__": HTTPServer(("127.0.0.1", 9003), Handler).serve_forever()

启动它:

python3 view.py

浏览器打开http://127.0.0.1:9003,应该看到一张表格,两行记录共享同一个 trace id。到这一步,「一次请求被串起来」这件事就变成了一个可以被外部同学直接看的东西。

8 用 cpolar 把只读视图开给外部同学

现在的问题是:视图只跑在127.0.0.1:9003,外网访问不了。让同事进内网不现实,这时候用 cpolar 把本地端口映射成一个公网 HTTP 地址就行。

按 cpolar 官方文档,HTTP 隧道直接用命令行指定本地端口:

cpolar http 9003

如果本地还没配好账号,先在 cpolar Web UI(本机http://127.0.0.1:9200)登录,或者在命令行写入 token:

cpolar authtoken <你的-token>

命令回车后,终端会输出一段公网地址,形如https://xxxx.r3.cpolar.cn,把它发给外部同学即可。

几个提醒:

  • 免费套餐的随机公网地址会在 24 小时内变化,同事核对时最好当场发当次地址,不要用旧链接。
  • 只开这一个只读端口,不要把 9001、9002 一起映射出去,避免把上游服务也暴露了。
  • 想让地址固定下来,需要基础套餐或以上才能绑定固定二级子域名;临时核对用随机地址就够。

外部同学打开这个地址,看到的应该是第 7 步那张只读表格,而不是任何可交互的写入页面。这也是验证标准之一:cpolar 公网地址打开的必须是只读页。

图4:只读视图跑在 127.0.0.1:9003,经 cpolar 隧道映射成公网地址供外部同学核对。

9 常见误区与排查顺序

排查的时候按下面顺序走,能省不少时间:

  1. 先确认头格式。traceparent是不是 4 段、trace id 是否 32 位、span id 是否 16 位。格式不对,接收方会直接丢掉。
  2. 再确认是否真的透传。在 A 的转发代码里打印 headers,看看traceparent有没有带上。漏了这一步,B 收到的就是新生成的上下文。
  3. 再看 trace id 对不对得上。两个服务日志里 trace id 一致,链路才算串上。
  4. 最后才看只读页和公网地址。前面都对,但公网打不开,先检查本地9003能不能访问,再看 cpolar 隧道是否在线。
  5. 别忽视只读性。如果外部访问到的是可写入的接口,说明映射错了端口,立刻停掉重新指定。

10 总结

到这里,一个小实验就闭环了:两个最小服务通过 W3C Trace Context 的traceparent头把一次请求串起来,日志里可以通过同一个 trace id 对上每一跳;再把链路做成只读页,用 cpolar 开给不在内网的同学核对。

几个关键步骤捋一下:

  • 头格式是version-traceid-spanid-flags,trace id 32 位、span id 16 位,这两点写错链路必断。
  • 上游转发时要沿用同一个traceparent,trace id 才能一路不变。
  • 只读页只展示不写入,映射时只开这一个端口。

再往下扩展,可以把tracestate也接进来记录更多上下文,或者把只读页做成按 trace id 查询的形式。这样做的好处在排查场景里很明显:你不用再把一堆日志甩来甩去,给外部同学一个链接,他自己就能把链路对清楚。


本文为隔离环境中的可执行实验提纲,正文未在真实环境安装或启动任何服务,未记录实测数值。所有命令与头部格式以 W3C Trace Context 规范与 cpolar 官方文档口径为准。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询