ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot整合Zipkin分布式链路追踪实战指南

SpringBoot整合Zipkin分布式链路追踪实战指南

1. 为什么需要链路追踪:一次线上排查的真实教训

先说个真实发生过的事。某天群里有人发了个问题:用户反馈下单接口偶尔很慢,慢到网关超时。排查的人把用户服务、订单服务、库存服务、支付服务的日志全都翻了一遍,每个服务单独看都“正常”,没有任何报错,耗时也都在几百毫秒内。但整个链路拼起来就是超过3秒,而且时好时坏,完全找不到规律。最后是靠人工把几个服务的日志按时间戳硬拼在一起,才勉强定位到是库存服务里一个Redis调用在高并发下偶发阻塞。整个过程用了大半天。

这种经历但凡遇到一次,你就会明白:微服务架构下,靠单点日志排查跨服务问题是行不通的。你根本不知道一次请求到底经过了哪些服务,每个环节花了多少时间,网络传输占了多少,服务处理占了多少。而Zipkin这类分布式链路追踪系统,就是专门解决这个问题的——它给每一条请求生成一个全局唯一的链路ID,把经过的每个服务、每个调用都串成一条完整的时间线,你打开页面就能看到整条链路长什么样、瓶颈在哪里。

这篇文章我会从一个实际落地的角度,把SpringBoot整合Zipkin的完整过程拆开讲清楚:从核心概念、环境搭建、依赖配置,到联调验证、常见坑和进阶玩法,每一步都给可以直接抄的配置和代码。适合正在做微服务改造、被跨服务排查看得头疼的后端开发者,不管你是第一次接触链路追踪,还是已经用过但想深入理解其中的机制,这篇文章应该都能帮到你。

2. 核心概念拆解:Trace、Span和那四个时间点

在动手写配置之前,我建议先把Zipkin的几个核心概念吃透。否则你部署完了看到页面上那些时间轴,只会觉得“好像有点东西”,但看不出门道。

2.1 一次调用是一条Trace,每个环节是一个Span

Zipkin里的两个最基础概念是Trace和Span。一次完整的业务请求,从进入网关到最后返回响应,整个过程就是一个Trace。这个Trace会被分配一个全局唯一的Trace ID。而请求经过的每一个服务调用、每一步操作,就是一个个Span,每个Span都有自己的Span ID。

Span之间是有父子关系的。比如请求先到订单服务,订单服务再调用库存服务,那么“库存服务调用”这个Span,就是“订单服务处理”这个Span的子Span。在Zipkin UI上,这种父子关系会直接体现为时间轴上一层层嵌套的条块。你可以把它理解成看一集电视剧:Trace是整部剧,Span是每一集,而父Span是主线剧情,子Span则是支线剧情。所有Span通过Trace ID关联成一个整体,又通过Parent Span ID组织出层次结构。

这里要特别提醒一点:很多人在排查时容易把Trace ID和日志里的requestId混淆。requestId通常是业务自己生成的,只存在于当前服务的上下文里,跨服务就断了。而Trace ID是链路追踪框架自动生成、自动透传的,会通过HTTP Header(默认是X-B3-TraceId)在服务之间传播。这也是为什么链路追踪能做到“全链路串起来”的根本原因。

2.2 四个时间戳:一眼算出耗时到底花在哪

Zipkin之所以能画出一条带时间刻度的链路图,是因为每个Span都记录了四个关键时间点,学名叫做Annotation:

时间点含义
CS(Client Send)客户端发起请求的时间
SR(Server Receive)服务端接收到请求的时间
SS(Server Send)服务端处理完、返回响应的时间
CR(Client Receive)客户端收到响应的时间

有了这四个时间点,就能算出几段关键耗时。整个网络耗时约等于SR - CS加上CR - SS,服务端处理耗时则是SS - SR。如果一条链路的总耗时很大,但每个服务内部的SS - SR都很小,那问题基本就出在网络层或者中间件上;反过来,如果某个服务的SS - SR占了绝大部分,那瓶颈就在这个服务的业务逻辑或它依赖的数据库、Redis等组件上。

