☰
Spring Cloud+Dubbo混合架构下TraceId透传与链路追踪实战
2026/10/15 12:11:31 网站建设 项目流程

上一篇写完了整体选型思路,评论区里问得最多的是“实现细节怎么落”“traceId到底怎么传才不断”。这一篇就把我在Spring Cloud + Dubbo混合架构下的链路追踪和traceId透传方案完整补齐,从生成规则、三条透传通道的代码,到MDC的坑、日志采集端的配合,全部是直接可抄的实战内容。

先交代背景:这套架构里,HTTP入口走Spring Cloud(包含网关和Feign调用),内部服务间又有大量Dubbo泛化调用,再加上异步线程池、定时任务、MQ消费端,链路特别容易断在中间。如果你也在这个组合里折腾链路追踪,或者正准备给老服务补traceId,这篇值得完整看完。

1. 先搞清楚:Spring Cloud + Dubbo混跑时,链路到底断在哪

1.1 三种框架“各自为政”的调用链路

很多团队一开始会觉得,链路追踪有什么难的,Spring Cloud Sleuth加个依赖,Zipkin配一下地址,不就有traceId了吗?等真上了生产才发现,这套组合拳只对Spring Cloud体系的调用管用。Dubbo的RPC调用走的是dubbo协议,头部没有HTTP Header那套机制,Sleuth默认的传播器根本覆盖不到。

举个实际例子:用户请求打到网关,网关通过Feign调订单服务,订单服务里面又用Dubbo调库存服务。这时候Sleuth在网关和订单服务之间是能串起来的,但订单服务到库存服务这一跳,traceId就丢了。库存服务的日志打出来,traceId是重新生成的,跟前面的完全不一样。排查一个问题,得同时打开三四个系统的日志,靠时间和业务单号去对,效率极低。

这里要理解一个本质问题:链路追踪的核心不是“生成一个ID”,而是“让这个ID跟着调用链走完每一跳”。HTTP有Header,MQ有消息属性,Dubbo有attachment,这些都是天然的透传通道。真正的工程量在于,把每个通道都接上,而且接得不能漏。

1.2 为什么现成方案不能完全省事

业界常见的现成方案,无非是三类。第一类是Sleuth + Zipkin,Spring Cloud亲儿子,对Web MVC、Feign、RestTemplate支持极好,但对Dubbo支持几乎为零,需要自己写Dubbo Filter或者升级到支持dubbo的版本。第二类是SkyWalking,通过Java Agent字节码注入,号称无侵入,但部署上要动JVM参数,很多业务团队不敢在生产上随便加agent,而且SkyWalking的traceId是它自己的一套,跟业务系统里日志打的traceId怎么对齐,需要额外配置。第三类是自研透传,也就是我最终采用的方案,代码量不大,但每个环节都掌握在自己手里,可控性最强。

自研方案的核心,其实就是一个全局唯一的traceId,配上三样东西:生成器、透传通道、MDC绑定。生成器保证每个请求进来时分配一个全局唯一ID;透传通道保证这个ID能从网关一路传到最底层服务;MDC绑定保证日志框架能把traceId打出来。这三件套覆盖住,链路追踪就跑通了。至于Zipkin那种可视化链路树,反倒是后话,先把日志串起来,解决日常排障的痛点,比什么都实在。

2. traceId的设计:不是随便取个随机串就行

2.1 一个我能用一年的traceId生成规则

很多初版实现喜欢直接用UUID去掉横线,这种方案简单但不推荐作为线上主力。UUID无序、长度偏长、可读性差,最难受的是,当你要从海量日志里按traceId做模糊排查,或者想从traceId里直接看出大概是哪个时段哪个实例产生的请求时,完全做不到。

我给生产环境用的生成规则是:业务标识 + 时间戳 + 机器标识 + 自增序列,总共拼成一个32位左右的字符串。比如:

traceId = "t" + System.currentTimeMillis() // 13位毫秒时间戳 + workerId // 2位机器标识,01-99 + sequence // 4位自增序列,循环使用

展开来看,完整traceId大概长这样:t17214567890123010001。其中1721456789012是毫秒时间戳,30是我配置的机器序号,0001是这个毫秒内的自增序列号。

这个格式的好处是:一看到时间戳段就知道请求发生的时间;看到机器标识能快速定位是哪台实例处理的;同一毫秒内的多笔请求靠序列号区分。排障的时候,如果几个服务之间的traceId前缀一致,几乎可以确定是同一条链路。配合告警系统做按traceId聚合分析时,这种结构化的ID也更容易处理。

