做微服务或者多模块项目的同学,应该都有过这种体验:一个用户请求从前端进来,后端调用三个服务,中间还穿插了几次Redis和数据库操作。突然线上报了个错,你去翻日志,发现只有一条孤零零的异常信息,上下文里既没有请求参数,也没有调用链路上的其他日志。你只能靠猜,靠时间戳碰运气,在四五个服务里来回翻日志,折腾一两个小时才定位到问题。
这就是我今天想聊的事——给SpringBoot项目实现全链路日志TraceId追踪。这套方案解决的就是“一次请求跨多个服务,日志怎么串起来”的问题。核心思路不复杂:给每个请求生成一个全局唯一的ID,打进所有日志里,后期靠这个ID把所有日志捞出来。但实际落地过程中,线程池传递、Feign调用、日志格式改造、关键位置埋点,每一环都有坑。这篇文章把这些步骤完整走一遍,包含可直接复制的代码和配置,适合正在做微服务拆分、以及被日志排查折磨过的兄弟参考。
1. 为什么你的日志在微服务环境下越来越难用
1.1 一次真实的生产事故排查经历
先讲个我自己的经历。前两年我们一个订单系统做了微服务拆分,拆成了订单服务、库存服务、支付服务三个模块。上线后不到一周,线上出现了一次偶发性的支付超时。用户下单后前端一直转圈,但最终订单状态居然还是支付成功的。
我一个人从订单服务的日志开始查,找到下单日志,跟着用户ID翻库存扣减记录,再跟着订单号翻支付回调解。单次请求涉及的日志散落在三台服务器的文件里,每台服务器的时间还不完全一致,要靠秒级时间戳去猜上下文。整个排查过程花了将近三个小时,最后发现是支付回调里一个空指针导致回调处理提前退出,后续重试机制又因为缺少幂等判断重复扣了库存。
这件事之后我下决心把TraceId方案落地。思路也很直接:用户每次请求进入系统时生成一个全局唯一的TraceId,无论这个请求在内部调了多少个服务、异步线程怎么切换,日志里都必须带着同一个TraceId。排查时只需要拿TraceId在所有服务的日志里搜一遍,整条链路就清晰了。
1.2 全链路日志追踪的核心目标与价值
所谓全链路日志追踪,标准做法是给一次业务请求分配全局唯一的标识符,并在整个调用过程中传递这个标识。最常见的标识体系分两层:
- TraceId(链路ID):一次业务请求的全局唯一ID,从入口往下游传递,整个过程保持不变。
- SpanId(单元ID):链路中每一个服务调用单元都有独立ID,多个Span按顺序串起来,就能还原出调用拓扑。
实际工程中,很多团队只做了TraceId一层,也够用。但如果你后续打算接入SkyWalking这类APM系统,建议一开始就把父子Span的概念设计进去,后面改造成本更低。
这套方案解决的核心痛点有三个:
- 快速检索:一次请求的全部日志拥有同一个TraceId,用
grep "traceId=xxx"就能跨服务捞全。 - 异常关联:业务异常从底层抛到上层,日志里都带TraceId,可以直观看到异常在哪个服务、哪个方法触发。
- 性能分析:通过Span的起止时间,能粗略判断每个环节耗时,定位慢服务。
一句话总结:日志从“按时间猜”变成“按ID精准捞”,排查效率至少提升一个数量级。
2. 实现方案选型:从零手写不如理解原理再动手
2.1 先搞清楚TraceId必须经过哪些环节
动手写代码之前,先把TraceId的生命周期梳理清楚。一个典型的同步请求链路是这样的:
浏览器发起请求 → 网关(或入口服务)接收 → 内部调用订单服务 → 订单服务调用库存服务 → 返回结果给前端
TraceId需要经历的环节包括:
- 生成:在链路入口(网关或第一个服务)生成全局唯一ID。
- 传递:在服务间调用时,通过HTTP Header携带TraceId传给下游。
- 记录:服务内部所有日志输出统一带上TraceId。
- 清理:请求处理完毕后,删除日志上下文中的TraceId。
如果链路中混入了异步线程池、MQ消息、定时任务,还需要额外处理线程间传递,否则子线程里日志的TraceId就断了。
2.2 四种TraceId生成方案对比
生成TraceId的方案不少,我把常见几种列一下:
| 方案 | 长度 | 有序性 | 优点 | 缺点 |
|---|---|---|---|---|
| Java UUID | 36位 | 无序 | 生成简单,无需额外依赖 | 太长,日志里占空间;无序导致索引性能差 |
| 短UUID(Hutool) | 32位(去横杠) | 无序 | 简单好用,社区普及 | 内容偏随机,但够用 |
| 雪花算法 | 19位左右 | 趋势有序 | 短小有序,适合海量日志 | 依赖时钟,时钟回拨时有极小概率重复 |
| W3C Trace-Context | 55位左右 | 无序 | 标准化,云厂商兼容 | 偏长,非必要场景有点重 |
我的建议是:生产环境用Hutool的IdUtil.fastSimpleUUID()或自研雪花算法实现。前者简单可靠,ID长度适中,唯一性完全够用;后者适合对日志体积和排序有极强要求的团队。不要为了炫技引入复杂方案,TraceId的核心是全局唯一,不重复就行。
2.3 关键技术底座:MDC(Mapped Diagnostic Context)
选定了ID生成方式,接下来要解决“日志怎么自动带TraceId”的问题。这里必须提到SLF4J提供的MDC机制。
MDC全称Mapped Diagnostic Context,是日志框架提供的一种线程绑定的KV存储,本质是一个ThreadLocal。你可以往MDC里放值,比如:
MDC.put("traceId", "abc123");然后在日志配置文件的pattern里用占位符引用它:
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - [%X{traceId}] - %msg%n</pattern>%X{traceId}就是MDC中key为traceId的值。这样所有日志输出会自动带上TraceId,业务代码里不需要手动拼接。
MDC既然是ThreadLocal,就要注意两点:子线程默认拿不到父线程的MDC值;线程池复用时,MDC里的旧值要记得清理。这两点后面单独说。
2.4 为什么不推荐用Spring Cloud Sleuth或SkyWalking方案
看到这里可能有人问:现在不是有Spring Cloud Sleuth、Micrometer Tracing,还有SkyWalking吗,为什么还要自己写?
我的看法是:轻量场景手写一套完全够用,而且可控性更强。Sleuth 3.0之后更名为Micrometer Tracing,对Spring Boot 3.x和Spring Cloud版本有强依赖,升级Spring Boot时容易连带踩坑。SkyWalking的TraceId查询确实更强,但它本质是APM系统,落地需要部署Agent、存储后端和UI,对中小团队来说运维成本偏高。
手写方案把核心逻辑收敛在一个Filter和几个拦截器里,代码量不超过200行,不引入额外组件。等你真的需要APM能力时,再在现有TraceId基础上接SkyWalking也不迟,自定义链路ID是可以和SkyWalking的TraceId做映射的。
3. 手把手实现:Filter + MDC + 日志格式改造
3.1 第一步:编写TraceIdFilter核心拦截逻辑
先创建一个过滤器,作为TraceId的入口。这里有几个细节需要特别注意,我直接在代码里标注了:
import org.slf4j.MDC; import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.UUID; /** * 全链路日志TraceId过滤器 */ @Component @Order(Ordered.HIGHEST_PRECEDENCE) public class TraceIdFilter extends OncePerRequestFilter { /** 请求头中传递TraceId的key */ public static final String TRACE_ID_HEADER = "X-Trace-Id"; /** MDC中存放TraceId的key */ public static final String MDC_TRACE_ID_KEY = "traceId"; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { try { // 优先取上游传递的TraceId,保证链路串联 String traceId = request.getHeader(TRACE_ID_HEADER); if (traceId == null || traceId.trim().isEmpty()) { traceId = generateTraceId(); } MDC.put(MDC_TRACE_ID_KEY, traceId); // 响应头也带上TraceId,方便前端定位问题 response.setHeader(TRACE_ID_HEADER, traceId); filterChain.doFilter(request, response); } finally { // 必须清理MDC,避免线程池复用导致TraceId串用 MDC.remove(MDC_TRACE_ID_KEY); } } private String generateTraceId() { return UUID.randomUUID().toString().replace("-", ""); } }几个关键决策说下:
- 必须用
OncePerRequestFilter:Spring的普通Filter在Servlet容器里可能被调用多次,但OncePerRequestFilter保证一次请求只执行一次过滤逻辑,避免重复生成TraceId。 @Order(Ordered.HIGHEST_PRECEDENCE):过滤器执行顺序很关键,必须放在所有业务Filter最前面,否则后续Filter的日志就已经拿不到TraceId了。finally里清理MDC:这步经常被忽略。Tomcat线程池是复用的,如果不清理,下一次请求可能读到上一次的TraceId,日志会串链路。- 响应头带回TraceId:这个环节是加分项。前端拿到TraceId,报错时直接提交这个ID,后端能立刻定位。
3.2 第二步:改造logback日志格式
过滤器能力有了,接下来要让日志真正输出TraceId。我用的是Spring Boot最常见的logback-spring.xml配置,核心是修改pattern:
<?xml version="1.0" encoding="UTF-8"?> <configuration> <!-- 日志输出格式中加入%X{traceId} --> <property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - [%X{traceId}] - %msg%n"/> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>${LOG_PATTERN}</pattern> <charset>UTF-8</charset> </encoder> </appender> <!-- 异步文件Appender,生产环境建议开启 --> <appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender"> <appender-ref ref="FILE"/> </appender> <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_PATH:-logs}/app.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>${LOG_PATH:-logs}/app.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>${LOG_PATTERN}</pattern> <charset>UTF-8</charset> </encoder> </appender> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="ASYNC_FILE"/> </root> </configuration>配置好之后,启动服务,访问任意接口,你会看到类似这样的输出:
2025-01-15 10:23:45.123 [http-nio-8080-exec-1] INFO com.example.OrderService - [a3f9d0c2e1b84a5f9d0c2e1b84a5f9] - 收到订单创建请求方括号里那一串就是TraceId。以后排查问题先拿到这串ID,再在所有服务的日志目录里搜,链路就出来了。
3.3 第三步:跨服务调用传递TraceId(Feign/RestTemplate)
入口服务和网关的TraceId生成了,日志格式也改好了,但下游服务怎么拿到同一个TraceId?靠的是HTTP Header传递。这里以项目里最常用的OpenFeign为例:
import feign.RequestInterceptor; import feign.RequestTemplate; import org.slf4j.MDC; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class FeignTraceIdConfig { @Bean public RequestInterceptor feignTraceIdInterceptor() { return new RequestInterceptor() { @Override public void apply(RequestTemplate template) { // 从MDC中取出当前线程的TraceId String traceId = MDC.get("traceId"); if (traceId != null && !traceId.isEmpty()) { template.header("X-Trace-Id", traceId); } } }; } }RestTemplate同样有拦截器机制,原理一致:
import org.springframework.http.HttpRequest; import org.springframework.http.client.ClientHttpRequestExecution; import org.springframework.http.client.ClientHttpRequestInterceptor; import org.springframework.http.client.ClientHttpResponse; import org.slf4j.MDC; import java.io.IOException; public class RestTemplateTraceIdInterceptor implements ClientHttpRequestInterceptor { @Override public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException { String traceId = MDC.get("traceId"); if (traceId != null && !traceId.isEmpty()) { request.getHeaders().add("X-Trace-Id", traceId); } return execution.execute(request, body); } }使用RestTemplate时注册这个拦截器即可:
RestTemplate restTemplate = new RestTemplate(); restTemplate.setInterceptors(Collections.singletonList(new RestTemplateTraceIdInterceptor()));下游服务不需要单独写逻辑,因为入口的TraceIdFilter会从Header读取X-Trace-Id并放入MDC。这样整条调用链的TraceId就自动串联了。
3.4 第四步:异步线程池场景保持链路贯通
MDC基于ThreadLocal,天生跨线程失效。项目中用到@Async、自定义线程池、或者CompletableFuture的,子线程里的日志就不会带TraceId了。这是全链路日志落地中最大的坑。
解决方法有两种,我推荐第二种。
方法一:手动传递
在提交任务前手动把TraceId塞到子线程,用了ThreadLocal的包装类:
// 任务提交处包装Runnable Runnable taskWithTraceId = () -> { try { MDC.put("traceId", MDC.get("traceId")); // 实际业务逻辑 } finally { MDC.remove("traceId"); } }; executor.submit(taskWithTraceId);这种方法的问题很明显:每个提交任务的地方都要写一遍,容易漏。
方法二:用TaskDecorator统一包装线程池任务
Spring的ThreadPoolTaskExecutor提供了TaskDecorator钩子,可以统一处理所有任务提交。一次配置,全局生效:
import org.slf4j.MDC; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.task.TaskDecorator; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.Map; @Configuration public class ThreadPoolConfig { @Bean public ThreadPoolTaskExecutor customTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setThreadNamePrefix("custom-task-"); // 重点:设置TaskDecorator executor.setTaskDecorator(new MdcTaskDecorator()); executor.initialize(); return executor; } /** * 将父线程的MDC上下文拷贝到子线程 */ public static class MdcTaskDecorator implements TaskDecorator { @Override public Runnable decorate(Runnable runnable) { Map<String, String> contextMap = MDC.getCopyOfContextMap(); return () -> { try { if (contextMap != null) { MDC.setContextMap(contextMap); } runnable.run(); } finally { MDC.clear(); } }; } } }MDC.getCopyOfContextMap()会复制当前线程的MDC全部内容,在子线程执行前放进去,执行完清理。这样即使子线程异常抛出,也不会有残留。
对于@Async注解,通过自定义TaskDecorator同样有效。配置一下AsyncConfigurer即可:
@Configuration @EnableAsync public class AsyncConfig implements AsyncConfigurer { @Override public Executor getAsyncExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setThreadNamePrefix("async-task-"); executor.setTaskDecorator(new ThreadPoolConfig.MdcTaskDecorator()); executor.initialize(); return executor; } }这套方案可以把90%以上的异步场景覆盖掉。剩下的像new Thread()手搓线程、第三方框架内部线程池,只能靠代码规范去约束了。
4. 全链路日志落地的8个典型问题与排查实录
4.1 问题速查表
手写TraceId方案踩过的坑不少,我把最高频的问题整理成一张表,方便排查时按图索骥:
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 所有日志都没有TraceId | 没有加载logback配置文件或pattern未配置%X | 检查logback-spring.xml是否生效,确认pattern里有%X{traceId} |
| 入口服务日志有TraceId,下游没有 | Feign/RestTemplate没加拦截器 | 确认服务间调用是否走的是同一套HTTP客户端封装 |
| 日志TraceId串到其他请求 | MDC在finally中未清理 | 在Filter的finally里调用MDC.remove |
| 异步线程日志没有TraceId | ThreadLocal子线程拿不到父线程MDC | 线程池配置TaskDecorator,统一复制MDC |
| 第一个请求没有TraceId后续请求有 | Filter顺序错误,业务Filter先执行了 | 给TraceIdFilter设置最高优先级 |
| 前端跨域场景自定义Header被拦截 | 浏览器CORS预检未放行自定义Header | 网关/后端配置Access-Control-Allow-Headers |
| MQ消费者日志无法关联业务链路 | 消息体没有携带TraceId | 生产者在消息Header写TraceId,消费者读取后塞入MDC |
| 网关入口没接入Filter,TraceId一直变化 | 请求入口在网关,但网关没生成/传递TraceId | 在网关最外层统一接入TraceIdFilter |
4.2 网关入口接入注意点
如果项目走了网关(比如Gateway),那入口Filter必须加在网关层,而不是每个业务服务各来一个。否则用户每次请求到不同服务,生成的TraceId都不一样,链路就断了。
网关的写法基本和Filter相同,核心逻辑仍然是:读上游Header → 没有就生成 → 放入MDC → 转发下游时带上Header。需要注意网关自身的WebFlux模型是响应式的,不能用Servlet的OncePerRequestFilter,要改用WebFilter:
import org.slf4j.MDC; import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.stereotype.Component; import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Mono; import java.util.UUID; @Component public class GatewayTraceIdFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, org.springframework.cloud.gateway.filter.GatewayFilterChain chain) { String traceId = exchange.getRequest().getHeaders().getFirst(TraceIdFilter.TRACE_ID_HEADER); if (traceId == null || traceId.trim().isEmpty()) { traceId = UUID.randomUUID().toString().replace("-", ""); } MDC.put(TraceIdFilter.MDC_TRACE_ID_KEY, traceId); // 将TraceId写入请求头,转发给下游服务 ServerHttpRequest mutatedRequest = exchange.getRequest() .mutate() .header(TraceIdFilter.TRACE_ID_HEADER, traceId) .build(); ServerWebExchange mutatedExchange = exchange.mutate().request(mutatedRequest).build(); return chain.filter(mutatedExchange).doFinally(signalType -> MDC.remove(TraceIdFilter.MDC_TRACE_ID_KEY)); } @Override public int getOrder() { return Ordered.HIGHEST_PRECEDENCE; } }注意点:响应式场景里,MDC.remove不能写在普通finally里,因为Mono不一定被执行完,要用doFinally确保链路结束后清理。
4.3 MQ消息场景的TraceId传递
异步场景里还有一块容易断链的是消息队列。比如订单服务发消息给库存服务,如果消息里不带TraceId,库存服务侧日志就跟主链路脱节。
MQ的解决方案和HTTP类似,关键点在于消息头传递。以RocketMQ为例,生产者发送消息时把TraceId塞进Message的properties:
Message message = new Message("inventory_topic", body); message.putUserProperty("X-Trace-Id", MDC.get("traceId")); producer.send(message);消费者接收到消息后,先从消息头里取TraceId放入MDC,再执行业务逻辑:
@Override public void onMessage(MessageExt messageExt) { String traceId = messageExt.getUserProperty("X-Trace-Id"); if (traceId == null || traceId.isEmpty()) { traceId = UUID.randomUUID().toString().replace("-", ""); } MDC.put(TraceIdFilter.MDC_TRACE_ID_KEY, traceId); try { // 消费业务逻辑 } finally { MDC.remove(TraceIdFilter.MDC_TRACE_ID_KEY); } }Kafka的ConsumerRecord也有headers属性,同理。RabbitMQ则是用MessageProperties的headers。无非是“取→放MDC→执行→清理”四步,套路一致。
4.4 埋点检测与链路演练
TraceId方案上线前,至少要做一次完整链路的演练。我的做法是:
- 找一台测试环境,启动入口网关 + 订单服务 + 库存服务三个应用。
- 用一个带自定义Header的请求工具(比如Postman)发起一次调用,Header里写
X-Trace-Id: test-10001。 - 依次去三个服务的日志文件里grep这个TraceId,确认每条日志都带上了。
- 再发起一次不带Header的请求,确认入口服务自动生成了新的TraceId,并且下游服务跟随的是同一个值。
- 看一条关键业务链路是否完整,比如从“收到请求”到“返回响应”之间,数据库操作、Redis缓存、远程调用这些关键节点是否都有日志。
演练过程中如果发现某段链路日志缺失,多半是以下三种原因:一是代码走了AOP切面,切面里自己打着玩没带MDC的占位符;二是某些日志是第三方SDK内部放出来的,不受logback pattern控制,这种情况只能靠SDK自身支持MDC,或者用日志增强插件截获后补充;三是异步线程内部的嵌套任务又开了线程,子线程的装饰器没生效。
4.5 几个提升排查效率的额外小技巧
方案主流程跑通之后,我建议加几个小优化,排查效率能再上一个台阶。
标记完整调用链:不光是异常日志打TraceId,请求入口、关键业务节点、外部调用、出参返回这些位置都打一条带TraceId的日志。这样拿到TraceId就能看到整个调用过程的“时间线”,比只看异常灭菌更直观。
响应头里回传TraceId:前面提到过,前端报错时只要把浏览器Network里的X-Trace-Id值发过来,后端就能直接定位。这个习惯要养起来,线上沟通成本能低很多。
日志文件按天切分并保留30天:TraceId方案再完美,日志文件被覆盖了也是白搭。logback-spring.xml里给RollingFileAppender配置maxHistory,历史日志保留至少30天。如果磁盘允许,保留60天。
关键服务接入独立的Error日志文件:可以把ERROR级别的日志单独输出到一个文件,排查线上问题时先在这个文件里搜TraceId,效率更高。配置方式给一段参考:
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>${LOG_PATH:-logs}/error.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>${LOG_PATH:-logs}/error.%d{yyyy-MM-dd}.log</fileNamePattern> <maxHistory>30</maxHistory> </rollingPolicy> <encoder> <pattern>${LOG_PATTERN}</pattern> <charset>UTF-8</charset> </encoder> <filter class="ch.qos.logback.classic.filter.LevelFilter"> <level>ERROR</level> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> </appender> <logger name="com.example" level="INFO"/> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="ASYNC_FILE"/> <appender-ref ref="ERROR_FILE"/> </root>5. 全链路日志追踪的扩展方向与我的实操体会
5.1 从日志追踪走向调用链分析
手写的TraceId方案解决的是“日志检索”问题,但它的数据天然可以用来做调用链分析。只要你在代码的关键节点记录当前SpanId、父SpanId、方法名、耗时,把日志汇总到ELK或Loki,就能按TraceId聚合出一次请求的完整调用拓扑。
我之前做过一个轻量版:在AOP切面里拦截所有业务方法,每次调用把方法名、参数、耗时、TraceId、SpanId拼成一条结构化日志,输出到独立文件。然后用小脚本按TraceId聚合,生成简单的调用链树。虽然比起SkyWalking差远了,但解决“这个调用耗时3秒,花在哪了”这种问题完全够用。
如果团队有预算部署SkyWalking,落地时也无需推翻现有方案。SkyWalking有原生的TraceId体系,但它的搜索框也支持自定义业务TraceId关联查询,相当于两条并行能力。
5.2 我对这套方案的真实感受
这套方案我前前后后在两个项目里落地过,代码量不大,收益却非常明显。最直观的感受是:线上排查从“碰运气猜时间窗口”变成“拿ID说话”,新人也能很快上手。以前周报里写“排查线上问题耗时X小时”,现在基本都是分钟级定位。
踩过几次坑之后,有几个习惯我一直保持到现在:
- 所有日志一律走SLF4J接口,禁止直接调用log4j2或logback的API。这样才能保证MDC随处可用,将来切换日志实现也不受影响。
- Filter里不用
try-catch吞异常,只做try-finally。TraceIdFilter不干预业务异常传播,只在最后清理MDC,避免副作用。 - 每个服务都要配一份TraceId上下文工具类。比如把取TraceId、生成TraceId的方法收敛到一个公共组件里,避免各服务自己各写一套,维护时才知道什么叫痛苦。
5.3 一个可以直接抄作业的优化点
最后分享一个我在项目里做的小优化:当请求是外部传入时,TraceId生成在网关或第一个服务;但如果是一次内部定时任务发起的业务,链路入口不在HTTP请求里,Filter根本不会触发。
解决方式是写一个通用的工具方法,业务入口或定时任务启动时手动调用:
public class TraceIdUtil { /** 初始化TraceId,适用于非HTTP入口 */ public static void initTraceId() { if (MDC.get("traceId") == null) { MDC.put("traceId", UUID.randomUUID().toString().replace("-", "")); } } /** 清理TraceId */ public static void clearTraceId() { MDC.remove("traceId"); } }定时任务里这样用:
@Scheduled(cron = "0 0 2 * * ?") public void dailyReconciliationTask() { TraceIdUtil.initTraceId(); try { // 业务逻辑 log.info("每日对账任务开始执行"); // ... } finally { TraceIdUtil.clearTraceId(); } }这样定时任务的日志也带上了TraceId,和HTTP请求走的是同一套检索逻辑。排查问题时不用再做“任务日志在另一个文件里只能盲猜”这种事了。
全链路日志TraceId这个东西,单独看每个环节都不难,难的是把所有管道都打通:HTTP入口、HTTP调用、异步线程、MQ消息、定时任务、日志格式。哪一环断了,排查的时候TraceId就搜不到完整链路,方案就算白做。如果你正准备在项目里落地这套方案,按我前文的顺序一步步来,把最后那些容易断的点都处理掉,基本就能做到一套代码全场景覆盖了。