我实际排查过的一个案例就是典型:订单服务调用支付服务的总耗时2.8秒,但支付服务自己的处理只花了200毫秒,剩下2.6秒都在“路上”。后来一查,是两台机器之间的网络存在间歇性丢包和TCP重传。这种问题如果只看应用日志,永远发现不了,但Zipkin的链路时间轴上一眼就能看出来。

2.3 采样率:全量采集还是按比例采集

链路数据是有成本的。每产生一次调用,客户端就要在内存里组装Span,再异步上报给Zipkin Server。如果QPS很高,全量上报对性能、网络和存储都是不小的负担。所以Zipkin默认的采样率并不是100%。

在Spring Cloud Sleuth体系下,默认采样率是0.1,也就是10%的请求会被记录。这个值可以通过配置调整:

spring: sleuth: sampler: probability: 1.0

probability的取值范围是0到1。1.0表示全量采集,0.5表示一半概率,0表示不采集。需要注意的是,这个采样的动作发生在客户端,也就是每个服务自己决定这条Trace要不要上报,因此不同服务之间采样率如果不一致,可能出现“这头采样了,那头没采样”的情况,导致链路上报不完整。所以生产环境里,同一套链路的采样率配置要统一。

我的建议是:联调环境直接开1.0,方便排查问题;生产环境从0.01或0.1开始,根据业务量逐步调整。不要一上来就全量,如果是高并发核心链路,全量带来的开销会让性能敏感的系统很难受。

3. 环境准备:把Zipkin Server跑起来

客户端接入之前,得先把Zipkin的服务端准备好。Zipkin Server本身也是一个SpringBoot应用,很多新手在这里就开始困惑:我到底是下个jar包、用Docker,还是要自己写个服务?这里我把几种方式对比一下。

3.1 三种部署方式怎么选

部署方式适用场景优点缺点
Docker启动个人开发、测试环境最快,一条命令搞定需要Docker环境
直接运行jar包生产小规模部署简单可控,不依赖Docker需要自己管理JVM参数
源码编译启动需要二次开发可以改源码定制成本最高,一般用不到

绝大多数情况下,用官方提供的Docker镜像就够了。如果你手头没有Docker环境,去官方仓库下载对应版本的zipkin-server jar包,然后java -jar启动也是一样的。这里我用Docker方式演示,因为最省事。

3.2 用Docker启动Zipkin并验证UI

直接拉官方镜像启动:

docker run -d \ --name zipkin-server \ -p 9411:9411 \ openzipkin/zipkin:2.24

启动之后,浏览器访问http://localhost:9411,能看到Zipkin的查询页面就说明服务端起来了。页面上会有一个搜索框、一个时间范围选择器,还有一段示例查询按钮。刚启动没有数据的时候页面是空的,这很正常,不用急着怀疑部署有问题。

这里强调一下端口:Zipkin默认的HTTP端口是9411,UI和API都在这个端口上。上报链路数据的默认接口是/api/v2/spans,后面排查“收不到数据”的时候,你会用到这个地址来验证客户端推送是否成功。

还有一个容易被忽略的点:Zipkin Server的内存模式只在单机、数据量小的时候够用。默认情况下,Zipkin把链路数据放在内存里,服务重启数据就没了,而且内存数据存不了多少。后面我会单独讲存储选型。

3.3 存储选型:内存、MySQL还是Elasticsearch

Zipkin支持多种存储后端,常见的有三种:

存储方式特点适合场景
内存(默认)零配置,不持久化,容量有限开发调试、Demo验证
MySQL持久化,查询能力一般,量大后性能下降小规模内部系统
Elasticsearch适合海量数据,支持聚合分析,原生链路查询高效生产环境、链路数据量大

如果你只是跑通Demo,内存模式足够。但如果要把Zipkin用在生产环境,我强烈建议至少接MySQL,量大就上Elasticsearch。原因很简单:内存模式一旦服务重启,排查历史数据就全没了;而且内存模式的数据量受JVM堆大小限制,高并发下很容易丢数据。

用MySQL存储时,端口上就要额外加存储配置。Docker里可以通过环境变量指定:

docker run -d \ --name zipkin-server \ -p 9411:9411 \ -e STORAGE_TYPE=mysql \ -e MYSQL_HOST=127.0.0.1 \ -e MYSQL_TCP_PORT=3306 \ -e MYSQL_DB=zipkin \ -e MYSQL_USER=zipkin \ -e MYSQL_PASS=zipkin123 \ openzipkin/zipkin:2.24

注意,使用MySQL存储前需要先初始化数据库表结构,官方仓库里有对应的mysql.sql脚本,直接用MySQL客户端执行即可。这里我多说一句:不要在生产环境用默认的root账号连接Zipkin库,单独建一个最小权限账号,避免误操作影响到业务库。

4. SpringBoot项目接入Zipkin:依赖与配置

服务端搞定之后,接下来就是客户端接入。这一块看起来只是加两个依赖、写几行配置,但版本兼容问题、配置项含义如果不搞清楚,很容易出现“启动不报错、就是看不到数据”的尴尬局面。

4.1 版本兼容是第一道门槛

Zipkin客户端接入SpringBoot,目前有两套主流组合。经典组合是Spring Cloud Sleuth + Zipkin Reporter,适用于Spring Boot 2.x;新一点的组合是Micrometer Tracing + Brave,适用于Spring Boot 3.x。

技术组合适用SpringBoot版本说明
Spring Cloud Sleuth 3.x + spring-cloud-sleuth-zipkin2.6 ~ 2.7经典方案,资料多,大多数存量项目在用
Micrometer Tracing + micrometer-tracing-bridge-brave3.xSleuth停止维护后的官方替代方案

很多教程直接让你加spring-cloud-starter-zipkin,但要注意:这个starter在Spring Cloud较新的版本里已经被移除了。如果你用的是Spring Cloud 2020.0(对应Spring Boot 2.4以上)及之后的版本,正确做法是引入spring-cloud-sleuth-zipkin。我在下面给的依赖配置,是按经典方案来的,你直接复制就能用。

4.2 引入依赖:只需要两个坐标

在SpringBoot项目的pom.xml里加上:

<dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-sleuth</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-sleuth-zipkin</artifactId> </dependency>

如果你的项目是通过spring-cloud-dependencies统一管理版本的,这两个依赖的版本会自动关联,不需要手写版本号。如果你的项目没有引入Spring Cloud的BOM,那就要去查一下当前Spring Cloud发布的版本,手动指定一个和Spring Boot匹配的版本。

这里有一个常见的误解:Sleuth只是负责生成和传播Trace/Span信息,真正把数据发给Zipkin Server的是第二个依赖spring-cloud-sleuth-zipkin。有些教程只让你加第一个依赖,结果数据一直发不出去,就是这个原因。

4.3 核心配置项逐一解释

依赖加好之后,在application.yml里配置:

spring: application: name: order-service zipkin: base-url: http://localhost:9411 sender: type: web sleuth: sampler: probability: 1.0

逐项说明这几个配置:

  • spring.application.name:这个字段非常关键,它决定了在Zipkin UI上每个Span显示的服务名。如果不配置,链路图里会看到一堆无从区分来源的节点。
  • spring.zipkin.base-url:Zipkin Server的地址。如果是本地联调就是http://localhost:9411;如果是生产环境,建议通过配置中心动态下发,不要硬编码到代码里。
  • spring.zipkin.sender.type:发送方式,默认是web,也就是通过HTTP上报。还有kafka、rabbit等选择,后面进阶部分会讲。
  • spring.sleuth.sampler.probability:采样率。联调环境建议1.0,生产环境根据实际QPS调整。

