☰
SpringCloud Sleuth链路追踪实战:Trace与Span生成、采样策略及异步透传
2026/10/16 4:38:51 网站建设 项目流程

1. 微服务架构下链路追踪要解决的核心痛点

单体应用时代,一个请求从入口到数据库再到返回,所有日志都写在一个文件里,排查问题只需要按时间戳顺序翻日志就行。但服务拆分成几十个微服务之后,一个用户请求可能经过网关、认证服务、订单服务、库存服务、支付服务、通知服务,每个服务又可能部署多个实例。这时候线上出现一个超时或者报错,你面对的是散落在十几台机器、几十个日志文件里的碎片信息,根本拼不出完整的调用路径。

我印象很深的一次排查经历:某次大促期间,用户反馈下单后偶尔收不到确认通知。查通知服务的日志,发现它确实收到了消息,但消息里的订单号是空的。再查订单服务,订单创建成功且返回了订单号。问题出在中间某个环节把订单号丢了。但中间隔了三个服务、两个消息队列,没有链路追踪的情况下,只能靠人工在日志里搜订单号,搜了整整一个下午才定位到是一个序列化配置的兼容性问题。

链路追踪要解决的就是这个问题:给每个请求分配一个全局唯一的Trace ID,在服务间调用时把这个ID透传下去,每个服务在处理时都记录自己的Span ID和父Span ID,最终把所有日志串联成一棵完整的调用树。SpringCloud Sleuth就是Spring Cloud生态里做这件事的标准组件,它基于Google Dapper论文的思想,兼容Zipkin、ELK等后端存储和展示系统。

这篇文章适合正在做微服务架构落地、被线上问题排查折磨过的后端开发同学。我会从Sleuth的核心概念讲起,把采样策略、日志埋点、异步透传、与Zipkin的整合这些关键环节拆开揉碎,再分享几个我在实际项目中踩过的坑和总结出来的配置技巧。读完你至少能搞清楚三件事:Sleuth的Trace和Span到底怎么生成和传递、采样率设多少才合理、异步场景下链路为什么会断以及怎么修。

2. Trace与Span的生成逻辑:从入口到出口的完整链路

2.1 一次请求的链路数据是怎么长出来的

Sleuth的核心数据模型只有两个概念:Trace和Span。Trace代表一次完整的请求链路,从请求进入系统到最终返回,所有相关的操作都属于同一个Trace。Span代表链路中的一个工作单元,比如一次HTTP调用、一次数据库查询、一次方法执行。每个Span有自己的唯一ID,同时记录父Span的ID,这样就能还原出调用层级关系。

当一个请求到达第一个服务的入口(比如Spring MVC的DispatcherServlet),Sleuth会检查请求头里有没有X-B3-TraceId。如果没有,说明这是链路的起点,它会生成一个新的Trace ID(64位或128位十六进制字符串),同时创建一个根Span。如果请求头里已经有Trace ID,说明是从上游服务传过来的,Sleuth会复用这个Trace ID,并创建一个新的Span作为子节点。

这里有个细节值得注意:Sleuth默认使用128位的Trace ID,但很多后端系统(比如Zipkin的旧版本)只支持64位。如果你在整合时发现Trace ID被截断了,大概率是这个原因。可以在配置里显式指定:

spring: sleuth: trace-id128: false

设置成false之后,Sleuth会生成64位的Trace ID,兼容性更好。但如果你系统规模特别大,担心64位ID在高并发下碰撞,那就保持128位,同时确保后端存储也支持。

2.2 Span的父子关系与时间线记录

每个Span除了ID之外,还记录了四个关键时间戳:cs(Client Send,客户端发起调用)、sr(Server Receive,服务端收到请求)、ss(Server Send,服务端返回响应)、cr(Client Receive,客户端收到响应)。这四个时间戳能精确计算出网络延迟和服务处理耗时。

举个例子,服务A调用服务B,在服务A这边会记录一个Span,它的cs是发出请求的时刻,cr是收到响应的时刻。在服务B这边也会记录一个Span,它的sr是收到请求的时刻,ss是返回响应的时刻。把这两个Span放在一起,就能算出网络传输耗时(sr - cs)和服务B的实际处理耗时(ss - sr)。

Sleuth在生成Span时会自动打上一些标签(Tag),比如http.method、http.path、http.status_code、mvc.controller.class、mvc.controller.method等。这些标签在排查问题时非常有用,比如你可以直接在Zipkin里按http.status_code=500过滤,快速找到所有报错的请求链路。

2.3 日志中的链路信息是怎么打进去的

Sleuth最实用的功能之一是把Trace ID和Span ID自动注入到日志的MDC(Mapped Diagnostic Context)里。默认情况下,日志格式里会包含[appname,traceId,spanId,exportable]这四个字段。你只需要在logback的pattern里加上%X{X-B3-TraceId}和%X{X-B3-SpanId}就能输出。

