01 为什么需要链路追踪
摘要:
当一个用户请求从浏览器出发,经过 API 网关、认证服务、订单服务、库存服务、支付服务、通知服务,最终返回结果时,如果响应时间从正常的 200ms 突然飙升到 5 秒,工程师如何定位瓶颈在哪个环节?在单体架构时代,一个 profiler 或一条日志就能回答这个问题;在微服务架构时代,请求分散在十几个进程、几十台机器上,传统的调试手段彻底失效。链路追踪(Distributed Tracing)正是为解决这个问题而生的技术。本文从微服务架构带来的”可观测性黑洞”出发,分析为什么日志和指标无法单独解决跨服务的延迟定位问题,然后回顾 Google Dapper 论文如何奠定了整个链路追踪领域的理论基础,最后梳理从 Dapper 到 Zipkin、Jaeger、SkyWalking、OpenTelemetry 的技术演进脉络。
第 1 章 微服务架构带来的调试困境
1.1 单体时代的幸福:调试是简单的
在单体架构(Monolithic Architecture)中,所有业务逻辑运行在同一个进程中。当出现性能问题时,工程师有丰富且高效的工具:
工具一:本地日志
所有模块的日志写入同一个文件,按时间排序。一个请求从开始到结束的所有日志集中在一起,通过 grep 就能追踪完整的执行路径:
2024-01-01 10:00:00.100 [req-123] AuthModule: 用户认证成功,耗时 5ms
2024-01-01 10:00:00.105 [req-123] OrderModule: 创建订单,耗时 50ms
2024-01-01 10:00:00.155 [req-123] InventoryModule: 扣减库存,耗时 30ms
2024-01-01 10:00:00.185 [req-123] PaymentModule: 发起支付,耗时 100ms
2024-01-01 10:00:00.285 [req-123] Response: 返回成功,总耗时 185ms
一眼就能看出:支付模块耗时最长(100ms),如果有性能问题,优先排查支付模块。
工具二:进程内 Profiler
JVM 上的 VisualVM、async-profiler,或者 Python 的 cProfile,都可以在进程级别生成火焰图或调用栈统计,精确到函数级别的 CPU/内存消耗。
工具三:本地调试器
IDE 中直接打断点、单步执行、查看变量值——这是最直接的调试手段,在单体架构中完全可行。
1.2 微服务时代的痛苦:调试变成了侦探工作
当系统从单体拆分为微服务后,上述三个工具全部失效或大幅退化:
日志分散:一个请求经过 10 个微服务,日志分布在 10 台不同的机器上,工程师需要分别登录 10 台机器查看日志,然后手动拼接出完整的调用链路。如果请求量很大(每秒数千个请求),从海量日志中找到同一个请求的所有日志几乎不可能——除非每条日志都携带了唯一的请求 ID(但即使有请求 ID,跨服务的日志聚合仍然需要额外的基础设施)。
Profiler 只能看到单服务:async-profiler 只能分析一个 JVM 进程的 CPU 消耗。如果延迟瓶颈不在当前服务内部,而是在当前服务等待下游服务响应时产生的(网络等待时间),Profiler 看到的只是 Thread.sleep 或 socket.read 的调用栈——它告诉你”当前线程在等待 I/O”,但不告诉你”它在等待哪个下游服务、那个下游服务为什么慢”。
本地调试不可行:微服务运行在分布式集群中(可能跨多个数据中心),你无法在 IDE 中同时对 10 个远程进程打断点。即使可以远程调试单个服务,也无法观察跨服务的调用时序关系。
一个真实的痛苦场景:
用户反馈:"下单页面很慢,加载要 5 秒"
工程师的排查过程(没有链路追踪):
1. 查看 API 网关日志:请求到达时间 10:00:00.000,响应时间 10:00:05.000,总耗时 5s
→ 问题确认,但不知道 5 秒花在哪里
2. 查看认证服务日志:处理耗时 10ms → 不是瓶颈
3. 查看订单服务日志:处理耗时 50ms → 不是瓶颈
4. 查看库存服务日志:处理耗时 30ms → 不是瓶颈
5. 查看支付服务日志:没有找到对应的请求日志!
→ 可能是支付服务没有收到请求?还是日志没来得及写入?
→ 也可能是订单服务到支付服务之间的网络延迟?
6. 检查订单服务的网络调用日志:发现订单服务调用支付服务时等待了 4.8 秒
→ 但为什么等待了这么久?是支付服务处理慢,还是网络问题?
7. 终于在支付服务的另一个日志文件中发现:支付服务调用第三方支付网关超时(4.5s)
整个排查过程:30 分钟 ~ 2 小时
如果有链路追踪:30 秒(打开 Trace 详情页,一眼看到支付网关 Span 耗时 4.5s)
1.3 日志和指标为什么不够
有人可能会问:如果在所有服务中统一添加结构化日志(带请求 ID),再加上 Prometheus 指标监控,是不是就够了?
日志的局限:
- 日志是事件级别的,不是请求级别的。即使所有日志都携带了请求 ID,要还原一个请求的完整调用链路,需要从多个服务的日志系统中按请求 ID 聚合,这个查询本身就很慢(全文搜索 + 跨服务聚合)
- 日志不携带耗时分布信息。日志可以记录”本服务处理耗时 50ms”,但不能记录”本服务中,30ms 花在数据库查询、15ms 花在调用下游服务、5ms 花在 JSON 序列化”
- 日志不携带父子关系。看到订单服务和支付服务的日志,你知道它们”可能有调用关系”,但不知道具体的调用层级(订单服务直接调用支付服务?还是经过了一个中间的风控服务?)
指标的局限:
- 指标是聚合数据,不是个体数据。指标告诉你”支付服务的 P99 延迟是 500ms”,但不告诉你”某个具体的请求为什么花了 5 秒”
- 指标不携带因果关系。你可以看到”订单服务延迟升高”和”支付服务延迟升高”同时发生,但不知道是”支付服务慢导致订单服务等待”还是”它们各自独立出了问题”
链路追踪填补的空白:
- 请求级别:每条 Trace 对应一个完整的用户请求,从入口到出口
- 层级关系:Span 的父子关系天然表达了”谁调用了谁”
- 耗时分布:每个 Span 有精确的开始时间和结束时间,直观展示延迟分布
- 跨服务关联:通过 Trace ID 将分散在不同服务中的 Span 关联成完整的调用树
第 2 章 Google Dapper:链路追踪的理论基石
2.1 Dapper 论文的背景
2010 年,Google 发表了论文 Dapper, a Large-Scale Distributed Systems Tracing Infrastructure,这篇论文奠定了整个分布式链路追踪领域的理论基础。几乎所有后来的链路追踪系统(Zipkin、Jaeger、SkyWalking、OpenTelemetry)都直接或间接地继承了 Dapper 的设计思想。
Dapper 诞生的背景是 Google 内部的微服务规模:一个典型的用户搜索请求会触发数千个 RPC 调用,涉及数百个不同的服务。在这样的规模下,传统的日志分析和指标监控根本无法有效定位性能问题。Google 需要一个系统,能够以极低的性能开销,记录每个请求的完整调用链路,并支持实时查询。
2.2 Dapper 的核心设计原则
Dapper 论文提出了三个核心设计原则,这三个原则至今仍是所有链路追踪系统的设计基石:
原则一:低开销(Low Overhead)
链路追踪系统本身不能显著影响业务系统的性能。Dapper 要求追踪的额外开销对 CPU 的影响 < 0.3%,对延迟的影响可忽略不计。
这个原则直接决定了链路追踪的两个关键设计决策:
- 采样(Sampling):不记录所有请求,只记录一部分(如 1/1024 或 1/100),大幅降低数据量和处理开销
- 异步上报:Span 数据不在请求处理的关键路径上同步发送,而是先写入本地缓冲区,由后台线程异步批量上报
原则二:应用透明(Application Transparency)
链路追踪不应该要求应用开发者修改业务代码。Dapper 通过在 Google 内部的 RPC 框架(Stubby)和线程库中植入追踪逻辑,实现了对应用代码完全透明的埋点——开发者不需要在代码中手动创建 Span 或传递 Trace ID,框架层自动处理。
这个原则深刻影响了后来的链路追踪系统设计:
- Apache SkyWalking 的 Java Agent 通过字节码增强实现无侵入埋点
- OpenTelemetry 的 Auto-Instrumentation 通过语言级别的自动注入实现类似效果
- Zipkin 的 Brave 库需要手动埋点,是对这个原则的部分妥协(但提供了 Spring Boot Starter 减少手动工作)
原则三:广泛部署(Ubiquitous Deployment)
链路追踪系统必须覆盖整个调用链路——如果调用链中有一个服务没有被追踪,整条链路就会”断裂”,无法看到完整的调用关系。Dapper 通过统一的 RPC 框架层植入,确保了几乎所有 Google 内部服务都被覆盖。
这个原则在开源社区的实践中面临更大挑战:不同团队使用不同的语言、不同的框架,统一覆盖的难度远大于 Google 内部。OpenTelemetry 通过多语言 SDK 和统一协议(OTLP)试图解决这个问题。
2.3 Dapper 的数据模型:Trace 和 Span
Dapper 定义了两个核心概念,成为后续所有链路追踪系统的基本数据模型:
Trace(追踪):一个 Trace 代表一个完整的请求生命周期,由一组 Span 组成。每个 Trace 有一个全局唯一的 Trace ID。
Span(跨度):一个 Span 代表一个”工作单元”——通常对应一次 RPC 调用、一次数据库查询、或一次 HTTP 请求。每个 Span 包含:
- Span ID:本 Span 的唯一标识
- Parent Span ID:父 Span 的 ID(根 Span 没有 Parent)
- 操作名(Operation Name):如
GET /api/orders、MySQL SELECT - 开始时间和持续时间
- 标签(Tags/Attributes):键值对形式的元数据(如
http.status_code=200、db.statement=SELECT...) - 事件/日志(Events/Logs):Span 执行过程中的离散事件
Span 之间通过 Parent Span ID 形成树状结构——根 Span(Root Span)代表请求的入口(如 API 网关),子 Span 代表被调用的下游服务:
Trace(Trace ID = abc123)
│
├── Span 1: API Gateway(Root Span)
│ ├── Span 2: Auth Service
│ ├── Span 3: Order Service
│ │ ├── Span 4: Inventory Service
│ │ └── Span 5: Payment Service
│ │ └── Span 6: Third-party Payment Gateway
│ └── Span 7: Notification Service
这个树状结构天然表达了”谁调用了谁”以及”每个环节花了多长时间”,是链路追踪解决”请求在哪里慢了”的核心数据结构。
2.4 Context Propagation:链路追踪的”血液循环系统”
链路追踪最核心的技术难点不是”如何记录 Span”,而是如何在跨服务调用时传递 Trace ID 和 Parent Span ID,使得下游服务能够知道”我属于哪个 Trace、我的父 Span 是谁”。
这个机制称为 Context Propagation(上下文传播)。
进程内传播:在同一个进程中,Trace Context(包含 Trace ID、当前 Span ID)通常存储在 ThreadLocal(Java)、Context(Go)、AsyncLocalStorage(Node.js)等线程/协程本地存储中。当代码执行到新的 Span 创建点时,从 ThreadLocal 中读取父 Span 的信息。
跨进程传播:当服务 A 调用服务 B 时,A 需要将 Trace Context 通过某种方式传递给 B。最常见的方式是通过 HTTP Header 或 RPC Metadata:
服务 A 发起 HTTP 请求到服务 B:
HTTP Request:
GET /api/inventory HTTP/1.1
Host: inventory-service:8080
traceparent: 00-abc123-span3id-01 ← W3C Trace Context 标准格式
sw8: 1-abc123-span3id-... ← SkyWalking 自定义格式
x-b3-traceid: abc123 ← Zipkin B3 格式
x-b3-spanid: span3id
x-b3-parentspanid: span1id
服务 B 收到请求后:
1. 从 HTTP Header 中提取 Trace Context
2. 创建新的 Span(Parent = span3id)
3. 将新 Span 的信息存入本地 ThreadLocal
4. 处理业务逻辑
5. 调用下游服务时,将更新后的 Trace Context 注入新的 HTTP Header
W3C Trace Context 标准
2020 年,W3C 发布了 Trace Context 标准(https://www.w3.org/TR/trace-context/),定义了统一的 HTTP Header 格式
traceparent和tracestate,解决了此前各家链路追踪系统(Zipkin 的 B3、SkyWalking 的 sw8、Jaeger 的 uber-trace-id)各自定义 Header 导致的互操作性问题。OpenTelemetry 默认使用 W3C Trace Context 标准。
第 3 章 链路追踪技术的演进脉络
3.1 从 Dapper 到开源生态
Dapper 论文发表后,开源社区迅速跟进,诞生了一系列链路追踪系统:
2012 年:Twitter Zipkin
Zipkin 是第一个广泛使用的开源链路追踪系统,由 Twitter 基于 Dapper 论文实现。Zipkin 采用”库埋点”的方式(通过 Brave 库在应用代码中手动或半自动创建 Span),数据存储在 Cassandra 或 Elasticsearch 中。Zipkin 的架构简洁,但需要侵入应用代码,且缺乏 APM(Application Performance Management)能力——它只做链路追踪,不做指标聚合和拓扑发现。
2015 年:Apache SkyWalking
Apache SkyWalking 由吴晟(Wu Sheng)发起,是国内最流行的 APM 系统。SkyWalking 的核心差异化优势是 Java Agent 无侵入埋点——通过 Java Instrumentation API 和 Byte Buddy 字节码增强技术,在类加载时自动修改目标类的字节码,注入追踪逻辑,应用代码完全不需要修改。SkyWalking 不仅做链路追踪,还提供服务拓扑发现、性能指标聚合、告警等完整的 APM 能力。
2017 年:Uber Jaeger
Jaeger 由 Uber 开源,后来捐赠给 CNCF(2019 年毕业)。Jaeger 最初基于 OpenTracing 标准,架构上与 Zipkin 类似,但在采样策略和存储扩展性上做了增强。Jaeger 支持自适应采样(Adaptive Sampling),能够根据流量自动调整采样率。
2019 年:OpenTelemetry 项目启动
OpenTelemetry(OTel)合并了此前分裂的 OpenTracing 和 OpenCensus 两个标准,成为 CNCF 的官方可观测性标准项目。OTel 不是一个具体的后端系统(不存储数据、不提供 UI),而是一套标准 + SDK + Collector:定义统一的数据模型和传输协议(OTLP),提供多语言 SDK 用于埋点和数据采集,通过 Collector 将数据转发到各种后端(SkyWalking、Jaeger、Zipkin、Grafana Tempo 等)。
3.2 主流链路追踪系统对比
| 维度 | Zipkin | Jaeger | SkyWalking | OTel + Tempo |
|---|---|---|---|---|
| 埋点方式 | Brave 库(半侵入) | OTel SDK / Jaeger Client | Java Agent(无侵入) | OTel SDK / Auto-Instrumentation |
| 数据模型 | Zipkin 自定义 | OpenTracing → OTel | SkyWalking 自定义(兼容 OTel) | OTel 标准 |
| 传播格式 | B3 Header | Uber-trace-id → W3C | sw8 Header(兼容 W3C) | W3C Trace Context |
| 存储后端 | Cassandra / ES / MySQL | Cassandra / ES / Kafka | ES / BanyanDB / MySQL | 对象存储(S3/GCS) |
| APM 能力 | 仅链路追踪 | 链路追踪 + 基础指标 | 完整 APM(链路+指标+拓扑+告警+Profiler) | 取决于后端选型 |
| 语言支持 | Java 为主 | Go / Java / Python / Node | Java 为主(Agent),多语言 SDK | 全语言覆盖 |
| 社区活跃度 | 维护模式 | CNCF 毕业,活跃 | Apache 顶级项目,活跃 | CNCF 最活跃项目之一 |
| 适用场景 | 轻量级链路追踪 | 云原生环境 | Java 生态 APM 全栈方案 | 标准化、多后端灵活选型 |
3.3 技术趋势:从”各自为战”到”标准统一”
链路追踪技术的演进有一条清晰的主线:从各家自定义标准,走向 OpenTelemetry 统一标准。
2015~2019 年:标准分裂期
- OpenTracing(CNCF 标准,只定义 API,不提供实现)
- OpenCensus(Google 主导,提供 SDK 实现)
- SkyWalking 自定义协议
- Zipkin B3 格式
- Jaeger uber-trace-id 格式
2019~至今:标准统一期
- OpenTelemetry 合并 OpenTracing 和 OpenCensus
- W3C Trace Context 成为 HTTP 传播的标准格式
- SkyWalking、Jaeger、Zipkin 纷纷增加 OTel 兼容支持
- 新的链路追踪后端(如 Grafana Tempo)原生支持 OTel
选型建议
- Java 为主的企业,追求开箱即用的全栈 APM:SkyWalking 是目前最成熟的选择,Java Agent 无侵入埋点的便利性无可替代
- 多语言、云原生环境,追求标准化和灵活性:OTel SDK + Collector + Grafana Tempo/Jaeger 是更灵活的组合
- 已有 ELK 或 Grafana 栈,追求低成本集成:OTel + Grafana Tempo(使用对象存储,成本极低)
参考资料
- Sigelman, B., et al. (2010). Dapper, a Large-Scale Distributed Systems Tracing Infrastructure. Google Technical Report. https://research.google/pubs/pub36356/
- W3C Trace Context Specification. https://www.w3.org/TR/trace-context/
- Apache SkyWalking 官方文档:https://skywalking.apache.org/docs/
- OpenTelemetry 官方文档:https://opentelemetry.io/docs/
- Zipkin 官方文档:https://zipkin.io/
- Jaeger 官方文档:https://www.jaegertracing.io/docs/
- Shkuro, Y. (2019). Mastering Distributed Tracing. Packt Publishing.
- Sridharan, C. (2018). Distributed Systems Observability. O’Reilly Media.
思考题
- OpenTelemetry(OTel)统一了 Traces、Metrics 和 Logs 的采集标准——替代了之前碎片化的工具(OpenTracing、OpenCensus、StatsD 等)。OTel 提供了多语言 SDK 和 Collector。在一个已经使用 Prometheus Client Library 和 Jaeger Client 的项目中,迁移到 OTel 的工作量有多大?迁移的收益是什么?
- OTel 的核心理念是’vendor-neutral’——采集的数据可以发送到任何后端(Jaeger、Zipkin、Prometheus、Datadog、Grafana Cloud 等)。这种中立性如何降低了厂商锁定的风险?但 OTel SDK 的配置复杂度是否增加了开发者的负担?
- OTel 的 Context Propagation 是分布式追踪的基础——每个请求携带 Trace Context(TraceID、SpanID、Trace Flags)在服务间传递。W3C Trace Context 是标准格式。如果调用链中某个服务不支持 W3C Trace Context(如使用 B3 格式的旧服务),链路会断裂。OTel 的 Propagator 如何支持多种格式?