资讯详情

资讯详情

SpringCloud Sleuth链路追踪实战:Trace与Span生成、采样策略及异步透传

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的DispatcherServletSleuth会检查请求头里有没有X-B3-TraceId。如果没有说明这是链路的起点它会生成一个新的Trace ID64位或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之外还记录了四个关键时间戳csClient Send客户端发起调用、srServer Receive服务端收到请求、ssServer Send服务端返回响应、crClient 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_code500过滤快速找到所有报错的请求链路。2.3 日志中的链路信息是怎么打进去的Sleuth最实用的功能之一是把Trace ID和Span ID自动注入到日志的MDCMapped 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.13.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 IDSleuth的链路信息是存在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的ThreadPoolTaskExecutorSleuth也提供了自动包装。只需要在配置类里把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_TYPEelasticsearch \ --ES_HOSTShttp://es-host:9200 \ --ES_INDEXzipkin \ --ES_DATE_SEPARATOR- \ --ES_INDEX_SHARDS3 \ --ES_INDEX_REPLICAS1ES_INDEX_SHARDS和ES_INDEX_REPLICAS要根据你的数据量和集群规模来定。如果每天写入量在千万级Span以上建议分片数设成5到10副本数至少1。另外Zipkin默认会按天创建索引可以通过ES_DATE_SEPARATOR来调整索引命名格式。5.2 客户端依赖与上报配置在Spring Cloud应用中引入Sleuth和Zipkin的依赖dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-sleuth/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-sleuth-zipkin/artifactId /dependency然后在配置文件里指定Zipkin服务端的地址和上报方式spring: zipkin: base-url: http://zipkin-host:9411 sender: type: web sleuth: sampler: probability: 0.1sender.type有三种可选web通过HTTP上报kafka通过Kafka上报rabbit通过RabbitMQ上报。如果Zipkin服务端和客户端之间网络不稳定建议用kafka方式客户端把Span发到KafkaZipkin从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相关包的日志级别调到WARNlogger nameorg.springframework.cloud.sleuth levelWARN/ logger namebrave levelWARN/另外如果不需要把链路信息输出到日志文件可以在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-enabledfalse避免每次上报都去服务发现里查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.5msQPS从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来实现避免手动改配置出错。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →