上周五晚上十点多客服群炸了锅几十条用户投诉都在说同一件事下单成功了但库存一直没扣。我打开订单服务日志看扣库存接口明明是200但仓储侧那边货一直锁不上。链路涉及网关、订单、库存、支付回调、会员积分五个服务分属四个团队任何单一服务都查不出问题。最后从网关access log里翻出真相网关超时后做了一次重试第二次请求走了另一个已经过期的缓存分支把库存状态覆盖掉了。问题的本质不是某个服务写错了代码而是这个请求在多个服务间的完整状态已经没法靠单机日志还原了。也是从那次之后我们才把分布式追踪当成基础设施来做而不是报表工具。今天想把这个过程完整写下来——Spring Boot微服务下用OpenTelemetry实现分布式追踪从选型、接入、避坑到进阶玩法。这篇文章适合正在做微服务、或者已经有微服务但还没有统一追踪系统的团队哪怕你现在的服务只有两三个也值得看完因为这个坑早晚会踩到。1. 微服务链路排查难在哪两个真实场景先别急着看技术方案搞清楚这个问题到底解决的是什么。很多团队说“我们要上个链路追踪”但追问一句“你希望它帮你解决什么”往往答不上来。我先说两个我真实遇到过的场景。1.1 场景一两边日志都对但业务就是坏了一次大促后接到告警积分发放延迟超过30分钟。积分服务日志显示有大量任务堆积但积分的任务是从订单服务的MQ推过来的。订单服务日志显示消息发送成功积分服务日志也显示消费者正常拉取两边日志时间对得上消息内容也对得上——那为什么任务堆积最后查出来是RabbitMQ的某个消费者在反序列化时抛了一个被吞掉的异常消费线程反复拉起消息就是不动。在没有链路追踪的时候你要人肉比对两个系统的时间戳和业务主键比如订单号你才发现“哦原来这个单号在订单服务里状态是已支付但在积分服务里状态一直是处理中”。但这种比对要花多久取决于你对这套系统的熟悉程度新来的同事根本无从下手。1.2 场景二P99涨了三倍但哪一环变慢完全不知道某个核心接口P99从300ms涨到1200ms。数据库EXPLAIN看不出问题Redis Slowlog也查不出异常最后用压测分组对比才发现是下游一个第三方回调变慢了。问题在于这个接口一路调用了十几个依赖每一个依赖都有监控但每个监控都是独立的“局部视图”你没有一个工具能告诉你这1200ms里到底哪个环节贡献了900ms。这两个场景的共同点是每个服务都在记录“自己视角下发生的事情”但没人知道一次完整请求从进入到返回到底经历了什么。链路追踪要解决的就是这件事。1.3 Trace、Span和上下文传递的基本概念链路追踪的核心模型其实很朴素。一次请求进来系统会给它分配一个全局唯一的trace id可以理解为快递单号。这个请求每经过一个服务、一次数据库查询、一个外部HTTP调用都会生成一个span相当于快递运输中每个站点的记录。每个span会记录自己的父span id这样多个span自然形成一棵树这棵树就是一次请求的完整时间线。Span和Span之间要能串起来靠的是SpanContext在服务间传递。比如A服务调用B服务时A会在HTTP头里写入当前的trace id和span idB收到请求后从头里提取出来把它作为自己生成新span的父信息。整个机制不需要什么中心节点也不需要全局协调只要每个服务都遵守同一个上下文传播规范就行。这也是为什么OpenTelemetry要把协议标准化放在这么重要的位置——因为不同语言、不同框架的服务之间必须用同一个上下文协议才能串成一条完整的链路。2. 为什么是OpenTelemetry和Zipkin、SkyWalking的取舍如果你的团队已经用Zipkin或者SkyWalking跑了好几年要不要换我的建议是先不要急着回答看完这章再判断。2.1 ZipkinSleuth的现状能用但天花板明显Spring Cloud Sleuth配Zipkin是很经典的组合很多老项目都在用。问题在于Sleuth已经进入维护状态Spring Boot 3之后官方默认不再集成。更重要的是Zipkin的定位是一个轻量级后端存储和UI都比较简单。服务规模上来之后你想做自定义标签查询、想和指标系统关联、想让不同业务团队各自看各自的服务拓扑Zipkin能给的支持非常有限。我用过Zipkin生产环境说实话小团队快速起步它有价值但一旦你要做精细化治理它的数据模型和查询能力会成为瓶颈。2.2 SkyWalking功能很全但协议是私有的SkyWalking的功能确实强大拓扑图、告警、JVM监控都有国内团队用得很多。但它的问题在于整套体系是私有协议数据进了SkyWalking想再导出去做其他分析非常麻烦。如果你的技术栈是纯Java、不需要和外部系统共享数据那它完全够用。但如果你的体系里还有Go服务、Python服务或者公司有统一的数据平台要求私有协议就会变成一个很别扭的墙。一句话总结SkyWalking把链路数据做成了“绑定套餐”OpenTelemetry把链路数据做成了“标准建材”。2.3 OpenTelemetry的架构优势规范、SDK、CollectorOpenTelemetry在2023年成为CNCF孵化项目它实际上管了三层东西规范定义Trace、Metrics、Logs的数据模型以及上下文在HTTP/gRPC消息里的传递格式W3C tracecontext。SDK提供Java、Go、Python、Node.js等语言的埋点API和实现自动或手动埋点都支持。Collector一个独立的采集端服务接收各服务上报的数据做过滤、采样、脱敏再导出到Jaeger、Tempo、Prometheus等各种后端。这带来的直接好处是今天用Jaeger看链路明天想换Grafana Tempo或者自研展示平台采集端几乎不用动。链路数据是你的资产协议不是你的负债。而且OpenTelemetry的跨语言支持是原生能力——Java后端、Python的FastAPI服务、Go的微服务都能用同一套标准串到一条链路里这个在混合技术栈团队里太重要了。3. 三条接入路径Agent、Starter、Manual API选型阶段团队问得最多的问题是到底用哪种方式接入我把它拆成三条路径每条都有明确的适用场景。3.1 路径一Java Agent自动埋点用-javaagent方式启动应用相当于在JVM层面做字节码增强自动给Spring MVC、RestTemplate、Feign、JDBC、Kafka这些常用框架埋点。它的优势是零代码侵入加一行启动参数就能拿到完整链路特别适合想快速看到效果、或者存量服务特别多来不及改造的团队。缺点也很明显自动埋点是“框架级”的你能看到Feign调用花了多少毫秒但看不到业务内部“优惠券计算”花了多少毫秒。想加业务维度的span还得配合手动埋点API一起用。3.2 路径二Spring Boot Starter SDKopentelemetry-spring-boot-starter这类Starter会把OpenTelemetry SDK纳入Spring容器管理你可以在代码里直接注入Tracer写自定义span同时也能享受部分自动埋点能力。从实际体验来说它比纯Agent更灵活适合从“看链路”迈向“用链路做业务分析”的团队。从Spring Boot 2.7到3.x都可以用但要注意javax和jakarta命名空间的差异引入Starter前先确认版本兼容。3.3 路径三纯Manual API打点完全不依赖自动埋点所有span都由自己在代码里创建和管理。这种方式最灵活也最可控但工作量非常大除非你的团队有专职的可观测性小组否则不推荐在生产环境大面积铺开。三条路径不是互斥的。我实际推荐的节奏是先上Agent跑一周确认所有核心服务都能串成完整链路然后把需要业务分析的下游服务逐步改造成Starter自定义span纯Manual留给极少数特殊场景。接入方式切入成本自动埋点范围自定义能力运维复杂度Java Agent极低广覆盖主流框架一般需配合API低Spring Boot Starter中中覆盖主流框架较强支持注入Tracer中Manual API高无全手动最强高4. 实战接入Spring Boot Starter Jaeger 从零跑通这部分是纯操作我按我们踩通的路走一遍。后端以Jaeger为例因为先用All-in-One模式本地跑通最方便。4.1 先用Docker起一个Jaeger后端Jaeger的All-in-One镜像已经把查询UI、采集端、存储都打包好了适合本地调试。version: 3 services: jaeger: image: jaegertracing/all-in-one:latest ports: - 16686:16686 - 4317:4317 - 4318:431816686是Jaeger UI端口4317是OTLP gRPC接收端口4318是OTLP HTTP接收端口。如果你要用Zipkin兼容模式再暴个9411也行但新项目建议直接用OTLP这是后续所有数据能灵活流转的基础。4.2 添加Starter依赖Maven项目里加下面这个依赖版本号以你现在能拿到的最新稳定版为准。dependency groupIdio.opentelemetry.instrumentation/groupId artifactIdopentelemetry-spring-boot-starter/artifactId version1.40.0/version /dependency如果项目里有多个OpenTelemetry相关依赖建议再加一个BOM统一管版本避免SDK内部版本冲突。dependencyManagement dependencies dependency groupIdio.opentelemetry/groupId artifactIdopentelemetry-bom/artifactId version1.40.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement4.3 配置导出地址和服务名在application.yml里加基础配置思路是告诉SDK你这服务叫什么、trace往哪里送。otel: service: name: order-service traces: exporter: otlp exporter: otlp: endpoint: http://localhost:4317如果你用环境变量方式更习惯也可以这样export OTEL_SERVICE_NAMEorder-service export OTEL_TRACES_EXPORTERotlp export OTEL_EXPORTER_OTLP_ENDPOINThttp://localhost:4317这两种方式等价。环境变量的好处是不同环境开发、测试、生产可以复用同一份代码配置所有部署参数交给运维平台注入。4.4 验证链路是否打通启动两个Spring Boot服务比如order-service和inventory-service让订单服务通过Feign调用库存服务。发起一次请求后打开http://localhost:16686在Jaeger UI里搜索对应的服务名你应该能看到一条调用链从订单服务的Controller span到Feign调用的client span再到库存服务的server span层级完整耗时分布一清二楚。这里有一个本地调试时很容易被忽略的点如果Jaeger没启动SDK会尝试重连应用本身不受影响但日志里会反复出现OTLP导出失败。本地开发时如果不需要追踪建议把采样率配成0避免无意义的报错噪音。5. 上下文传播异步线程、MQ消费和网关重试接入链路之后你会发现一个规律第一次跑通往往是在同步调用链路上但真正让链路断掉的永远是异步和消息。5.1 为什么线程一换链路就断OpenTelemetry的Context使用了一套线程本地存储机制来保存当前trace和span信息思路类似log4j的MDC。好处是同步调用时不需要显式传参坏处是——线程切换后这个Context默认不会跟着过去。你用Async、自定义线程池、或者消息队列的消费线程去执行任务时新线程的Context是空的从新线程里再发起的任何HTTP调用都会生成一条没有父span的新链路。5.2 修复方案用TaskDecorator包装线程切换Spring在ThreadPoolTaskExecutor里提供了一个TaskDecorator接口可以在任务提交时包装RunnableOpenTelemetry也提供了对应的实现思路。配置一个Bean就能解决大部分自定义线程池场景Bean public ThreadPoolTaskExecutor taskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setTaskDecorator(runnable - Context.current().wrap(runnable)); return executor; }Context.current().wrap()会在Runnable执行开始时把当前线程的trace上下文绑定到即将执行的任务上。这样你在业务线程里往线程池丢任务任务内部产生的span就能正确挂到调用方下面。5.3 Kafka和RabbitMQ消费端的上下文传播自动埋点的Agent会处理Spring Kafka和Spring AMQP的消费场景但如果你用原生KafkaClient或者自己封装了消费者就需要手动处理。做法是在生产端发送消息时把当前trace上下文通过消息头传递出去TextMapPropagator propagator GlobalOpenTelemetry.getPropagators().getTextMapPropagator(); propagator.inject(Context.current(), headers, (carrier, key, value) - carrier.put(key, value));消费端再用extract从消息头里恢复上下文然后makeCurrent绑定到当前线程Context extractedContext propagator.extract(Context.current(), headers, (carrier, key) - carrier.get(key)); try (Scope scope extractedContext.makeCurrent()) { // 处理消息的业务逻辑 }实际排查中我遇到过因为漏了这一步导致“消息消费慢”的业务每次告警都指向一条孤零零的链路查不到是从哪个上游服务发起的。补上传播之后才能看到完整链路。5.4 网关重试的防呆设计开头那个故障本质就是重试导致的。网关层做超时重试时如果第二次请求重新生成了一个trace id整个链路就断了。正确做法是重试请求必须透传原始trace id但可以重新生成span id表示这是一次新的尝试。这也是W3C tracecontext规范里traceparent头设计成“trace id span id”结构的原因——trace id标识一次业务请求span id标识一次具体调用两者可以分离。排查重试问题有一个小技巧在Jaeger里搜同一个trace id关联的span数量如果发现同一个trace id下面同时出现多个互为兄弟的HTTP span基本可以断定是重试产生了分支。再看时间戳和响应码就能区分是“用户点了两次”还是“网关自动重试”。6. 采样策略与业务Span设计链路数据不是越多越好。数据量过大的时候存储成本、查询速度、后端压力都会变成新的问题所以采样是必须做的。6.1 先算一笔账假设每天500万请求平均每个请求产生40个span每条span序列化后大约500字节那么一天的数据量是500万 * 40 * 500字节 10GB/天这还没算索引开销和查询放大。一个月就是300GB起步如果全量保留你的存储账单会很难看。所以生产环境几乎都要配采样。6.2 常见采样策略怎么选采样策略行为适用场景always_on采样率100%测试环境、核心低流量服务parentbased_traceidratio按trace id哈希比例采样比如0.1即10%生产环境默认推荐parentbased_always_off什么都不采临时关闭、排查时改配置tail-based sampling先全量接收按规则错误、慢请求决定是否保留支付、交易等核心链路生产环境我默认推荐parentbased_traceidratio比例设在0.1到0.01之间既能看出趋势又不会把存储撑爆。有一点要特别注意按比例采样遇到错误链路时可能会把错误“抽”没了比如一个错误请求恰好没被采样到。解决方法是配合日志系统保证日志里始终有trace_id能搜到链路数据丢了日志仍然可以定位。6.3 业务Span应该怎么埋自动埋点能告诉你“Feign调用花了200ms”但如果产品想分析“优惠券计算花了多少时间”就必须在业务代码里手动埋一个span。最基础的方式是注入Tracer然后手动创建Autowired private Tracer tracer; public void calculateDiscount(OrderBO order) { Span span tracer.spanBuilder(calculateDiscount) .setAttribute(order.id, order.getId()) .setAttribute(user.id, order.getUserId()) .startSpan(); try (Scope scope span.makeCurrent()) { // 业务逻辑 } finally { span.end(); } }注意两点。第一makeCurrent()不能省它让当前线程绑定了这个span的上下文span内部再发起的任何下游调用才会自动成为这个span的子span。第二attribute的设计有讲究。像order.id、user.id这种基数可控的字段最适合做查询条件不要把完整日志正文、手机号这类数据塞进attribute既增加存储又容易踩数据合规的坑。另外建议全团队统一命名前缀比如order.*、user.*不然时间长了同一个字段在不同服务里叫orderId或order_id查询时很难受。7. 链路、日志、指标三件套打通链路数据单独看价值有限。它一定要和日志、指标打通才能形成完整的排查链条。这个环节是最容易被忽略的——很多人接入链路后发现排查效率并没有想象中提升原因就出在“链路是链路日志是日志各查各的”。7.1 日志里塞进trace id在logback的pattern里加上MDC字段让每行日志都带上当前的trace id和span idpattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [traceId%X{trace_id} spanId%X{span_id}] - %msg%n/pattern加了这个之后排查路径就变成了双向的在Jaeger里看到一个慢span复制trace id去日志平台一搜这个请求所有服务的日志全出来了反过来在日志平台看到某条报错日志复制trace id也能跳回链路视图看它周围发生了什么。这个能力对排查效率的提升是相乘的不是相加的。这里有个很常见的坑Starter接好了但MDC字段没生效。原因通常是日志框架和OpenTelemetry的MDC注入器版本不匹配或者pattern里字段名拼写不对。验证方法很简单手动打一条日志看输出里有没有trace_id字段没有就先检查pattern再检查依赖里是否引入了logback的instrumentation。7.2 链路与指标的对照使用指标告诉你“系统变慢了”链路告诉你“具体慢在哪一环节”。实际运维中我经常是Prometheus上看到某个服务平均响应时间上涨再顺着Jaeger看这个服务下游依赖的span耗时分布定位是Redis慢了还是某个外部接口慢了。OpenTelemetry也支持导出Metrics但我个人不建议让应用同时接两套指标采集Spring Boot生态用Micrometer已经足够链路和指标各司其职、互相印证就好。7.3 OTLP导出的几个生产参数最后补充几个生产实际用得上的参数。如果服务出口带宽紧张开启gzip压缩export OTEL_EXPORTER_OTLP_COMPRESSIONgzip设置导出超时和队列容量避免后端抖动时把业务线程拖死export OTEL_EXPORTER_OTLP_TIMEOUT5000 export OTEL_BSP_SCHEDULE_DELAY_MILLIS5000 export OTEL_BSP_MAX_QUEUE_SIZE2048这些都是SDK层面的保护参数默认值偏保守生产环境最好根据请求量手动调过一遍。如果你今天就要动手我给两个建议。第一不管选Agent还是Starter先在一个测试环境完整跑通一条跨服务的调用链亲眼看到父子span的关系再谈别的第二别在第一天就纠结采样率定多少、标签怎么起名那些是有了数据之后才会感知的二阶问题。我自己踩过最大的坑反而是链路图画得很漂亮了但日志里没有trace_id查问题的时候两头对不上——所以接入链路的第一天顺手把日志的trace_id也带上这件事越早做越好。