2.2 生成器的性能细节与线程安全

生成器看着简单,但线程安全问题必须认真处理。自增序列如果每个请求都走synchronized同步,高并发下会有性能损耗。更稳妥的做法是用AtomicLong,或者结合当前毫秒做双重校验。

我实际用的生成器长这样:

public class TraceIdGenerator { private static final AtomicLong SEQ = new AtomicLong(0); private static final long MAX_SEQ = 9999L; private static String workerId; static { workerId = initWorkerId(); } private static String initWorkerId() { // 从环境变量或配置文件读取,如 "03"、"15" String env = System.getenv("TRACE_WORKER_ID"); if (env == null || env.isBlank()) { env = "01"; } return String.format("%02d", Integer.parseInt(env) % 100); } public static String nextTraceId() { long now = System.currentTimeMillis(); long seq = SEQ.incrementAndGet(); if (seq > MAX_SEQ) { synchronized (TraceIdGenerator.class) { // 再次检查,避免重置重复 if (SEQ.get() > MAX_SEQ) { SEQ.set(1); } } } return new StringBuilder(32) .append("t") .append(now) .append(workerId) .append(String.format("%04d", seq)) .toString(); } }

这里有个容易被忽略的细节:System.currentTimeMillis()在高频调用下,同一毫秒内多次调用返回相同值,完全依赖它不行,序列号必须能区分同一毫秒内的多个请求。SEQ超过最大值后要循环,重置时如果不在synchronized里做二次检查,两个线程都可能重置,就会产生重复ID。这个问题在压测时最容易暴露,实际生产环境里并发峰值下也会偶发。

3. 核心:三条透传通道的完整实现

3.1 HTTP入口:OncePerRequestFilter接住请求

HTTP入口是所有链路的第一步,网关和对外服务都要在这里接住traceId。关键就在一个点上:进来的请求如果有traceId,就沿用;没有就生成一个。千万别一进来就无条件生成新ID,那样网关已经给下游传好的traceId会被覆盖掉。

我推荐用OncePerRequestFilter而不是普通Filter,原因很简单:在Spring Cloud Gateway之前的Servlet环境下,OncePerRequestFilter能保证一次请求只执行一次过滤逻辑,避免因为内部转发导致的重复执行。实现如下:

@Component public class HttpTraceIdFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String traceId = request.getHeader("X-Trace-Id"); if (traceId == null || traceId.isBlank() || traceId.length() > 64) { traceId = TraceIdGenerator.nextTraceId(); } TraceContext.setTraceId(traceId); response.setHeader("X-Trace-Id", traceId); try { filterChain.doFilter(request, response); } finally { TraceContext.clear(); } } }

注意看finally里做了清理。这一步极其重要,后续讲线程池坑时会详细展开。同时我在response里回写了X-Trace-Id,这个小心思很实用:前端或者调用方在拿到报错响应时,可以直接把这个ID贴到工单里,后端根据ID一查日志全出来了。

有两个边界要补一下:一个是请求头里的traceId要做合法性校验,别直接信任外部传入的值,超长或者包含非法字符的直接丢弃,防止有人恶意塞超长字符串把日志系统打爆;另一个是如果你用的是Spring Cloud Gateway这种基于WebFlux的网关,传统OncePerRequestFilter不生效,要写GlobalFilter配合reactor.context去传递。

3.2 Dubbo调用:自定义Filter把traceId带过RPC

Dubbo的透传是整个方案里最容易出错的环节。Dubbo里能跨服务传递的载体是RpcContext的attachments,类似于HTTP的Header。消费者端在发起调用前,把traceId放进attachment;提供者端在接收请求时,从attachment里取出来。

先看消费者端的Filter实现:

@Activate(group = CommonConstants.CONSUMER) public class TraceIdConsumerFilter implements Filter { @Override public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException { String traceId = TraceContext.getTraceId(); if (traceId != null) { RpcContext.getContext().setAttachment("traceId", traceId); } return invoker.invoke(invocation); } }

再看提供者端的Filter:

@Activate(group = CommonConstants.PROVIDER) public class TraceIdProviderFilter implements Filter { @Override public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException { String traceId = RpcContext.getContext().getAttachment("traceId"); if (traceId == null || traceId.isBlank()) { traceId = TraceIdGenerator.nextTraceId(); } TraceContext.setTraceId(traceId); try { return invoker.invoke(invocation); } finally { TraceContext.clear(); } } }

两个Filter注册到src/main/resources/META-INF/dubbo/org.apache.dubbo.rpc.Filter文件里,内容格式是traceIdConsumer=com.example.filter.TraceIdConsumerFilter,提供者同理。

