线上接口突然变慢了,用户反馈说点一下要转好几秒。打开监控一看,数据库CPU飙到了90%。可数据库里的SQL那么多,到底哪一条才是真正的罪魁祸首?这时候,Jaeger派上了用场。不需要猜,一张火焰图就能告诉你时间花在了哪里。更妙的是,每个Span的持续时间会把“哪一段操作最折磨人”钉得明明白白。

一、从一次线上事故说起

昨天下午三点,订单服务突然开始超时。我第一反应是数据库出问题了,登录RDS控制台,慢查询数量确实暴涨。但慢日志里记录了几十条SQL,每条看着都有可能。有的走了全表扫描,有的写了复杂的子查询,有的还关联了五六张表。到底先优化哪一个?靠经验去猜效率太低。好在我们服务里接入了Jaeger,只需要打开Jaeger的页面,找到对应的调用链,拉出火焰图,最宽的那一块就是花费时间最多的地方。

有趣的是,火焰图里最宽的并不是数据库查询,而是一个外部HTTP调用。调用的是第三方物流接口,平时也就几毫秒,那一次竟然耗时12秒。如果当时只顾着优化数据库,方向就完全错了。这正好说明了用数据说话的重要性。借助Jaeger,你能直接看到整个请求时间都消耗在了哪个环节,是数据库慢查询,还是外部调用延迟,又或者是中间处理逻辑太笨重。

二、Jaeger里的数据长什么样

Jaeger是开源的分布式链路追踪系统。理解它之前,先明白两个基础概念:Trace和Span。一条Trace就代表一次完整请求从入口到结束的整个路径。而Span是这条路径里的一小段操作,比如查询数据库、调用外部接口、写缓存等。每个Span都有开始时间、结束时间以及一些自定义属性。

打个比方,你去办一张银行卡。全程是一个Trace,而“取号”、“等待叫号”、“填写资料”、“窗口办理”、“拿到卡片”这些步骤就是一个个Span。每个步骤花了多久,加起来是多少,一目了然。

比如在Go语言里,我们通常这样使用Jaeger SDK创建Span,下面是一个Go语言示例:

package main

import (
    "fmt"
    "time"

    "github.com/opentracing/opentracing-go"
    "github.com/uber/jaeger-client-go"
    "github.com/uber/jaeger-client-go/config"
)

func initTracer() opentracing.Tracer {
    // 配置Jaeger Agent地址,这里用默认的本地地址
    cfg := config.Configuration{
        ServiceName: "order-service",
        Sampler: &config.SamplerConfig{
            Type:  "const", // 固定采样:所有请求都记录,测试环境常用
            Param: 1,       // 采样比例 1 表示 100%
        },
        Reporter: &config.ReporterConfig{
            LogSpans: true, // 控制台打印,方便调试
        },
    }
    tracer, _, err := cfg.NewTracer()
    if err != nil {
        panic("Jaeger初始化失败: " + err.Error())
    }
    return tracer
}

func handleRequest() {
    tracer := initTracer()
    // 创建一个根Span,代表一次完整的请求
    span := tracer.StartSpan("handleRequest")
    defer span.Finish() // 请求结束时必须Finish,否则持续时间不准确

    // 模拟业务处理耗时 100ms
    time.Sleep(100 * time.Millisecond)
    fmt.Println("请求处理完成")
}

这个示例里,StartSpan创建了一个叫handleRequest的Span,defer span.Finish()保证函数退出时记录结束时间。Jaeger会自动把这个Span的持续时间显示在界面上,也会传给火焰图做展示。

三、火焰图怎么看

Jaeger的火焰图很直观。横轴代表时间,从左到右是时间流逝的方向。每一个色块就是一个Span,色块越长,说明这个操作耗时越久。整个火焰图的最上层是根Span,下面是它的子Span。子Span又会有自己的子Span,一层层往下展开。

还是拿上面的例子,如果一次请求被拆分成三个子操作:查数据库、调外部API、写Redis缓存,那么火焰图看起来就像这样:

请求handleRequest
  ├── 查询用户表 (280ms)
  ├── 调用外部积分API (1500ms)
  └── 写入Redis缓存 (30ms)

你会发现“调用外部积分API”这个色块最宽,占了整个请求的80%以上。那瓶颈就是它。

看火焰图有个小诀窍:不要急着看最底层那些密密麻麻的小色块,先从根Span下面找最宽的那个子Span,然后一层层往下追踪,直到找到最里面最耗时的那个点。因为总耗时是所有子Span共同叠加的,宽色块一定意味着它所在的分支很慢。

另外,火焰图上的颜色并不代表性能好坏,它们只是用来区分不同的服务或不同的Span类型。不要因为某个色块是红色就觉得它有问题,判断标准只有宽度和持续时间。

四、用Span持续时间做精确判断

火焰图给了我们一个全局视野,但有时候需要精确到某个Span的执行时间。Jaeger里每个Span都会记录duration,表示从开始到结束的毫秒数。这个duration是排查问题的硬指标,比感觉可靠得多。

在实际开发中,我们总会给Span打上业务标签,比如SQL语句、HTTP URL、错误信息、数据库系统类型等等。这样在Jaeger里就能快速筛选出慢Span,也可以根据标签信息直接定位到具体是哪一条SQL、哪一个接口。

下面是一个Go语言示例,模拟一个包含数据库查询和外部调用的业务函数,并给Span添加关键信息:

package main

import (
    "net/http"
    "time"

    "github.com/opentracing/opentracing-go"
    "github.com/opentracing/opentracing-go/ext"
)

func createOrder(tracer opentracing.Tracer, orderID string) {
    // 创建一个根Span,代表这个函数
    span := tracer.StartSpan("createOrder")
    defer span.Finish()

    // 给Span打上业务标签,方便后续搜索
    span.SetTag("order.id", orderID)
    span.SetTag("db.type", "mysql")

    // ---- 执行数据库查询 ----
    dbSpan := tracer.StartSpan("queryOrder", opentracing.ChildOf(span.Context()))
    // 模拟慢SQL耗时 200ms
    time.Sleep(200 * time.Millisecond)
    // 记录SQL语句和结果
    dbSpan.SetTag("sql", "SELECT * FROM orders WHERE id = ?")
    dbSpan.SetTag("error", "false")
    dbSpan.Finish()

    // ---- 调用外部API ----
    apiSpan := tracer.StartSpan("callThirdPartyAPI", opentracing.ChildOf(span.Context()))
    req, _ := http.NewRequest("GET", "https://api.example.com/check", nil)
    // 使用标准扩展标签记录HTTP信息
    ext.HTTPMethod.Set(apiSpan, req.Method)
    ext.HTTPUrl.Set(apiSpan, req.URL.String())
    // 记录一条日志,表示开始调用
    apiSpan.LogKV("start", "calling external api")
    // 模拟外部服务延迟 1200ms
    time.Sleep(1200 * time.Millisecond)
    apiSpan.SetTag("http.status_code", 200)
    ext.HTTPStatusCode.Set(apiSpan, 200)
    apiSpan.Finish()
}

这里用了opentracing.ChildOf把子Span挂到根Span下面,形成父子关系。用SetTag记录SQL、URL、状态码等关键信息,用LogKV记录时间点上的事件。有了这些信息,Jaeger不仅能告诉你时间去了哪里,还能告诉你为什么慢。

注意,Span持续时间也会受到机器时钟影响。在分布式系统中,如果服务部署在不同机器上,且系统时间没有用NTP对齐,Span的开始、结束时间计算会出现偏差,甚至出现负数。所以生产环境务必做好时间同步。

五、真实示例:定位慢查询

假设我们的用户列表接口最近很慢。在Jaeger中打开这个接口的Trace,火焰图显示根Span下面有一个叫findAllUsers的Span特别宽,有3.4秒。点开这个Span,看到SQL标签是SELECT * FROM users,没有加任何索引。接下来用Go语言配合GORM执行这个查询,同时把SQL记录到Span中:

package main