配置完之后,你的SpringBoot应用不需要写任何埋点代码,只要用到了Spring生态内的HTTP组件(RestTemplate、Feign、WebClient)、数据库组件(JDBC、MyBatis)、消息队列组件(Kafka、RabbitMQ),Sleuth都会自动生成对应的Span并上报。这也是它最省心的地方。

注意:如果你用了Spring Boot 3.x,上面的配置不再生效,需要改用management.tracing.sampling.probability和management.zipkin.tracing.endpoint。迁移到新版本时,经常有人沿用老配置、发现链路全没了,其实就是配置项被改了。

5. 实战验证:两个服务串出完整链路

配置写完了,光看配置没感觉,最好亲手搭两个服务,跑一次调用,然后在Zipkin里看到一条完整的链路。我下面用最简单的用户服务和订单服务来做演示。

5.1 准备两个模拟服务

先启动Zipkin Server,然后在两个SpringBoot项目里都完成上面的依赖和配置。一个叫user-service,一个叫order-service。

user-service提供一个接口:

@RestController @RequestMapping("/user") public class UserController { @GetMapping("/info") public String info() { // 模拟耗时操作,方便在时间轴上肉眼观察 try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return "user-info"; } }

order-service里用RestTemplate调用user-service的接口:

@RestController @RequestMapping("/order") public class OrderController { @Autowired private RestTemplate restTemplate; @GetMapping("/detail") public String detail() { // 这里会触发一个跨服务调用,自动生成父子Span String userInfo = restTemplate.getForObject( "http://localhost:8081/user/info", String.class); return "order-detail, " + userInfo; } }

注意order-service里的RestTemplate Bean,需要显式声明:

@Configuration public class RestTemplateConfig { @Bean public RestTemplate restTemplate() { return new RestTemplate(); } }

两个服务分别启动在8080和8081端口,然后浏览器访问http://localhost:8080/order/detail,看到正常返回,链路就已经产生了。

5.2 在Zipkin UI中观察链路

打开Zipkin的查询页面,点击搜索按钮,刚才的调用就会出现在列表里。点进详情,你会看到一条清晰的时间轴:最外层是order-service的Span,里面嵌套着一个调用user-service的子Span。两个Span之间有重叠的时间段,重叠的部分就是网络传输和服务等待的时间。

在这个页面上,我一般会重点看三样东西:

一是整条链路的耗时分布。哪个Span颜色最深(耗时最长),重点就盯哪里。 二是Span之间的间隔。如果子Span和父Span之间出现了比较大的空隙,说明网络传输占了大头,应用代码层面大概率没问题。 三是服务名是否正确。如果服务名显示异常或者为空,回去检查spring.application.name。

另外,页面顶部还有个依赖分析功能,可以看到服务之间的调用关系图。这个图在梳理微服务拓扑时特别好用,尤其是在接手一个没人说得清调用关系的旧系统时,跑一遍流量之后,谁调用谁一目了然。

5.3 手动埋点:把关键业务标成自定义Span

自动埋点覆盖的是框架层面的调用,但有时你希望把某个方法、某段业务逻辑单独做成一个Span,方便观察这段逻辑自身的耗时。这时候可以用Sleuth提供的埋点注解。

在方法上直接加@NewSpan:

@NewSpan("checkStock") public boolean checkStock(String skuId) { // 模拟库存校验逻辑 try { Thread.sleep(150); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return true; }

这样调用checkStock方法时,Zipkin上就会多出一个名为checkStock的独立Span,并且自动挂到当前链路上。

如果你想给Span打上一些业务标签,便于检索,可以用@SpanTag:

@NewSpan("checkStock") public boolean checkStock(@SpanTag("sku.id") String skuId) { // ... }

这样在Span详情里就能看到sku.id这个字段的值。手动埋点的原则是“克制”。不要每个方法都加,只有那些你真正关心耗时、需要单独观测的业务节点才值得加。埋多了反而会干扰链路图的阅读。

6. 进阶场景:网关、异步线程和消息队列的链路透传

基础链路通了之后,你会发现一个更现实的问题:实际的业务系统里,请求往往要经过网关,服务之间有用线程池做异步处理的,还有通过消息队列解耦的。这些场景里,链路上下文一不小心就断了。下面几个是我在项目中实际处理过的场景。

6.1 网关层的链路透传

如果你的系统有网关,比如Spring Cloud Gateway,那网关就是整个Trace的起点。网关本身如果也引入了Sleuth依赖,它会自动生成Trace ID,并把相关的Header往下游服务传递。下游服务只要同样引入了Sleuth,就能自动续上这条链路。

如果你没有经过网关,而是通过Nginx之类的反向代理,那就需要确认一件事:Nginx默认情况下不会丢弃X-B3-*开头的请求头,Sleuth就是靠这些Header来传递上下文的。如果你在Nginx里配置了自定义的header过滤规则,一定要把X-B3-TraceId、X-B3-SpanId、X-B3-ParentSpanId、X-B3-Sampled这几个放行,否则链路在网关这一层就断了。

6.2 异步线程会丢链路上下文

Sleuth的上下文默认存在线程本地变量里。也就是说,你新开一个线程去执行任务,新线程里是没有当前链路上下文的。如果你在异步任务里再去做HTTP调用,这些调用就不会被计入原Trace。

解决办法是用Sleuth装饰过的线程池。看下面这个例子:

@Bean public ExecutorService traceExecutor() { // 用Sleuth的装饰方法包装线程池 return new TraceableExecutorService( Executors.newFixedThreadPool(4), tracer); }

如果你用的是Spring的@Async注解,同样可以把Executor替换成TraceableExecutorService,或者直接在异步方法里手动传递上下文。这一点如果你一开始没注意,异步调用在Zipkin里会表现为一条条孤立的链路,很难和主流程关联起来。

6.3 消息队列场景:链路拆分还是续传

消息队列场景有一个设计上的取舍:生产者和消费者之间,算同一条链路还是两条链路?在Sleuth里,默认情况下消息从生产者发到消费者,Trace ID是延续的,也就是说消费者处理会挂在生产者那条链路上。但有些团队更希望把消息的消费单独看作一个新链路,方便独立追踪消息处理性能。

如果是Kafka或RabbitMQ,Sleuth都能自动完成跨线程的上下文透传,不需要额外开发。但这里有个性能注意事项:如果消费者非常多、消息量非常大,所有消费Span都挂到同一条Trace上,会导致这条Trace异常庞大,Zipkin查询和展示都会变慢。这种情况下,建议在消费者入口显式标记一个新的Trace起点,或者对消费链路单独设置采样率。

提示:链路追踪不一定要求“全链路、全覆盖”。更常见的做法是只对核心交易链路做完整追踪,其他次要链路按比例采样。你得学会在成本和问题定位能力之间找平衡,而不是盲目追求所有请求都全量追踪。

7. 高频问题与排查技巧实录

整合链路追踪的过程中,大家遇到的问题其实高度集中。我把这些年见过、踩过的坑整理成速查表,每一项都是真实排查过的。

7.1 Zipkin页面始终看不到数据

这个问题的原因排查是有固定套路的。先确认客户端有没有成功发出数据,再确认服务端有没有收到,最后看存储和查询有没有问题。

排查点具体操作
依赖缺失确认是否同时引入了spring-cloud-starter-sleuth和spring-cloud-sleuth-zipkin,很多人漏了后者
配置错误确认spring.zipkin.base-url指向的服务端端口正确,且网络能通
采样率为0检查spring.sleuth.sampler.probability是否误配成了0
版本不匹配Spring Boot 3.x项目仍然在用Sleuth老配置,会静默失效
服务端存储问题如果服务端日志有存储相关报错,先参考日志定位

最直接的验证方式,是用curl模拟一次上报:

curl -X POST http://localhost:9411/api/v2/spans \ -H "Content-Type: application/json" \ -d '[]'

返回空数组不会报错,说明API是通的。接下来可以在客户端日志里看有没有发送Zipkin相关报错,或者直接把日志级别调到DEBUG,观察Sleuth相关的日志输出。

7.2 链路时间错乱、Span时间比调用时间还长

如果你在Zipkin UI上看到Span的起止时间和实际调用对不上,或者出现时间为负这类诡异现象,多半是机器时钟不同步导致的。分布式链路追踪对时间一致性要求很高,所有参与链路的服务机器,必须通过NTP等方式保持时钟同步。

这个问题在云环境里尤其容易发生,因为很多虚拟机的时钟漂移比较明显。我建议在服务器上把NTP同步配置好之后再上链路追踪,不然后面排查问题时,你会发现时间轴上的数据完全是乱的。

7.3 依赖冲突导致启动报错

Zipkin的客户端涉及到Jackson等JSON处理库,如果你的项目里已经引入了某些老版本的依赖,可能会出现类冲突。典型的表现是启动时直接报NoSuchMethodError或者ClassNotFoundException。

解决办法是先定位冲突来源:

mvn dependency:tree -Dincludes=com.fasterxml.jackson.*

看输出结果里有没有多个不同版本的Jackson并存。有的话,在pom.xml里排除旧的依赖版本,或者通过dependencyManagement统一版本。另外,如果你用的是Spring Boot 2.4以上的版本,启动时如果提示Sleuth版本相关的循环依赖,可以尝试在配置里关闭Bean依赖检查,但这属于治标,治本还是得把版本对齐。

7.4 采样率配置不生效

有一种情况很容易踩坑:你在application.yml里写了spring.sleuth.sampler.probability: 0.5,但实际看链路数据还是全量或者还是只有默认的10%。这个问题的原因,大概率是配置中心的变量覆盖了本地配置,或者你在代码里手动创建了SamplerBean,覆盖了配置文件的默认行为。

排查思路是:看配置来源优先级,本地的application.yml会被配置中心的下发值覆盖;再全局搜一下代码里有没有自定义的Sampler相关Bean。如果有,把你自定义的逻辑去掉或改成分散配置方式,让配置文件的采样率生效。

8. 实操心得与生产落地建议

链路追踪这个东西,部署成本不高,但真正把它用好,需要一点运维和排查的经验沉淀。最后分享几条我个人在项目里积累的建议。

第一,链路追踪要结合日志体系一起用。Trace ID不要只存在于Zipkin里,最好同时打到应用日志中。这样一旦Zipkin定位到某个Span慢,你可以拿着这个Span对应的Trace ID去日志系统里把相关服务当天的全部日志拉出来细看。Sleuth在接入后会把Trace ID自动加到日志的MDC里,你只需要在日志格式里配置输出对应的变量即可。这个联动在排查线上问题时特别高效,相当于先看地图、再钻细节。

第二,生产环境的采样率不要追求“全”。真正有用的排查方式不是全量捞数据,而是当你收到一个慢请求或错误请求时,能拿到它的Trace ID,然后按Trace ID去Zipkin里精确查询。如果业务上能做到这一点,那就算采样率只有1%,也能覆盖绝大多数问题定位场景。

第三,Zipkin Server本身要纳入监控。链路追踪系统是用来解决线上问题的,它自己如果挂了,反而会成为新的盲区。因此Zipkin Server的JVM内存、存储水位、接口响应时间这些指标要有基础监控告警。同时建议把上报方式从HTTP改成Kafka,这样即使Zipkin Server短暂不可用,消息也会在Kafka里积压,等服务恢复后再补报,链路数据不会白白丢掉。

从第一次看到Zipkin页面上那条完整链路时的震惊,到现在日常排查问题已经离不开它,我的体会是:分布式链路追踪不是可有可无的附加功能,而是一套系统的“神经连接图”。在没有它的时候,你排查跨服务问题靠的是直觉和人肉拼接日志;有了它之后,整个系统的调用关系、耗时分布、瓶颈位置,都能在一张时间轴上看清楚。尤其是项目规模还在涨的时候,越早接入,后面积累的链路数据就越有价值,排查问题的底气也就越足。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进