这里要重点提醒三个版本差异的坑。

第一,Dubbo 2.7.x之前和2.7.x之后,RpcContext的API有微调。老版本用RpcContext.getContext()拿的是当前线程的上下文,2.7.x引入了RpcContext.getServerContext()和RpcContext.getClientContext()的区分,用错了在异步场景下会拿不到attachment。

第二,如果你的Provider端开启的是async=true异步执行,invoker.invoke(invocation)返回后,业务线程已经切走了,finally里清理的可能不是业务线程的MDC。这个场景要配合3.3的TaskDecorator一起处理。

第三,不要用RpcContext.getContext().setAttachment()去设置大的对象或者复杂类型,attachment的传递是有性能开销的,统一放字符串就好。也别把用户敏感信息放进去,attachment会跟着RPC请求传到所有下游服务。

3.3 异步线程:TaskDecorator是唯一护身符

前面几次提到异步线程,这里专门展开。Java的ThreadLocal是线程私有的,你在线程A里设置了traceId,在线程B里根本读不到。异步线程池有两个典型场景会撕裂链路:

场景一,业务代码里把任务丢给ExecutorService执行,新线程里TraceContext.getTraceId()是null。场景二,线程池里的线程是复用的,上一个任务的traceId残留在线程里,下一个任务一进来就拿到了别人的traceId,日志全串了。

打通异步线程的正解是TaskDecorator。Spring的ThreadPoolTaskExecutor暴露了这个扩展点,能在任务提交到执行之间包装一下Runnable。我在项目里的实现是:

public class TraceTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { String traceId = TraceContext.getTraceId(); if (traceId == null) { traceId = TraceIdGenerator.nextTraceId(); } return () -> { TraceContext.setTraceId(traceId); try { runnable.run(); } finally { TraceContext.clear(); } }; } }

配置到线程池上:

ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(10); executor.setMaxPoolSize(20); executor.setQueueCapacity(200); executor.setTaskDecorator(new TraceTaskDecorator()); executor.initialize();

这段代码解决了两件事:新线程执行前把主线程的traceId塞进去,执行完把上下文清理掉。ThreadLocal不会天然清理,finally清理是防串号的最后一道防线。

如果你用的是Java自己new的裸线程池,没有TaskDecorator概念,那就得手动在每个Runnable的run()开头和结尾加清理逻辑,或者包一层TraceRunnable。我强烈建议统一走Spring线程池,别在裸线程池上加逻辑,容易漏。

4. 日志侧怎么把traceId真正“串”起来

4.1 MDC的正确打开与清理姿势

透传做完了,traceId还躺在TraceContext里,日志里看不到等于白做。把traceId打出来靠的是logback的MDC。MDC.put("traceId", xxx)之后,日志pattern里加一个%X{traceId},每行日志都会带上traceId。

最省事的做法是在HTTP Filter和Dubbo Provider Filter里把traceId同时put到MDC:

MDC.put("traceId", traceId);

但这里有个和异步线程一模一样的坑:MDC底层也是ThreadLocal,异步线程照样读不到,线程池复用照样会污染。所以TraceTaskDecorator里除了调TraceContext.setTraceId,还要同步补一句MDC.put("traceId", traceId)。

清理的时候也要两边一起清。有的团队只调了TraceContext.clear(),忘记MDC.clear(),结果线程池复用后,所有后续任务都带着旧traceId。这是我见过最多的误操作,没有之一。

正确写法是搞一个统一的工具类,把TraceContext和MDC的读写清理都封装进去:

public class TraceContext { private static final ThreadLocal<String> HOLDER = new ThreadLocal<>(); public static void setTraceId(String traceId) { HOLDER.set(traceId); MDC.put("traceId", traceId); } public static String getTraceId() { return HOLDER.get(); } public static void clear() { HOLDER.remove(); MDC.remove("traceId"); } }

这样所有业务代码只需要面对TraceContext.setTraceId和TraceContext.clear两个方法,不会出现漏清MDC的情况。

4.2 日志模板和采集端的联动

日志端只做了MDC还不够,还要保证采集链路能认出来。纯粹在logback pattern里加[%X{traceId}]是能让人肉眼看到,但如果你用的是日志采集系统或ELK,最好同时把traceId作为独立的日志字段输出。否则采集后如果做的字段拆分按空格来,traceId会被并到消息前面那一整块里,检索时非常费劲。

我项目里的logback pattern是这样配的:

<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [%X{traceId}] - %msg%n</pattern>