import (
    "github.com/opentracing/opentracing-go"
    "gorm.io/driver/mysql"
    "gorm.io/gorm"
)

func findUsers(tracer opentracing.Tracer) {
    // 创建子Span,代表这次数据库查询
    span := tracer.StartSpan("findAllUsers")
    defer span.Finish()

    // 连接数据库,DSN格式:用户名:密码@协议(地址)/数据库名
    dsn := "root:password@tcp(127.0.0.1:3306)/mydb?charset=utf8mb4&parseTime=True"
    db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
    if err != nil {
        // 记录连接失败的错误
        span.SetTag("error", true)
        span.LogKV("message", "database connection failed", "detail", err.Error())
        return
    }

    // 记录SQL模板和数据库类型
    span.SetTag("db.system", "mysql")
    span.SetTag("db.statement", "SELECT * FROM users")

    var users []User
    // 执行查询,注意这里缺少索引时是全表扫描
    if err := db.Find(&users).Error; err != nil {
        span.SetTag("error", true)
        span.LogKV("message", "query failed", "detail", err.Error())
        return
    }

    // 查询完成后记录返回行数
    span.SetTag("rows", len(users))
}

实际定位时,通过Jaeger看到SQL语句后,去数据库执行EXPLAIN SELECT * FROM users,发现type=ALL,说明没走索引,全表扫描了。于是在users表的status字段上加了一个普通索引,再次压测,这个Span的持续时间从3.4秒降到了50毫秒。这就是一次典型的借助Jaeger定位慢查询的流程。

不过,慢查询不一定都是SQL本身的问题。数据库连接池耗尽、锁等待、磁盘IO高也都会让一条简单SQL变慢。这时候你可以在Span上加更多标签,比如“连接池等待耗时”和“执行耗时”,区分出哪一部分是瓶颈。Jaeger不会解决SQL优化问题,但它能精准告诉你问题发生在哪一层,剩下的事情再交给对应工具去处理。

六、外部调用瓶颈怎么找

外部调用比数据库更隐蔽,因为服务端不是我们的,问题可能出在网络延迟、对方接口处理慢、或者我们自己的客户端配置不对。火焰图上如果外部调用的Span特别宽,点开查看标签里的URL和状态码,就能判断方向。比如状态码200但耗时很长,很可能就是对方业务处理慢;如果状态码是504或者连接超时,那就是网络或网关问题。

下面是一个Go语言示例,演示如何封装一个HTTP客户端,在调用外部API时自动创建子Span,并记录关键信息:

package main

import (
    "io/ioutil"
    "net/http"
    "time"

    "github.com/opentracing/opentracing-go"
    "github.com/opentracing/opentracing-go/ext"
)

func callExternalAPI(tracer opentracing.Tracer, url string) ([]byte, error) {
    // 创建子Span,代表一次外部HTTP调用
    span := tracer.StartSpan("http-request")
    defer span.Finish()

    // 设置标准HTTP标签
    ext.HTTPMethod.Set(span, "GET")
    ext.HTTPUrl.Set(span, url)

    client := &http.Client{
        Timeout: 5 * time.Second, // 设置超时,避免无限等待
    }

    req, _ := http.NewRequest("GET", url, nil)
    start := time.Now()
    resp, err := client.Do(req)
    duration := time.Since(start)

    // 记录实际耗时,单位毫秒
    span.SetTag("duration.ms", duration.Milliseconds())

    if err != nil {
        // 记录错误信息
        span.SetTag("error", true)
        span.LogKV("message", "external api call failed", "detail", err.Error())
        return nil, err
    }
    defer resp.Body.Close()

    body, _ := ioutil.ReadAll(resp.Body)
    // 记录状态码和响应体大小
    ext.HTTPStatusCode.Set(span, uint16(resp.StatusCode))
    span.SetTag("response.size", len(body))
    return body, nil
}

用这个方法替换项目中所有的外部HTTP调用后,Jaeger里就能清晰看到每一次调用的耗时。如果某个API经常超时,火焰图会暴露得很明显。另外,外部调用慢不一定都是对方慢,也可能是我们自己的连接池不够用,请求一直在排队。你可以额外创建一个“连接池等待”的子Span来测量排队时间,这样才能把责任划分清楚。

