一文搞懂链路追踪:一个请求经过了哪些微服务,我们是怎么知道的?
如果你做过一段时间的后端开发,大概率见过这样的项目:
Controller
↓
Service
↓
Mapper
↓
MySQL
出了问题也比较简单。
接口报错?
看日志。
SQL 慢?
看慢查询。
某个方法执行时间太长?
打个断点,或者加几行日志看看。
但是,当项目从一个单体应用变成微服务之后,事情就没这么简单了。
假设现在有这样一个下单接口:
用户
↓
订单服务
↓
商品服务
↓
库存服务
↓
支付服务
↓
MySQL / Redis / MQ
用户点击了一次:
POST /order/create
结果接口花了 5 秒钟。
问题来了:
这 5 秒到底慢在哪里?
是订单服务慢?
还是库存服务慢?
还是 Redis 卡了?
还是某条 SQL 执行了 3 秒?
又或者支付服务调用第三方接口超时了?
如果每个服务都只有自己的日志,我们可能只能这样排查:
订单服务日志找一下……
↓
拿着时间去商品服务继续找……
↓
再去库存服务找……
↓
再去支付服务找……
服务少的时候还能接受。
如果一次请求经过了十几个甚至几十个服务呢?
这时候就需要今天的主角:
链路追踪(Distributed Tracing)。
一、什么是链路追踪?
先不要急着背定义。
我们可以把链路追踪理解成:
给一次请求发一张“身份证”,然后记录它一路经过了哪些服务、调用了哪些方法、分别花了多长时间。
比如用户发起一次下单请求:
POST /order/create
系统给这次请求生成一个唯一编号:
TraceId = abc123
接下来,无论这个请求跑到订单服务、商品服务还是库存服务,都把这个 abc123 带过去。
于是我们就可以通过 TraceId 把散落在不同服务里的调用记录串起来。
最终得到:
TraceId: abc123
用户请求
│
├── OrderService 1200ms
│
├──── ProductService 200ms
│
├──── StockService 700ms
│ └── MySQL 650ms
│
└──── PaymentService 250ms
这时候问题就非常明显了:
StockService 700ms
↓
MySQL 650ms
那就优先去查库存服务的数据库操作。
这就是链路追踪最核心的价值之一:
把一个分布式请求的完整执行过程还原出来。
二、为什么微服务特别需要链路追踪?
单体项目里,一次请求通常发生在一个 JVM 里面:
浏览器
↓
Controller
↓
Service
↓
Mapper
↓
MySQL
所有日志基本都在一个应用里。
但是微服务不一样。
一次请求可能变成:
┌── 商品服务 ── MySQL
│
用户 → 网关 → 订单服务 ── 库存服务 ── Redis
│
└── 支付服务 ── 第三方支付
这里最大的麻烦是:
一次业务请求被拆散到了多个应用、多个线程,甚至多台服务器上。
订单服务不知道库存服务内部做了什么。
库存服务也不知道自己收到的请求最开始是哪个用户触发的。
如果没有一个东西把它们串起来,我们看到的只是:
订单服务:执行了一次 createOrder
库存服务:执行了一次 deductStock
支付服务:执行了一次 pay
但我们不知道:
这三条日志到底是不是同一个请求产生的。
所以链路追踪首先要解决一个非常核心的问题:
怎么证明不同服务里的这些操作属于同一次请求?
答案就是:
TraceId
三、第一个核心概念:TraceId
假设用户请求订单服务:
POST /order/create
订单服务收到请求以后,生成:
TraceId = 7f8a91
接下来调用库存服务时,把它一起传过去:
POST /stock/deduct
TraceId: 7f8a91
库存服务再调用其他服务时,继续携带:
TraceId: 7f8a91
于是整条调用链都拥有同一个 TraceId:
用户
↓
订单服务
TraceId = 7f8a91
↓
商品服务
TraceId = 7f8a91
↓
库存服务
TraceId = 7f8a91
↓
支付服务
TraceId = 7f8a91
所以:
TraceId 用来标识“一整次请求链路”。
你甚至可以简单粗暴地理解成:
TraceId = 一次请求的身份证号码
只要 TraceId 一样,我们就知道:
这些调用属于同一条链路。
但是,仅仅有 TraceId 还不够。
为什么?
四、只有 TraceId 为什么不行?
假设一次请求的调用关系是:
订单服务
├── 商品服务
├── 库存服务
│ └── Redis
└── 支付服务
如果我们只记录 TraceId:
TraceId = abc123 订单服务
TraceId = abc123 商品服务
TraceId = abc123 库存服务
TraceId = abc123 Redis
TraceId = abc123 支付服务
我们只能知道:
它们属于同一个请求。
但还有一个问题:
Redis 到底是谁调用的?
是订单服务直接调用 Redis?
还是库存服务调用 Redis?
我们不知道。
所以除了 TraceId,还需要另一个东西:
Span
五、第二个核心概念:Span
在链路追踪系统中,一次具体的调用或者操作,可以抽象成一个:
Span
比如:
订单服务处理请求
可以是一个 Span。
调用库存服务
可以是一个 Span。
执行 MySQL
也可以是一个 Span。
访问 Redis
同样可以是一个 Span。
所以一次 Trace 通常包含很多 Span。
例如:
Trace
│
├── Span:OrderController
│
├── Span:ProductService
│
├── Span:StockService
│
│ └── Span:Redis
│
└── Span:PaymentService
这里可以记住一句非常重要的话:
Trace 表示整条调用链,Span 表示调用链中的一次具体操作。
如果把 Trace 比作一次旅行:
北京 → 济南 → 南京 → 上海
那么:
整趟旅行 = Trace
北京 → 济南 = Span
济南 → 南京 = Span
南京 → 上海 = Span
这样就很好理解了。
六、SpanId 又是什么?
既然每一个 Span 都代表一个操作,那我们自然需要区分不同 Span。
于是就有:
SpanId
例如:
TraceId = abc123
订单服务
SpanId = 001
商品服务
SpanId = 002
库存服务
SpanId = 003
Redis
SpanId = 004
支付服务
SpanId = 005
因此:
TraceId
回答的是:
你属于哪一次请求?
而:
SpanId
回答的是:
你是这次请求里的哪一个操作?
可以把它们理解成:
TraceId = 家庭编号
SpanId = 家庭成员编号
例如:
家庭:10086
爸爸:001
妈妈:002
儿子:003
大家的家庭编号一样,但成员编号不同。
七、ParentSpanId:调用关系终于出来了
现在还有最后一个问题。
我们知道:
001 = 订单服务
002 = 商品服务
003 = 库存服务
004 = Redis
005 = 支付服务
但还是不知道:
004 Redis
到底是谁调用的。
所以 Span 通常还需要记录:
ParentSpanId
也就是:
我的父节点是谁?
例如:
订单服务
SpanId = 001
ParentSpanId = null
订单服务调用商品服务:
商品服务
SpanId = 002
ParentSpanId = 001
订单服务调用库存服务:
库存服务
SpanId = 003
ParentSpanId = 001
库存服务调用 Redis:
Redis
SpanId = 004
ParentSpanId = 003
订单服务调用支付服务:
支付服务
SpanId = 005
ParentSpanId = 001
现在把这些数据放到一起:
TraceId = abc123
SpanId ParentSpanId Service
----------------------------------
001 null Order
002 001 Product
003 001 Stock
004 003 Redis
005 001 Payment
只需要根据 ParentSpanId 找父节点,我们就可以把整棵树还原出来:
Order [001]
│
├── Product [002]
│
├── Stock [003]
│ └── Redis [004]
│
└── Payment [005]
看到这里,其实你已经理解链路追踪最核心的一部分了。
它没有想象中那么神秘。
本质上就是:
TraceId
+
SpanId
+
ParentSpanId
通过这些信息,把分散在不同服务里的调用重新拼成一棵完整的调用树。
八、一个 Span 到底记录什么?
真正的 Span 肯定不可能只记录:
TraceId
SpanId
ParentSpanId
否则我们只能知道调用关系,却不知道性能情况。
通常还会记录:
TraceId
SpanId
ParentSpanId
ServiceName
OperationName
StartTime
EndTime
Duration
Status
Tags
例如:
{
"traceId": "abc123",
"spanId": "003",
"parentSpanId": "001",
"serviceName": "stock-service",
"operationName": "deductStock",
"startTime": 1720000000000,
"duration": 700,
"status": "SUCCESS"
}
于是链路追踪系统就可以告诉我们:
stock-service
deductStock()
耗时:700ms
状态:SUCCESS
如果再记录 SQL:
MySQL
UPDATE stock SET count = count - 1
耗时:650ms
那问题就更明显了。
九、最关键的问题:TraceId 怎么跨服务传递?
这是理解链路追踪真正重要的一步。
假设:
订单服务
↓ HTTP
库存服务
订单服务里面已经有:
TraceId = abc123
但是库存服务是另外一个 JVM。
订单服务 JVM 里的变量,库存服务当然不可能直接读取。
那怎么办?
答案其实非常朴素:
把 TraceId 放进请求里传过去。
例如 HTTP Header:
POST /stock/deduct
X-Trace-Id: abc123
X-Span-Id: 001
库存服务收到 HTTP 请求之后:
读取 Header
↓
拿到 TraceId
↓
创建自己的 Span
于是:
订单服务
TraceId = abc123
SpanId = 001
↓ HTTP Header
库存服务
TraceId = abc123
SpanId = 002
ParentSpanId = 001
这样 Trace 就从一个服务传播到了另一个服务。
这个过程有一个非常重要的名字:
Context Propagation
也就是:
上下文传播。
十、什么叫 Trace Context?
这里又会遇到一个词:
Trace Context
其实不用把它想得很复杂。
它本质上就是:
当前链路执行到这里时,需要继续向下传递的信息。
例如我们可以定义:
public class TraceContext {
private String traceId;
private String spanId;
}
订单服务收到请求:
创建 TraceContext
得到:
traceId = abc123
spanId = 001
订单服务调用库存服务之前:
TraceContext
↓
写入 HTTP Header
↓
发送 HTTP 请求
库存服务:
HTTP Header
↓
读取 TraceId
↓
重新创建 TraceContext
于是整个过程就是:
Service A
TraceContext
↓
HTTP Header
↓
Service B
TraceContext
↓
HTTP Header
↓
Service C
链路信息就是这样一棒一棒传下去的。
像接力赛一样。
十一、那 ThreadLocal 又是干什么的?
看到这里,很多 Java 开发者会想到一个问题:
每个方法都传
TraceContext,是不是太麻烦了?
比如:
createOrder(traceContext);
deductStock(traceContext);
pay(traceContext);
saveOrder(traceContext);
这样代码会被链路追踪逻辑严重污染。
所以在一个 JVM 内部,我们通常希望:
当前线程
↓
自动拿到 TraceContext
这时候 Java 开发者非常熟悉的东西就出现了:
ThreadLocal
例如:
public class TraceContextHolder {
private static final ThreadLocal<TraceContext> HOLDER =
new ThreadLocal<>();
public static void set(TraceContext context) {
HOLDER.set(context);
}
public static TraceContext get() {
return HOLDER.get();
}
public static void remove() {
HOLDER.remove();
}
}
请求进来:
TraceContextHolder.set(context);
后面的代码:
TraceContext context = TraceContextHolder.get();
就可以直接拿到当前 Trace。
于是 JVM 内部的传播大致可以变成:
HTTP Request
↓
Filter / Interceptor
↓
创建 TraceContext
↓
ThreadLocal
↓
Controller
↓
Service
↓
DAO
请求结束:
TraceContextHolder.remove();
这里一定要注意 remove()。
因为 Web 服务器的工作线程通常会被线程池复用。
如果不清理 ThreadLocal,就可能产生上下文污染,甚至带来内存相关问题。
十二、到这里,我们已经能做一个最简单的链路追踪了
现在尝试把整个流程串起来。
用户请求:
POST /order/create
订单服务收到请求。
发现 Header 里面没有 TraceId。
说明:
这是整条链路的起点
于是生成:
TraceId = T10001
SpanId = S001
保存:
TraceContextHolder
接着订单服务调用库存服务。
发送请求之前,把:
TraceId = T10001
SpanId = S001
写入 HTTP Header。
库存服务收到:
TraceId = T10001
ParentSpanId = S001
然后生成自己的:
SpanId = S002
库存服务又访问 Redis:
TraceId = T10001
Redis SpanId = S003
ParentSpanId = S002
最终收集到:
T10001 S001 null OrderService
T10001 S002 S001 StockService
T10001 S003 S002 Redis
后端根据父子关系组装:
OrderService
└── StockService
└── Redis
恭喜。
一个极简版链路追踪系统的核心模型,其实已经出现了。
十三、那 Byte Buddy、Java Agent、字节码插桩又是什么?
看到这里,你可能发现一个严重的问题。
如果我们自己写:
startSpan();
try {
// 原来的业务逻辑
} finally {
endSpan();
}
那岂不是每一个方法都得修改?
比如:
public void createOrder() {
Span span = tracer.startSpan("createOrder");
try {
// 原业务代码
} finally {
tracer.endSpan(span);
}
}
这当然不现实。
我们真正希望的是:
业务代码完全不知道链路追踪的存在。
比如原来的代码还是:
public void createOrder() {
orderService.create();
}
但是 JVM 实际执行时变成:
开始记录 Span
↓
createOrder()
↓
结束记录 Span
这就涉及:
Java Agent
Byte Buddy
字节码增强
Instrumentation
它们的作用可以暂时简单理解为:
在不修改业务源代码的情况下,偷偷在目标方法执行前后插入链路追踪逻辑。
例如原方法:
public void createOrder() {
System.out.println("创建订单");
}
经过增强之后,可以理解成:
public void createOrder() {
tracer.startSpan();
System.out.println("创建订单");
tracer.endSpan();
}
当然,真正的实现远比这个复杂。
但学习链路追踪的时候不要一上来就陷进 Byte Buddy。
先理解:
Trace
Span
TraceId
SpanId
ParentSpanId
TraceContext
Context Propagation
再去学习:
Java Agent
Instrumentation
Byte Buddy
思路会清晰很多。
十四、完整的链路追踪系统到底长什么样?
如果继续往下做,一个简化版系统大概可以拆成:
用户请求
↓
Application
↓
Java Agent
↓
Tracer
↓
TraceContext / Span
↓
Reporter
↓
Collector
↓
Storage
↓
UI
分别是什么意思?
Application
真正运行的业务系统:
order-service
stock-service
payment-service
Java Agent
负责自动增强业务代码。
例如自动监控:
Spring MVC
Feign
RestTemplate
JDBC
Redis
Tracer
链路追踪的核心组件。
负责:
创建 Trace
创建 Span
生成 TraceId
生成 SpanId
维护父子关系
TraceContext
维护当前请求的链路上下文:
TraceId
SpanId
Reporter
负责把采集到的 Span 发送出去。
例如:
HTTP
Kafka
gRPC
Collector
负责接收不同服务发送来的 Span。
例如:
订单服务 ─┐
库存服务 ─┼──→ Collector
支付服务 ─┘
Storage
负责保存 Trace 数据:
Elasticsearch
ClickHouse
MySQL
当然,真正生产级系统通常会采用更适合海量时序/追踪数据的存储方案。
UI
最终把链路展示出来:
TraceId: abc123
OrderService 1200ms
├── ProductService 200ms
├── StockService 700ms
│ └── MySQL 650ms
└── PaymentService 250ms
开发者看到这个页面,就可以快速分析整条调用链。
十五、我们真正要实现的东西是什么?
如果自己手写一个玩具版链路追踪系统,我建议不要一开始就想着:
我要造一个 SkyWalking。
那基本会把自己劝退。
我们可以先完成一个非常小的目标:
用户请求
↓
Service A
↓
Service B
↓
Service C
做到:
1. 自动生成 TraceId
2. 自动生成 SpanId
3. 维护 ParentSpanId
4. 使用 ThreadLocal 保存 TraceContext
5. HTTP 调用时传播 TraceContext
6. 收集 Span
7. 统计每个 Span 的执行时间
8. 根据 ParentSpanId 还原调用树
只要这些功能能够跑通,你对链路追踪的理解就已经和单纯“会用一个监控平台”完全不同了。
因为你开始知道:
它为什么能看到这些数据。
十六、最后再用一个故事理解链路追踪
假设一个快递从北京发往深圳。
快递单号:
TraceId = SF10086
整个运输过程:
北京仓库
↓
北京转运中心
↓
武汉转运中心
↓
深圳转运中心
↓
深圳派送站
每经过一个节点,都产生一条记录:
Span
例如:
Span 001:北京仓库
Span 002:北京转运中心
Span 003:武汉转运中心
Span 004:深圳转运中心
Span 005:深圳派送站
每个节点都记录:
什么时候到达
什么时候离开
停留多久
从哪里来
下一站去哪里
于是当用户问:
为什么我的快递三天还没到?
系统查:
TraceId = SF10086
发现:
北京仓库 2h
北京转运中心 3h
武汉转运中心 48h ← 异常
深圳转运中心 2h
马上就知道:
武汉转运中心卡了 48 小时。
微服务链路追踪做的事情,本质上非常类似。
只不过运输的不是快递。
而是:
一次请求。
十七、总结:真正需要记住的只有这几个东西
如果这是你第一次接触链路追踪,不需要马上研究复杂源码。
先牢牢记住下面这条主线:
用户发起请求
↓
生成 TraceId
↓
创建第一个 Span
↓
TraceContext 保存上下文
↓
ThreadLocal 在当前线程传播
↓
HTTP Header 跨服务传播
↓
下游服务创建新的 Span
↓
通过 ParentSpanId 建立父子关系
↓
所有 Span 上报 Collector
↓
存储
↓
根据 TraceId 查询
↓
根据 ParentSpanId 组装调用树
↓
展示完整链路
几个核心名词可以浓缩成:
| 概念 | 一句话理解 |
|---|---|
| Trace | 一次完整的分布式请求 |
| TraceId | 整条请求链路的唯一 ID |
| Span | 链路中的一次具体操作 |
| SpanId | 当前操作的唯一 ID |
| ParentSpanId | 当前操作是谁调用的 |
| TraceContext | 当前链路需要携带的上下文 |
| Context Propagation | 把链路上下文传给下一个服务 |
| ThreadLocal | 在 JVM 当前线程中保存 TraceContext |
| Java Agent | 在不改业务代码的情况下进行增强 |
| Byte Buddy | 可以帮助我们完成字节码增强的工具 |
| Collector | 收集各个服务上报的 Span |
| Storage | 保存链路数据 |
所以,链路追踪真正的核心并不是“页面上画出一棵漂亮的调用树”。
它真正解决的问题是:
在一个请求跨线程、跨进程、跨服务之后,我们依然能够知道它从哪里来、经过了哪里、每一步做了什么、耗费了多少时间,以及哪里发生了异常。
理解这一点之后,再去学习 SkyWalking、Jaeger、Zipkin、OpenTelemetry,很多以前看起来很神秘的概念都会突然变得熟悉。
因为无论具体实现怎么变化,最底层都绕不开几个核心问题:
怎么生成 Trace?
怎么创建 Span?
怎么维护上下文?
怎么跨服务传播?
怎么自动采集?
怎么上报?
怎么存储?
怎么把 Span 重新组装成 Trace?
而这,也正是我们自己手写一个轻量级链路追踪系统时,需要一步一步解决的问题。
当你能够亲手把这些问题解决一遍,你掌握的就不再只是某个链路追踪工具的使用方式,而是它背后的设计原理。