但这里有个坑:Sleuth默认只把链路信息注入到它自己管理的线程里。如果你在业务代码里手动new了一个Thread,或者用了线程池,MDC里的信息是不会自动传过去的。这时候日志里就会出现空的Trace ID,链路就断了。解决办法后面会详细讲。

另外,exportable这个字段表示当前Span是否会被导出到后端存储。如果采样率是0.1,那么只有10%的Span的exportable是true,其余90%的Span虽然也会生成ID并注入日志,但不会上报到Zipkin。这个设计是为了在排查问题时保留全量日志,同时减少后端存储压力。

3. 采样策略的选择:全量采集还是按比例采样

3.1 采样率设多少才不拖垮系统

Sleuth默认的采样率是0.1,也就是10%的请求会被完整追踪并上报到Zipkin。这个默认值是有道理的:如果全量采集,高并发场景下Zipkin的存储和网络带宽压力会非常大。我见过一个日请求量千万级的系统,全量采集后Zipkin的Elasticsearch集群每天要写入几百GB的数据,不到一周就撑不住了。

但采样率设得太低也有问题。假设你的系统QPS是1000,采样率0.1,那么每秒只有100条链路被记录。如果某个偶发问题出现的概率是万分之一,你可能要等很久才能抓到一条完整的链路。更麻烦的是,如果问题出现在未被采样的请求上,你只能看到日志里的Trace ID,但Zipkin里查不到对应的链路数据。

我的经验是分环境设置:开发测试环境设成1.0,全量采集,方便调试;预发环境设成0.5,既能抓到大部分问题又不会压力太大;生产环境根据流量和存储容量来定,一般0.01到0.1之间。如果生产环境流量特别大,可以考虑用动态采样,比如只对报错的请求或者特定用户的请求全量采集。

配置方式很简单:

spring: sleuth: sampler: probability: 0.1

3.2 自定义采样器的实现思路

Sleuth提供了Sampler接口,你可以实现自己的采样逻辑。比如只对包含特定Header的请求采样,或者只对某个用户ID的请求采样。这在排查特定用户反馈的问题时特别有用。

