分布式链路追踪
这一课你将能做到
- 用自己的话讲清 Trace、Span、Span 上下文传播 三者的关系,并画出一次判题请求的完整 Span 树
- 对比 head-based 与 tail-based 采样的权衡,给出一个生产可用的折中策略
- 在本机用 OTel Java Agent + Jaeger 跑通一条完整链路,并说明 Jaeger / Tempo 等后端在架构中的位置
为什么要学
判题链路是典型的异步多跳场景:用户提交 → backend 收单 → 写 MySQL → 发 Kafka → worker 消费 → 沙箱执行 → 回写结果 → 更新 Redis。用户问「我的判题为什么 30 秒还没出结果」,没有链路追踪时,你只能在每个服务里分别翻日志,靠时间戳和 submissionId 人肉拼接——一次排查半小时起步。
有了链路追踪,你打开 Jaeger 搜 traceId,整棵调用树直接展开:backend 收单 20ms、Kafka 发送 5ms、队列排队 25 秒、沙箱执行 4.8 秒。瓶颈一目了然。这就是链路追踪的价值:把「总耗时很长」变成「第几跳贡献了多少耗时」。
面试里「你们怎么做链路追踪」「traceId 怎么跨服务传递」「采样怎么选」都是高频题,本课给到能背的程度。
先建立图像
| 术语 | 人话 |
|---|
| Trace | 一次端到端请求的「整条故事」,有全局唯一的 traceId |
| Span | 故事里的一章:某服务内的一段操作(有名字、起止时间、属性、状态) |
| Parent/Child Span | 调用关系:HTTP 客户端 Span 是下游服务端 Span 的父 |
| Context Propagation | 把 trace 上下文塞进请求头(W3C traceparent)传给下游 |
| 采样(Sampling) | 决定哪些请求的记录被保留(全量太贵,必须取舍) |
判题链路的 Span 树:
Trace (traceId=4bf92f35...)
└─ Span A backend "POST /api/judge/submissions" 120ms
├─ Span B MySQL "INSERT submissions" 15ms
└─ Span C Kafka "send judge-events" 5ms
Trace (同一个 traceId 延续)
└─ Span D worker "consume judge-events" 25400ms
├─ Span E sandbox "execute java Main" 4800ms ← 判题本体
└─ Span F MySQL "UPDATE submission_results" 20ms
瓶颈定位:D 和 A 之间的时间差 = 队列排队 25 秒 ← 真正问题
跟着做
步骤 1:起 Jaeger 后端
在上一课的 docker-compose.yml 里追加:
jaeger:
image: jaegertracing/all-in-one:1.58
ports:
- "16686:16686" # UI
- "4318:4318" # OTLP HTTP 接收
environment:
- COLLECTOR_OTLP_ENABLED=true
docker compose up -d 后打开 http://localhost:16686 就是 Jaeger UI。
步骤 2:用 OTel Java Agent 启动 backend
下载 agent 并启动(Windows PowerShell):
# 下载一次即可
Invoke-WebRequest -Uri "https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar" -OutFile "opentelemetry-javaagent.jar"
# 启动应用时挂上 agent
java -javaagent:./opentelemetry-javaagent.jar `
-Dotel.service.name=coderoadmap-backend `
-Dotel.exporter.otlp.endpoint=http://localhost:4318 `
-Dotel.traces.sampler=parentbased_traceidratio `
-Dotel.traces.sampler.arg=1.0 `
-jar target/backend.jar
1.0 表示本地全采样(100%),便于学习;生产会调低,见步骤 5。
步骤 3:发一个请求,在 Jaeger 里找到它
curl -X POST http://localhost:8080/api/judge/submissions \
-H "Content-Type: application/json" \
-d "{\"problemId\":42,\"language\":\"java\",\"code\":\"class Main{}\"}"
打开 Jaeger UI → Service 选 coderoadmap-backend → Find Traces。点开一条 Trace,应看到 HTTP Span、JDBC Span(INSERT)、Kafka Span 的层级关系,每个 Span 的耗时条长短一目了然。
步骤 4:手动补一个业务 Span
自动插桩覆盖了 HTTP/JDBC/Kafka 边界,但「判题编译」这类关键业务步骤值得手动补:
public JudgeResult judge(Submission s) {
Span span = Span.current() // 从当前上下文创建子 Span
.spanBuilder("judge.compile")
.setAttribute("problem.id", s.getProblemId()) // 低基数属性
.setAttribute("language", s.getLanguage())
.startSpan();
try (Scope scope = span.makeCurrent()) {
return compile(s);
} catch (Exception e) {
span.setStatus(StatusCode.ERROR, e.getMessage());
span.recordException(e); // UI 里标红
throw e;
} finally {
span.end(); // 千万别漏
}
}
注意三件事:属性低基数(不要塞 submissionId 之外的任何用户输入);异常要 recordException;end() 放 finally。
步骤 5:设计生产采样策略
环境策略:
dev/local: 100%(调试期间全保留)
prod: head 采样 5% 兜底 + tail 强化保留
tail 强化规则(在 Collector 配置,见第 7 课):
- 任何含 ERROR 状态的 Trace → 保留
- 总耗时 > 2 秒的 Trace → 保留
- 其余按 5% 随机保留
要点:纯随机采样会漏掉「正好没采到的故障请求」,所以错误和慢请求要强化保留。
步骤 6:看懂 traceparent 请求头
curl -H "traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01" \
http://localhost:8080/api/judge/submissions
格式:版本(00) - traceId(32位hex) - parentSpanId(16位hex) - flags(01=已采样)。你的 backend 收到这个头后,会继续在这个 traceId 下创建子 Span——这就是「传播」的全部秘密:一个 HTTP 头。
讲清楚要点
- 一次请求跨多个进程,需要共享
traceId 才能拼出完整故事——传播靠 W3C traceparent 头,标准且跨语言。
- Span 有起止时间和父子关系,所以能定位瓶颈跳——不是只知道「总时长」,而是「每一跳各多少」。
- 上下文必须沿 HTTP/MQ/线程传递,任何一环断掉都会「断链」——断链的典型现象:一个请求在 Jaeger 里变成多个互不关联的小 Trace。常见断点:Nginx 剥头、异步线程池没传递、Kafka 消息没带 header。
- 全量追踪贵,所以要采样;纯随机采样会漏故障,所以错误和慢请求要强化保留——生产标准做法是 head 低采样 + tail 按错误/耗时保留。
- Span 属性也占存储,同样要低基数、禁 PII——用户输入的代码、报错全文放日志,不进 Span 属性。
- 后端(Jaeger/Tempo)可换,应用侧优先 OTel API + OTLP 导出——换后端只改 Collector 配置,不动业务代码。
- 指标发现「群体变差」,链路解释「个体路径」——告警不能用采样后的 Trace 算(会漏),SLO 必须基于全量指标。
你可能会懵的地方
-
Trace 和 Span 哪个大?
Trace 是整次请求(大),Span 是其中一段操作(小)。一个 Trace 含多个 Span,靠 parentSpanId 组成树。
-
没采样到的请求是不是「没发生」?
不是。指标仍在统计它(指标通常不采样),只是这条请求没有完整链路可查。所以 SLO 用指标算,不能用链路算。
-
head 采样和 tail 采样的本质区别?
head:请求入口就决定采不采,实现简单、开销小,但决定时还不知道这个请求后来会不会出错。tail:请求结束后看结果再决定保留与否(错误、超时必留),效果好但需要在 Collector 里缓存整棵 Trace,资源开销大。
-
Kafka 这种异步链路怎么传 traceId?
标准做法:生产者把 traceparent 写进 Kafka 消息 header,消费者取出后继续链路。OTel agent 对 Kafka 客户端自动做了这件事;手动消费时注意别自己覆盖了 header。
-
为什么我的 Jaeger 里一个请求变成好几个小 Trace?
断链了。检查:下游服务是否也挂了 agent;中间是否有代理/Nginx 剥掉了 traceparent;异步处理是否跨了没包装的线程池。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 只在 backend 打日志,不传 trace 头 | 跨服务无法拼接 | 全链路 W3C Trace Context 传播 |
| 生产 100% 采样 | 存储与网络成本爆炸 | head 低采样 + tail 错误/慢请求强化 |
| Span 属性塞 userId、代码原文 | 存储膨胀、PII 泄漏 | 低基数属性;细节放受控日志 |
| 用采样后的 Trace 算成功率告警 | 漏报、数据不准 | 告警基于全量指标,Trace 只用于定位 |
@Async 线程池不传递 Context | 异步部分断链 | 用 OTel 包装的 executor(见第 5 课) |
卡点急救
- Jaeger UI 里搜不到自己的服务:确认
OTEL_EXPORTER_OTLP_ENDPOINT 指向 Jaeger 的 4318(HTTP)或 4317(gRPC)端口,且 Jaeger 启动时开了 COLLECTOR_OTLP_ENABLED=true;应用日志里搜 otel 关键字看 agent 是否加载成功。
- Trace 有但全是断的:按上面「断链」清单逐环排查——最先查网关/Nginx 是否透传
traceparent,其次查异步线程。
- Span 数量爆炸、Jaeger 很卡:检查是否把高并发循环里的每次迭代都建了手动 Span;手动 Span 只加在业务关键步骤,框架边界交给自动插桩。
面试常问
-
traceId 是怎么跨服务传递的?
背这个版本:入口服务生成或沿用上游的 traceId,通过 W3C 标准的 traceparent HTTP 头(格式:版本-traceId-parentSpanId-flags)传给下游;下游解析后在同一个 traceId 下创建子 Span。Kafka 等异步场景把 traceparent 写进消息 header。进程内部则通过 ThreadLocal(或 Context API)在线程内传播,跨线程池要显式包装。
-
链路追踪为什么要采样?head 和 tail 采样的区别?
全量追踪成本高(每次请求可能产生几十个 Span,大流量下存储和网络扛不住)。head 采样在请求入口按固定比例决定,简单但可能漏掉「后来才出错」的请求;tail 采样等请求结束后按结果决定(错误/超时必留),保故障样本但需要在 Collector 缓存整棵 Trace,成本高。生产常用组合:head 低采样兜底 + tail 强化保留关键样本。
-
怎么定位一次慢请求的根因?
指标(P99 升高)确认范围 → 用 traceId 或慢 Trace 列表找到具体请求 → 在 Span 树上找耗时最长的 Span(瀑布图最长条)→ 用同 traceId 查该时间窗的结构化日志看细节和堆栈。
自测三问
- 画出判题链路(backend → Kafka → worker → 沙箱)的 Span 树,标出队列排队时长应该从哪里读出来。
- 为什么 SLO 必须基于指标而不能基于采样后的 Trace 成功率?
- 如果 Nginx 把
traceparent 头剥掉了,你在 Jaeger 里会看到什么现象?
小结一句话
Span 拼成 Trace,Trace 串起 Metrics 与 Logs——跨服务排障靠标准传播加上合理的采样策略。
延伸