Spring Cloud Sleuth分布式追踪实战:从traceId到Zipkin链路分析 周末晚上 10 点半告警群里突然有人喊下单接口 P99 从 800ms 涨到了 2.3s。于是后端几个同事同时开查用户服务说我只用了 200ms订单服务说我这边 500ms 该返回的都返回了库存服务说我连缓存都查了才 100ms。三个人把各自服务的日志翻了个底朝天谁都没发现异常可网关侧的数据明明显示整条调用花了 2.3 秒。那一小时里我们做的所有事本质都是在猜——猜哪个服务慢了猜网络是不是抖了猜数据库是不是有慢 SQL。之所以要这样猜是因为微服务架构拆开以后每个服务都只看得见自己那一段日志没有任何东西能把一次跨多个服务的请求串成一条完整的线索。这种局部都正常、全局却异常的困境就是典型的可观测性缺失。而分布式追踪正是为了补齐这块短板而存在的技术。在 Java/Spring 生态里Spring Cloud Sleuth 是接入成本最低、使用最广的方案之一它不做花哨的事就专心解决一个问题把一个请求从入口到出口经过的所有服务、所有耗时、所有调用关系用一串 traceId 串起来让你真正看见链路而不是靠猜。这篇文章我会从实际工程角度把 Sleuth 的原理、接入、配置、以及生产环境里那些真正吃掉我时间的坑讲清楚。无论你是刚开始接触分布式追踪还是已经在排查线上疑难杂症相信都能找到有用的东西。1. 一场 2.3 秒的超时让我彻底理解了什么叫可观测性1.1 微服务拆完以后最头疼的事情是什么很多团队在微服务改造初期都会陷入同一个误区觉得服务拆得越细问题越容易定位。结果真正上线以后才发现拆分的代价之一就是排障变成了一场日志会诊。我经历过一个典型场景。用户服务、订单服务、库存服务、支付服务四个模块每个服务都有自己的日志文件每个服务的时间戳还未必同步。某次下单超时用户服务说自己 200ms 就返回了订单服务说它调库存服务等了几百毫秒库存服务又说它查数据库很快。但你把这些时间加起来怎么都对不上网关记录的那个 2.3s。中间那 1 秒多到底去哪了没人知道。你唯一能做的是让各服务把日志拉出来对着时间戳人肉对齐一遍遍地拼图。在这种架构下日志虽然完整但它天然是纵向的——每个服务只管自己的输出缺少一条横向的线索把不同服务的日志串在一起。这正是分布式追踪要解决的问题它不替代日志而是给日志加上一层关联维度让一次请求变成一个可以整体回放的故事。1.2 从日志会诊到分布式追踪三个维度补齐可观测性可观测性Observability这个词业界通常讲三个支柱Metrics指标、Logging日志、Tracing链路追踪。三者解决的是不同层面的问题我个人的理解是Metrics 回答系统是不是变慢了——它告诉你 P99 涨了但不知道是哪个请求、哪个服务导致的。Logging 回答某个服务里发生了什么——它告诉你异常堆栈、错误信息但孤立看无法还原全貌。Tracing 回答一次请求经历了什么——它把跨服务的调用链完整记录下来每段耗时、每次依赖都一目了然。如果只盯 Metrics你大概率会在知道出问题但找不到根因的状态里反复横跳。如果只靠 Logging那就会出现开头那种日志会诊的窘境——每个服务都觉得自己没问题但整体就是不对。所以我在团队里一直强调日志要配指标要采但链路追踪必须尽快补上。三者不是替代关系而是叠加关系。有了链路追踪之后日志才第一次有了跨服务的索引指标异常也才能直接定位到具体是链路里的哪一段拖了后腿。2. 一条请求跨四个服务Sleuth 是怎么把它串成一条线的2.1 traceId、spanId、parentId 的数据模型分布式追踪要解决的第一个问题是如何标识一次完整的请求。Sleuth 的思路非常朴素给一次请求分配一个全局唯一的 traceId再给这次请求经过的每一个服务节点分配一个 spanId。举个例子。一个下单请求从网关进来后网关先收到请求这时它是一个 span记为 Span AtraceId 是abc123spanId 是A。接着网关调用用户服务用户服务处理时产生 Span B它的 traceId 仍然是abc123但 spanId 是新的B同时 B 的 parentId 指向 A。用户服务再调用库存服务库存服务产生 Span C同样继承abc123父节点是 B。这样一层层传递下来A、B、C 就构成了一个树状结构一次请求的完整路径就被还原了。在 Sleuth 的数据模型里核心字段就三个字段含义作用traceId一次调用的全局唯一 ID把跨服务的所有 span 串联起来spanId单个服务处理阶段的 ID标识链路里的一个节点parentId父 span 的 ID记录调用关系决定树的层级需要注意parentId 记录的是调用关系而不是时间先后关系。一个服务可以同时发起多个下游调用形成多个子 span这在并发场景下很常见。最终所有 span 通过 traceId 聚合成一棵树谁调用谁、谁先谁后、谁在等谁一眼就能看出来。2.2 依赖注入和 Header 传递的真相很多人第一次接触 Sleuth 时会困惑我没写任何传递 traceId 的代码它是怎么在服务之间传播的答案在于 HTTP 请求的 Header。Sleuth 基于 Zipkin 的 B3 协议在服务间调用时自动注入一组 Header最核心的几个是X-B3-TraceId透传 traceIdX-B3-SpanId透传当前 spanIdX-B3-ParentSpanId透传父 spanIdX-B3-Sampled标记该请求是否被采样上报当你用 RestTemplate 或 Feign 发起调用时Sleuth 的拦截器会自动把这几个 Header 塞进请求里下游服务收到请求后Sleuth 又会自动从 Header 里把 traceId 和 parentId 取出来创建新的 span。正因为这个自动透传机制接入 Sleuth 的代码侵入性极低。这里有一个我自己踩过的坑如果项目里自定义了 RestTemplate 的拦截器或者用 Apache HttpClient、OkHttp 作为底层客户端一定要检查你的拦截器有没有把请求 Header 覆盖掉。Sleuth 的自动注入是依赖拦截器链完成的一旦你的自定义拦截器对 Header 做了一次只保留我指定的字段之类的操作链路就断了而且断得非常隐蔽——服务间调用依然正常日志里就是没有 traceId。2.3 用快递单号来理解整条链路如果觉得 traceId、spanId 这些概念太抽象我用一个生活化的类比。你把一次请求想象成一个快递包裹traceId 是快递单号。包裹从发货到签收不管经过多少个中转站单号从头到尾不变。span 是每一个中转站的签收记录。每个中转站都会记一条我收到了我干了什么我转走了的记录。parentId 是上一站是哪个的关联信息。没有它你只能看到一堆孤立的签收记录有了它才能把记录按中转顺序串起来。Zipkin 这类链路系统相当于快递公司的查询页面输入单号整条运输路径的每个节点、每段停留时间全部展开。这个类比基本可以覆盖分布式追踪的绝大多数概念。理解了这套模型后面看 Zipkin 界面也好配置采样率也好都会从容很多。3. 从零接入 Sleuth一个三服务 Demo 的完整改造过程3.1 最小依赖与 Spring Boot/Cloud 版本匹配接入 Sleuth 之前第一件要确认的事是版本兼容性。Sleuth 的版本节奏和 Spring Cloud 强绑定不同大版本之间差异不小Spring Cloud 版本Sleuth 版本备注2020.0.x (Ilford)2.2.x老项目常用稳定2021.0.x (Jubilee)3.0.x / 3.1.x目前生产环境的主流组合2022.0.x (Kilburn)不再继续演进官方推荐迁移到 Micrometer Tracing如果你的项目用的是 Spring Boot 2.6 或 2.7配合 Spring Cloud 2021.0.x用 Sleuth 3.x 是最稳妥的选择。我下面的 Demo 就以这个组合为例。在项目的pom.xml里加两个依赖dependencyManagement dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-dependencies/artifactId version2021.0.3/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-starter-sleuth/artifactId /dependency dependency groupIdorg.springframework.cloud/groupId artifactIdspring-cloud-sleuth-zipkin/artifactId /dependency /dependencies第二个依赖是上报 Zipkin 用的。如果你只需要在日志里看到 traceId不打算接入可视化链路系统那第一个依赖就足够了。但既然做了链路追踪我建议还是把 Zipkin 接上——只看日志里的 traceId跟直接在日志里查关键字没有本质区别真正的价值在于把链路可视化。3.2 关键配置项采样率还是上报地址依赖加完之后application.yml里做两组核心配置。spring: application: name: order-service sleuth: sampler: probability: 1.0 zipkin: base-url: http://localhost:9411spring.sleuth.sampler.probability是采样率取值 0 到 1默认是 0.1也就是 10% 的请求会被记录并上报。Demo 阶段建议直接设成 1.0确保每个请求都能在 Zipkin 里看到。生产环境再根据流量和存储压力调整后面我会专门讲采样率的取舍。spring.zipkin.base-url是 Zipkin 服务端的地址。本地开发直接用 Docker 起一个最方便docker run -d -p 9411:9411 openzipkin/zipkin启动后访问http://localhost:9411就是 Zipkin 的查询界面。3.3 观察日志traceId 冒出来那一刻我松了口气配置完成重启服务随便调用一个接口然后去看服务日志。你会发现日志格式悄悄变了2024-03-15 10:25:31.482 INFO [order-service,8f3a2b1c9d4e5f6a,8f3a2b1c9d4e5f6a,true] 12345 --- [nio-8081-exec-1] c.example.OrderController : receive order request中括号里的四个字段依次是服务名、traceId、spanId、是否上报。服务名来自spring.application.name在链路里它决定了节点名称。traceId 是整个请求的唯一标识一次调用链路上所有服务日志里的 traceId 完全一致。spanId 是当前服务内这次处理对应的节点 ID。最后一个true表示该请求命中了采样策略会被上报到 Zipkin。如果这里是false说明请求被采样器过滤掉了。看到日志里出现这个格式说明 Sleuth 已经在工作了。接下来你只需要在排查问题时用 traceId 去查各个服务的日志同一个 traceId 下的所有记录就是一次完整的调用路径。3.4 通过 RestTemplate 和 Feign 调用链路为什么自动就通了Demo 里如果服务间是通过 RestTemplate 或 Feign 调用的链路会自动打通不需要写任何额外代码。这个自动的背后是 Sleuth 注册了两个关键组件TraceRestTemplateInterceptor拦截 RestTemplate 发出的请求自动追加 B3 Header。TraceFeignClientAutoConfiguration为 Feign 客户端自动配置链路信息传递。这意味着你甚至不需要改业务代码只要引入了 Sleuth原来用的是标准 RestTemplate 或 Feign链路就能串起来。我当年第一次跑通这个 Demo 的时候看着两个服务的日志里出现同一个 traceId属实松了一口气——原来可观测性也可以零改造接入。不过要提醒一点如果服务间通信走的是WebClientSpring WebFlux 的响应式客户端Sleuth 也提供了自动装配支持但版本要求稍有不同需要额外确认你用的 Spring Cloud 版本是否包含对应的自动配置。4. 链路数据有了怎么把它变成可用的信息4.1 Zipkin 核心界面trace 详情、延时估算、依赖图链路数据上报到 Zipkin 之后最直观的用法是在 Zipkin 的 Web UI 里按服务名或 traceId 查链路。点开一条 trace你会看到一个时间轴视图每个 span 在时间轴上横向展开长度代表耗时层级关系通过缩进和连线体现。哪个 span 耗时最长一目了然。我在线上定位过一次典型的慢接口问题前端传过来的整体耗时 1.8s打开 Zipkin 发现 1.5s 都耗在订单服务调用库存服务的那个 span 上再往下钻发现库存服务内部又花了 1.2s 查数据库。沿着链路一层层剥根因很快就找到了。Zipkin 界面里还有一个容易被忽略的功能是依赖分析Dependencies。它通过一段时间内的链路数据自动生成服务间的调用拓扑图。谁在调谁、调用量多大、平均耗时多长全都能看到。对微服务架构梳理来说这个图比任何文档都真实。4.2 采样率、抓取概率和存储压力怎么权衡采样率是链路追踪里最需要权衡的参数。默认 0.1 意味着 90% 的请求不会进入 Zipkin。低采样率的好处是存储和性能开销小坏处是——真出问题的时候那条异常请求可能恰好没被采样。我自己的经验是分三层处理场景采样策略原因日常低流量服务probability: 1.0数据量小全量记录成本低排查最方便核心高流量服务probability: 0.10.5降低存储压力链路信息足以反映整体情况特定业务场景自定义 Sampler对重要的请求路径强制全采样自定义 Sampler 其实不复杂。实现Sampler接口根据请求特征比如订单号前缀、用户等级决定是否采样远程配置中心动态下发规则。我的做法是先把采样率调低再对下单这类核心链路单独配一条高采样规则两全其美。4.3 日志、指标、链路三者的关联使用链路追踪接好之后千万不要让它孤立存在。traceId 最大的价值之一是作为日志和链路之间的一座桥。实操流程我是这样设计的所有服务的日志 pattern 里带上 traceId。线上排查问题时先在 Zipkin 里搜 traceId看到链路里哪个 span 耗时异常。再拿同一个 traceId 去日志系统里搜把那个 span 对应的详细日志全部拉出来。结合指标系统看那段时间的 CPU、内存、GC 情况。这套流程跑顺以后线上疑难杂症的定位时间基本能从一个小时级降到分钟级。因为你不是在乱翻日志碰运气而是先通过链路确定具体哪个环节出问题然后只针对那一个环节深挖。5. 生产环境里真正吃掉我时间的那几个坑5.1 异步线程把 traceId 搞丢了接入 Sleuth 一段时间后我发现日志里部分请求的 traceId 是空的。排查了半天定位到根因代码里有大量的Async异步调用。Sleuth 传递 traceId 的机制是依托 ThreadLocal 实现的。而线程池里的线程跟发起请求的线程不是同一个ThreadLocal 里的上下文不会自动传递过去于是异步线程里产生的日志就丢了 traceId链路也在那里断成两截。解决方案有两个。一是在异步任务里手动传入上下文Autowired private Tracer tracer; public void asyncMethod() { Span span tracer.nextSpan().name(async-task).start(); try (Tracer.SpanInScope ws tracer.withSpanInScope(span)) { executor.submit(() - { // 需要链路追踪的业务逻辑 }); } finally { span.end(); } }另一种更省事的办法是用 Sleuth 提供的LazyTraceExecutor包装线程池。把自定义线程池替换成Bean public Executor traceExecutor(Tracer tracer, ThreadPoolTaskExecutor delegate) { return new LazyTraceExecutor(tracer, delegate); }这样线程池提交的任务会自动继承调用方的链路上下文。但要注意LazyTraceExecutor只能包装你自己创建的线程池如果是第三方中间件内部自带的线程池还是得走手动传上下文的方案。5.2 日志 pattern 没配好有 traceId 也搜不到还有一个特别隐蔽的坑Sleuth 依赖已经加了traceId 也生成了但你去看日志发现就是没有中括号那串信息。原因在于 Spring Boot 默认的日志输出格式里并没有包含 traceId。你需要在application.yml里显式配置日志 patternlogging: pattern: level: %5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]%X{traceId:-}是 Logback 里取 MDC 中 traceId 的写法:-表示取不到时输出空字符串。加上这个配置日志里才会出现[服务名,traceId,spanId]的前缀。这个配置我在每个项目里都要检查一遍因为太容易漏了。漏掉的结果就是Sleuth 白接链路日志在排查时完全用不上。5.3 采样率调低之后慢请求恰好全部被跳过我一度把生产环境的采样率调成 0.01也就是 1%以为省了存储成本万事大吉。结果某天线上一个慢接口被投诉我打开 Zipkin 怎么都查不到那条请求的 trace——因为它在 99% 的被放弃采样里。这个坑的教训是不要用全局单一采样率应对所有场景。对于核心交易链路一定要单独设高采样策略对于非核心的查询接口可以放心地设低采样率。实际操作上我建议在网关层或者入口服务上根据请求路径做采样决策而不是让每个服务各自为政。5.4 Sleuth 版本的演进新项目该何去何从如果你是新项目我要提醒一件重要的事Spring Cloud 2022.0.xKilburn开始Sleuth 已经进入维护模式官方不再新增功能推荐方案是迁移到 Micrometer Tracing。这并不意味着老的 Sleuth 项目必须马上重写。如果项目运行稳定继续用没问题社区仍在维护安全修复。但新项目建链路的起点可以考虑直接用 Micrometer Tracing Brave 或 OpenTelemetry。Micrometer Tracing 是 Micrometer 团队推出的统一追踪 API和 Sleuth 相比它不绑定特定的链路后端可以自由对接 Zipkin、Jaeger、SkyWalking 等系统。5.5 Feign 调用时 traceId 偶发丢失的深坑最后一个坑是我在某个项目里排查了整整两个下午才发现的。当时的现象很诡异Feign 调用大部分情况下 traceId 正常但偶尔会丢。后来发现是 Feign 开启了ribbon重试机制而且配置了多个重试 URL。Sleuth 的拦截器在第一次请求时已经消费了 traceId 上下文重试时上下文没有正确重置导致第二个 URL 的请求里没有带上 traceId。这种问题没有统一的修复模板但排查思路是通用的先确认链路断点发生在哪个组件再检查这个组件是否有重试、异步、线程复用等副作用。链路追踪系统自带的可视化视图恰好能帮你快速定位断点位置——一次排查下来Zipkin 比任何调试工具都有用。6. 再往前看从 Sleuth 到 OpenTelemetry再到 eBPF6.1 Spring Cloud 官方对 Sleuth 的定位变化Sleuth 在被 Spring Cloud 官方半退休之后很多团队都有点慌觉得是不是自己选错了方向。其实不是。Sleuth 把给 Java 微服务加链路追踪这个理念推广到了整个 Spring 生态历史使命已经完成了。它的继任者 Micrometer Tracing 不是另起炉灶而是提供了一个更开放的追踪 API。如果你现在还在用 Sleuth可以继续用但如果你准备做一套长期的、要覆盖多种语言的链路体系那么面向 OpenTelemetry 的方向更值得投入。OpenTelemetry 最大的优势是中立性和标准化一次埋点后端可以自由切换。6.2 eBPF 能不能免埋点做追踪最近两年可观测性领域里 eBPF 这个词频繁出现。eBPF 是一种 Linux 内核的动态追踪技术可以在不修改应用代码的情况下在内核态捕获网络包、系统调用、函数调用等信息。很多人问我有了 eBPF是不是连 Sleuth 都不用接了我的看法是eBPF 很强但它目前更适合做基础设施层的可观测性——容器网络监控、节点性能分析、无侵入的 HTTP 调用追踪这些都是它的强项。但 Java 应用内部的业务逻辑、JVM 调优、数据库访问参数、依赖库级别的调用细节eBPF 只能看到黑盒层面的信息不如应用内埋点来得细致。所以更务实的方案是eBPF 负责基础设施层Sleuth/OpenTelemetry 负责应用层二者互补不是替代关系。6.3 我建议你现在就动手做的事如果看完这篇文章你打算把一个现有项目接入分布式追踪我的建议是按这个顺序来先给所有服务的日志加 traceId用 Sleuth 自带的依赖不需要马上接 Zipkin。把 Zipkin 或 Jaeger 跑起来让链路可视化。把自动化测试环境里的采样率设成 1.0跑至少一周让团队习惯用链路工具排查问题。稳定之后再调采样率、做性能优化、接入更多自定义 span。这套路线我带着团队走过收益很大。刚开始大家查问题还是习惯翻日志但一旦体验过打开 Zipkin 直接看到整条链路的效率就再也回不去了。可观测性建设不需要一步到位但确实越早开始越好——因为下一次线上出问题时你手里多了一把真正好用的钥匙。