@Component public class CustomSampler implements Sampler { @Override public boolean isSampled(Span span) { // 从Span的Tag里获取请求路径 String path = span.getTags().get("http.path"); // 只对订单相关的接口全量采样 if (path != null && path.startsWith("/api/order")) { return true; } // 其他接口按10%采样 return Math.random() < 0.1; } }

这个自定义采样器需要注册到Spring容器里,Sleuth会自动用它替换默认的采样器。注意isSampled方法会被频繁调用,里面的逻辑要尽量轻量,不要做数据库查询或者远程调用。

3.3 采样决策的传递机制

采样决策是在链路起点做出的,然后通过请求头X-B3-Sampled传递给下游服务。这个头有两个值:1表示采样,0表示不采样。下游服务收到请求后,会读取这个头,如果值是0,即使它自己的采样率是1.0,也不会对这个请求进行采样。

这个机制保证了同一条链路要么全采要么全不采,不会出现上游采了、下游没采导致链路断裂的情况。但有个例外:如果上游服务没有传递X-B3-Sampled头(比如上游不是Spring Cloud应用),下游服务会根据自己的采样率重新决策。这时候就可能出现链路不完整的情况。

注意:如果你在网关层做了自定义的请求转发,一定要确保X-B3-Sampled头被正确传递。我遇到过网关把自定义Header过滤掉导致下游全部重新采样的问题,排查了很久才发现是网关配置的问题。

4. 异步场景下的链路断裂与修复方案

4.1 为什么异步调用会丢Trace ID

Sleuth的链路信息是存在ThreadLocal里的,具体来说是MDC和Sleuth自己的CurrentTraceContext。当一个请求进入Tomcat线程池的某个线程时,Sleuth会把Trace信息绑定到这个线程上。但如果你的业务代码里用了@Async、CompletableFuture、ExecutorService或者消息队列,实际执行逻辑的线程和接收请求的线程不是同一个,ThreadLocal里的信息就丢了。

我见过最典型的一个场景:订单服务创建订单后,通过@Async异步发送通知。结果通知服务收到的消息里Trace ID是空的,导致通知链路的日志完全独立,无法和订单链路关联起来。当时排查这个问题花了整整一天,因为日志里看起来一切正常,只是Trace ID对不上。

4.2 用TraceableExecutorService包装线程池

Sleuth提供了TraceableExecutorService和TraceableCallable来解决了这个问题。它的原理是在任务提交时捕获当前线程的Trace上下文,在任务执行时把上下文恢复到执行线程上。

@Configuration public class AsyncConfig { @Bean public ExecutorService traceableExecutorService() { ExecutorService executor = Executors.newFixedThreadPool(10); return new TraceableExecutorService(executor); } }

如果你用的是Spring的ThreadPoolTaskExecutor,Sleuth也提供了自动包装。只需要在配置类里把TaskDecorator设置成Sleuth提供的实现:

@Bean public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setTaskDecorator(new TraceableTaskDecorator()); return executor; }

TraceableTaskDecorator会在任务提交时把当前线程的MDC和Trace上下文复制到任务里,任务执行时再恢复。这样异步线程里的日志就能带上正确的Trace ID了。

4.3 消息队列场景的透传处理

消息队列的场景更复杂一些,因为消息的生产和消费是完全解耦的,中间隔了一个Broker。Sleuth对Spring Cloud Stream和Kafka有内置支持,但如果你用的是RocketMQ或者自己封装的MQ客户端,就需要手动处理。

核心思路是在发送消息时把Trace ID和Span ID放到消息的Header里,消费时再从Header里取出来注入到当前线程的上下文。以RocketMQ为例:

// 发送消息时注入Trace信息 Message message = new Message("topic", "tag", body.getBytes()); message.putUserProperty("X-B3-TraceId", MDC.get("X-B3-TraceId")); message.putUserProperty("X-B3-SpanId", MDC.get("X-B3-SpanId")); producer.send(message); // 消费消息时恢复Trace信息 consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> { for (MessageExt msg : msgs) { String traceId = msg.getUserProperty("X-B3-TraceId"); String spanId = msg.getUserProperty("X-B3-SpanId"); if (traceId != null) { MDC.put("X-B3-TraceId", traceId); MDC.put("X-B3-SpanId", spanId); } // 处理业务逻辑 handleMessage(msg); MDC.clear(); } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; });

这里有个细节:消费端恢复Trace信息后,最好创建一个新的Span作为子Span,而不是直接复用生产端的Span ID。因为消息的生产和消费在时间上可能间隔很久,复用同一个Span ID会导致Zipkin里的时间线看起来很奇怪。

5. 与Zipkin整合的完整配置与验证流程

5.1 服务端部署与存储选型

Zipkin的服务端部署很简单,官方提供了可执行的Jar包,直接java -jar zipkin.jar就能启动。但生产环境需要考虑存储问题,Zipkin默认把数据存在内存里,重启就没了。支持的存储后端有Elasticsearch、Cassandra和MySQL。

我的建议是选Elasticsearch,因为它的查询性能和扩展性最好,而且和Zipkin的整合最成熟。启动时指定存储类型:

java -jar zipkin.jar \ --STORAGE_TYPE=elasticsearch \ --ES_HOSTS=http://es-host:9200 \ --ES_INDEX=zipkin \ --ES_DATE_SEPARATOR=- \ --ES_INDEX_SHARDS=3 \ --ES_INDEX_REPLICAS=1

ES_INDEX_SHARDS和ES_INDEX_REPLICAS要根据你的数据量和集群规模来定。如果每天写入量在千万级Span以上,建议分片数设成5到10,副本数至少1。另外Zipkin默认会按天创建索引,可以通过ES_DATE_SEPARATOR来调整索引命名格式。

5.2 客户端依赖与上报配置

在Spring Cloud应用中引入Sleuth和Zipkin的依赖:

<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>

然后在配置文件里指定Zipkin服务端的地址和上报方式:

spring: zipkin: base-url: http://zipkin-host:9411 sender: type: web sleuth: sampler: probability: 0.1

sender.type有三种可选:web通过HTTP上报,kafka通过Kafka上报,rabbit通过RabbitMQ上报。如果Zipkin服务端和客户端之间网络不稳定,建议用kafka方式,客户端把Span发到Kafka,Zipkin从Kafka消费,这样即使Zipkin短暂不可用也不会丢数据。

5.3 验证链路数据是否完整

配置完成后,发起一个请求,然后去Zipkin的Web界面查询。正常情况下你应该能看到一条完整的调用链路,每个服务节点都有对应的Span,时间线清晰。

如果发现链路不完整,按以下顺序排查:

排查项检查方法常见问题
Trace ID是否传递查看下游服务日志里的Trace ID请求头被网关过滤
采样决策是否一致检查X-B3-Sampled头上游未传递导致下游重新采样
异步线程是否透传查看异步线程日志的Trace ID线程池未包装
上报是否成功查看Sleuth的日志或Zipkin服务端日志网络不通或存储写入失败
Span是否被丢弃检查Zipkin的存储容量存储满了导致写入失败

我遇到过一次链路不完整的问题,排查后发现是网关层用了自定义的过滤器,把X-B3-TraceId头给过滤掉了。因为网关不是Spring Cloud Gateway,没有自动集成Sleuth,需要手动在过滤器里放行这些Header。这个问题的隐蔽性在于,网关本身的日志里有Trace ID,但下游服务的日志里没有,很容易误以为是下游的问题。

6. 生产环境踩坑记录与性能调优建议

6.1 日志量暴增导致磁盘写满

Sleuth开启后,每条日志都会多出Trace ID和Span ID字段,日志体积会增加20%到30%。如果采样率设得高,再加上Zipkin的上报日志,磁盘消耗会更快。我见过一个系统上线Sleuth后第三天磁盘就满了,排查发现是Sleuth的DEBUG日志把磁盘写爆了。

解决办法是在logback里把Sleuth相关包的日志级别调到WARN:

<logger name="org.springframework.cloud.sleuth" level="WARN"/> <logger name="brave" level="WARN"/>

另外,如果不需要把链路信息输出到日志文件,可以在pattern里去掉%X{X-B3-TraceId},只保留在控制台输出。这样既能减少磁盘占用,又不影响开发调试。

6.2 高并发下Span上报成为瓶颈

默认的web上报方式是同步的,每个Span生成后立即通过HTTP发送到Zipkin。在高并发场景下,这会产生大量的HTTP连接,不仅消耗网络资源,还可能因为Zipkin响应慢而阻塞业务线程。

解决方案是改用kafka或rabbit异步上报,或者调整web上报的批量参数:

spring: zipkin: sender: type: web http: connect-timeout: 1000 read-timeout: 5000 compression: enabled: true

开启压缩后,上报的数据量能减少60%以上。另外可以设置spring.sleuth.zipkin.discovery-client-enabled=false,避免每次上报都去服务发现里查Zipkin的地址。

6.3 自定义Span的创建时机与注意事项

有时候自动埋点不够用,比如你想追踪一个复杂业务方法的内部执行耗时,就需要手动创建Span。Sleuth提供了Tracer接口:

@Autowired private Tracer tracer; public void complexBusiness() { Span span = tracer.nextSpan().name("complexBusiness").start(); try (Tracer.SpanInScope ws = tracer.withSpanInScope(span)) { // 业务逻辑 span.tag("business.step", "step1"); // 更多逻辑 } catch (Exception e) { span.error(e); throw e; } finally { span.finish(); } }

这里的关键是try-with-resources里的SpanInScope,它确保在代码块执行期间,当前线程的上下文是新的Span。如果不加这个,新创建的Span不会成为当前Span的子节点,链路关系就乱了。

提示:手动创建Span时一定要确保span.finish()被调用,否则这个Span永远不会结束,在Zipkin里会显示成一个未完成的节点。建议放在finally块里,或者用try-with-resources自动管理。

6.4 跨线程池的上下文传递性能损耗

TraceableExecutorService在任务提交时会做上下文拷贝,这个操作本身有性能开销。如果线程池的任务执行时间很短(比如几毫秒),上下文拷贝的开销可能占到总耗时的10%以上。我实测过一个场景,任务本身执行只要2ms,但加上上下文拷贝后变成了2.5ms,QPS从5000降到了4000。

优化思路是:对于执行时间极短的任务,可以考虑不包装线程池,而是在任务内部手动传递Trace ID。或者用InheritableThreadLocal替代普通的ThreadLocal,但要注意InheritableThreadLocal在线程池场景下不生效,因为线程是复用的。

另一个思路是降低采样率,减少需要传递上下文的请求比例。如果采样率是0.1,那么90%的请求其实不需要完整的上下文传递,只需要保证日志里有Trace ID就行。

6.5 与现有日志系统的兼容处理

很多公司已经有自己的日志采集和分析系统,比如ELK或者自研的日志平台。引入Sleuth后,需要确保Trace ID能被现有的日志系统识别和索引。通常的做法是在日志的JSON格式里增加traceId和spanId字段:

{ "timestamp": "2024-01-01T12:00:00.000Z", "level": "INFO", "traceId": "abc123def456", "spanId": "789ghi", "message": "Order created successfully" }

然后在日志平台的查询界面里,支持按traceId搜索。这样即使不打开Zipkin,也能在日志平台里快速过滤出同一条链路的所有日志。我在实际项目中更常用这种方式,因为日志平台的查询速度比Zipkin快,而且不需要额外维护一个Zipkin服务。

最后分享一个我个人的习惯:在开发和测试环境,我会把采样率设成1.0,并且把Sleuth的日志级别调到DEBUG,这样能看到每个Span的生成和上报过程。但在生产环境,采样率降到0.05,日志级别调到WARN,只保留必要的链路信息。这个切换通过Spring Profile来实现,避免手动改配置出错。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询