5192 字
约 17 分钟
5
一文搞懂链路追踪:一个请求经过了哪些微服务,我们是怎么知道的?

一文搞懂链路追踪:一个请求经过了哪些微服务,我们是怎么知道的?

如果你做过一段时间的后端开发,大概率见过这样的项目:

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?

而这,也正是我们自己手写一个轻量级链路追踪系统时,需要一步一步解决的问题。

当你能够亲手把这些问题解决一遍,你掌握的就不再只是某个链路追踪工具的使用方式,而是它背后的设计原理。

一文搞懂链路追踪:一个请求经过了哪些微服务,我们是怎么知道的?
http://www.clxhxhhr.top/posts/410/
作者
clxstart
发布于
2026-09-02
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。