如果走结构化日志,就考虑用JSON格式,把trace_id作为顶层字段输出,采集端解析起来更干净:

{ "timestamp": "2025-07-20 14:32:10.123", "level": "INFO", "thread": "http-nio-8080-exec-3", "trace_id": "t17214567890123010001", "logger": "com.example.order.service", "message": "订单下单成功" }

还有一个观测侧的小建议:按traceId聚合日志时,服务的系统时间必须校准。多台机器时间差超过几秒,链路排序就会错乱。这问题在换traceId生成规则时不会暴露,但一旦要按时间去还原调用时序,就会特别痛苦。上线前把所有机器的时间同步检查一遍,成本极低,收益极大。

5. 实战排障与六大深坑

5.1 高频问题排查速查表

把我在排查场景里遇到的高频问题整理成一个表,方便直接对照。

现象可能原因处理办法
HTTP入口日志有traceId,下游服务没有下游服务没接Dubbo透传Filter,或Filter没注册成功检查META-INF/dubbo/org.apache.dubbo.rpc.Filter配置和@Activate注解
异步线程日志里traceId是空线程池没配TaskDecorator给所有业务线程池统一配置TraceTaskDecorator
第一次请求正常,后续请求traceId串号请求结束时只清了ThreadLocal没清MDC用统一的TraceContext.clear()同时清两边
日志里有的行有traceId,有的没有部分异步代码用了裸线程池,包裹器没覆盖统一线程池创建入口,或者手动包TraceRunnable
Dubbo提供者日志里traceId和调用方不一样提供者端Filter取attachment时机不对,或RpcContext API版本用错核对Dubbo版本对应API,在invoke方法开头取
网关日志traceId正常,下游HTTP服务拿到的不对Spring Cloud Gateway用WebFlux,普通Filter失效写GlobalFilter配合Reactor的context链路传递

这张表基本覆盖了我遇到80%的线上问题,剩下20%多半是“Filter没注册”“jar包版本冲突”“Filter执行顺序问题”,这类问题用日志的启动记录和调试断点能很快定位。

5.2 三个必须记进周报的坑

第一个坑,是Dubbo的async调用下的线程切换。我们有个库存服务,内部用CompletableFuture并行调了多个下游服务,日志里前几步traceId正常,后面几步全部是新的traceId。排查了半天发现是Dubbo开启异步后,RpcContext.getContext()在回调线程里拿到的已经不是发起调用线程的上下文了。后来统一在发起异步调用的外层先取出traceId,再通过TaskDecorator或者thenApply里的上下文切换补回去。

第二个坑,是泛化引用调用。有段时间新服务接老服务,图省事用了Dubbo泛化调用,RpcContext的attachment确实传过去了,但泛化调用的那层封装把attachment的上游值覆盖了。排查这个花了两天,最后是在泛化调用入口重新set一下traceId,不能只依赖Consumer Filter。

第三个坑,是MDC清理和异常分支的结合。如果业务代码里try-finally写得不好,比如在finally之前就return了,清理逻辑没执行,线程池里就残留了traceId。这个问题最隐蔽,因为偶发、复现率不高、但又一直在发生,表现为系统运行越久,日志里的traceId越是随机串号。最终解决方案就是彻底统一清理入口,所有请求结束路径必须走同一个clear()方法,不只是finally块,异常分支也要覆盖。

6. 写在最后的几条个人经验

这套方案上线运行了半年多,最大的体会是:链路追踪的复杂度不在“设计”而在“收口”。生成规则再漂亮、Filter写得再完整,只要有一个线程池漏配置、一个异步回调没包装饰器,traceId就会断,前面的一切等于白做。

我给团队立了一条规矩:所有线程池的创建必须走统一工厂,禁止在业务代码里直接new ThreadPoolExecutor。这个办法强制把TaskDecorator的覆盖范围提升到了100%,再也没出现过异步链路丢失的问题。如果你刚准备落地这套方案,建议先从这条规矩做起。

traceId透传这块还可以继续扩展。比如把spanId也加上,记录每一跳的父子关系,这样就能在日志系统里还原出完整的调用树;再比如把TraceId和移动端App的请求ID对齐,用户反馈问题的时候,前端把Request ID发过来,后端一秒拉出全链路日志。这些后续再说,先把traceId断了的问题补齐,日常重放、压测定位、跨团队扯皮这些场景,都能立刻感受到价值。

最后再提醒一句:去改生产环境之前,先在测试环境把“异步线程复用+异常清理”这两个场景压一遍,代价比自己上线后再排查要小得多。

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

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

立即咨询