CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
可观测性入门/课时/分布式链路追踪
RawBlame
课时目录12 节

可观测性入门

  1. 01可观测性三支柱
  2. 02指标与 SLI
  3. 03结构化日志
  4. 04分布式链路追踪
  5. 05插桩与埋点
  6. 06监控与告警
  7. 07遥测与 OpenTelemetry
  8. 08Spring 可观测性
  9. 09SLO 与错误预算
  10. 10仪表盘设计
  11. 11故障排查流程
  12. 12综合练习

分布式链路追踪

Span、采样、Jaeger/Zipkin 概念。

35 分钟

分布式链路追踪

这一课你将能做到

  1. 用自己的话讲清 Trace、Span、Span 上下文传播 三者的关系,并画出一次判题请求的完整 Span 树
  2. 对比 head-based 与 tail-based 采样的权衡,给出一个生产可用的折中策略
  3. 在本机用 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 手动带一个追踪头:

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 头。

讲清楚要点

  1. 一次请求跨多个进程,需要共享 traceId 才能拼出完整故事——传播靠 W3C traceparent 头,标准且跨语言。
  2. Span 有起止时间和父子关系,所以能定位瓶颈跳——不是只知道「总时长」,而是「每一跳各多少」。
  3. 上下文必须沿 HTTP/MQ/线程传递,任何一环断掉都会「断链」——断链的典型现象:一个请求在 Jaeger 里变成多个互不关联的小 Trace。常见断点:Nginx 剥头、异步线程池没传递、Kafka 消息没带 header。
  4. 全量追踪贵,所以要采样;纯随机采样会漏故障,所以错误和慢请求要强化保留——生产标准做法是 head 低采样 + tail 按错误/耗时保留。
  5. Span 属性也占存储,同样要低基数、禁 PII——用户输入的代码、报错全文放日志,不进 Span 属性。
  6. 后端(Jaeger/Tempo)可换,应用侧优先 OTel API + OTLP 导出——换后端只改 Collector 配置,不动业务代码。
  7. 指标发现「群体变差」,链路解释「个体路径」——告警不能用采样后的 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 课)

卡点急救

  1. Jaeger UI 里搜不到自己的服务:确认 OTEL_EXPORTER_OTLP_ENDPOINT 指向 Jaeger 的 4318(HTTP)或 4317(gRPC)端口,且 Jaeger 启动时开了 COLLECTOR_OTLP_ENABLED=true;应用日志里搜 otel 关键字看 agent 是否加载成功。
  2. Trace 有但全是断的:按上面「断链」清单逐环排查——最先查网关/Nginx 是否透传 traceparent,其次查异步线程。
  3. 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 查该时间窗的结构化日志看细节和堆栈。

自测三问

  1. 画出判题链路(backend → Kafka → worker → 沙箱)的 Span 树,标出队列排队时长应该从哪里读出来。
  2. 为什么 SLO 必须基于指标而不能基于采样后的 Trace 成功率?
  3. 如果 Nginx 把 traceparent 头剥掉了,你在 Jaeger 里会看到什么现象?

小结一句话

Span 拼成 Trace,Trace 串起 Metrics 与 Logs——跨服务排障靠标准传播加上合理的采样策略。

延伸

  • OpenTelemetry:Traces 概念
  • W3C Trace Context 规范
  • Jaeger 官方文档
  • 下一课《插桩与埋点》:学会决定哪些信号框架自动给、哪些必须自己埋。
加载本节练习…
← 上一课结构化日志下一课 →插桩与埋点