七、应用场景与优缺点

7.1 应用场景

Jaeger火焰图最适合用在分布式微服务架构中。比如一个电商系统,用户下单要经过网关、订单服务、库存服务、支付服务。一次请求跨多个服务,任何一个环节慢了都影响整体。通过Jaeger把调用链串起来,火焰图一眼看出瓶颈在哪个服务。尤其在跨团队协作时,你不需要去问别人“你们的服务是不是很慢”,直接甩一张火焰图过去,谁最宽谁负责。

另外,在数据库性能优化、第三方API排查、消息队列延迟分析这些场景里,Span持续时间都能提供精确的量化依据。比如一个消息消费者处理一条消息耗时3秒,你可以把消费者拆解成“拉取消息”、“反序列化”、“调用外部服务”、“写库”几个Span,看看时间都去哪了。没有Tracing的时候,遇到慢请求只能靠猜,有了Jaeger,直接筛选慢Span即可。

7.2 技术优点

Jaeger作为CNCF毕业项目,与Kubernetes、Prometheus等生态集成非常方便。UI界面简洁,支持按服务名、操作名、标签去筛选链路,定位问题很快。采样策略也很灵活,可以按请求量或者固定比例采样,避免全量采集带来的性能损耗。它支持OpenTelemetry协议,如果你已经用OpenTelemetry埋点,可以直接把数据发给Jaeger,不需要重复改造。

7.3 技术缺点

不过Jaeger也不是完美的。学习成本是有的,需要先理解Trace和Span的概念,还要部署Agent、Collector和UI组件。如果业务特别庞大,全量采集会产生海量数据,存储压力不小。为了节省存储而调低采样率,又可能漏掉一些低频但关键的慢请求。另外,如果代码里忘记给Span设置合适的标签,排查问题的时候还得回代码里翻找,效率会打折扣。

7.4 注意事项

使用Jaeger时,这几点值得注意:

  • 命名规范:Span的名字一定要有意义,别叫abcspan1,否则火焰图上根本看不出来是哪个操作。建议用“类名.方法名”或者“HTTP方法 + 路由”的风格。
  • 标签精简:不要把整个HTTP请求体塞进标签,几百KB的数据会让存储爆炸。记录关键字段就够了,比如请求参数里的ID、状态码、SQL语句模板。
  • 采样率要合理:生产环境不一定需要100%采样,一般10%到30%就能覆盖大多数问题,但关键入口或核心下单接口可以单独配置高采样率。
  • 时间同步:分布式系统里每台机器都需要用NTP同步时间,否则Span持续时间会不准。
  • 结合日志和监控:Jaeger擅长定位链路,但具体SQL优化还需要配合数据库慢日志;外部服务的CPU、内存问题需要配合Prometheus。链路追踪、日志、监控三者结合才是完整方案。

八、总结

排查性能问题不应该靠猜。Jaeger的火焰图就像一张交通地图,让你直接看到拥堵发生在哪一段路,而Span持续时间提供了精确的路段耗时。无论是数据库慢查询,还是外部调用延迟,通过给Span打上SQL、URL、状态码等标签,都能快速锁定目标。

回到开头的场景,那次事故真正的瓶颈是外部物流接口。虽然数据库CPU高,但那只是结果而不是原因。如果没有Jaeger,我们很可能把时间浪费在优化几条无关紧要的SQL上。现在,每次接到性能告警,我第一件事就是打开Jaeger,看火焰图,找最宽的条,再顺着Tag信息去深挖。这套思路不但适合排查线上问题,也适合日常性能优化。

如果你也想在自己的服务里装上这样一双“眼睛”,不妨从接入Jaeger开始。集成步骤并不复杂,把Tracer初始化好,在关键操作前后创建Span,再设置几个有用的标签。等数据积累一段时间,你会发现性能问题不再神秘,每一次慢请求的来龙去脉都清清楚楚。