做微服务开发的兄弟,肯定都踩过同一个坑:线上出问题要排查日志,一个请求从网关到业务服务,再到数据库服务,每个服务的日志散在不同地方,想串起来比找对象还难!之前我傻呵呵在每个方法参数、DTO 里都塞个 traceId,手动往下传 —— 不仅代码里全是这种 “无关字段”,还总因为同事忘传,导致排查时对着一堆日志发呆。今天就把压箱底的办法分享给大家:用 Slf4j 的 MDC 搞全链路 traceId,代码几乎不用改,爽到直接减少一半排查时间~
一、先唠唠为啥要搞统一 traceId?
单体应用时代多省心啊,一个请求的日志全在一个线程里,顺着看就知道流程。但微服务一拆分,事儿就多了:比如用户下单,要调订单服务创建订单,再调支付服务扣钱,最后调库存服务减库存,每个服务的日志各存在自己的服务器上。
之前我是这么干的:订单服务生成个 traceId,调用支付服务时把它当参数传过去,支付服务再传给库存服务... 结果呢?
- 业务代码里全是 traceId 这个 “多余参数”,比如 createOrder(String orderNo, String traceId),看着就烦,还容易和业务字段混在一起;
- 万一新来的同事加了个服务调用,忘了传 traceId,线上出问题时,你就会发现:订单服务有日志,支付服务没对应 traceId,直接卡壳;
- 更坑的是,只要用了 @Async 搞异步(比如发通知、算积分),traceId 直接就丢了 —— 同步日志还能看到,异步日志里啥都没有,排查时直接断片。
这活儿干久了谁不崩溃?所以必须找个 “不用手动传,还能跟着请求跑” 的方案!
二、救星来了:MDC 到底是个啥?
其实 Slf4j 早给咱们准备好工具了 ——MDC(Mapped Diagnostic Context),翻译过来叫 “映射诊断上下文”,听着挺玄乎,实际就是个 “线程级别的小仓库”。
它的原理特简单:基于 ThreadLocal 实现,每个线程都有自己的独立空间,你往里面塞个 traceId,这个线程里所有打印日志的地方,都能自动拿到这个 traceId。
最香的是不用改业务代码!只要在日志配置里加个占位符,日志就会自动带上 MDC 里的 traceId—— 比如原来日志是 2025-10-28 10:00:00 - 订单创建成功,加完之后就是 2025-10-28 10:00:00 - traceId=123456 - 订单创建成功,一目了然,简直是懒人福音~
三、网关(Gateway)层:统一生成 traceId,从入口把控全链路
如果项目用了 Spring Cloud Gateway 作为网关,强烈建议在网关层统一生成 traceId!这样下游所有业务服务(订单、支付、库存)都不用自己生成,直接从请求头拿就行,避免同一个请求在不同服务出现不同 traceId 的情况。
3.1 Gateway 为啥适合生成 traceId?
Gateway 是所有请求的 “入口”,不管是用户直接访问,还是服务间调用,都要经过网关(除非配置了忽略路由)。在这一层生成 traceId,有两个好处:
- 统一:整个微服务体系的 traceId 都由网关生成,格式、规则完全一致;
- 省力:下游服务不用判断 “有没有上游传 traceId”,直接拿请求头里的用就行。
3.2 Gateway 配置:用 GlobalFilter 生成并传递 traceId
Gateway 是基于 WebFlux 实现的(响应式编程),不能用 Spring MVC 的 HandlerInterceptor,得用 GlobalFilter 来拦截请求,生成 traceId 并塞到请求头里。
第一步:写 Gateway 全局过滤器
@Component
public class GatewayTraceIdFilter implements GlobalFilter, Ordered {
private static final String TRACE_ID = "traceId";
// 过滤器优先级,设高一点(数字越小优先级越高),确保在其他过滤器之前执行
private static final int ORDER = -100;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 从请求头拿 traceId,有就用(比如跨系统调用时上游传的),没有就生成
String traceId = exchange.getRequest().getHeaders().getFirst(TRACE_ID);
if (StringUtils.isEmpty(traceId)) {
// 生成 UUID 并去掉横线,看着干净
traceId = UUID.randomUUID().toString().replaceAll("-", "");
}
// 2. 把 traceId 塞到请求头里,传给下游服务
// 注意:Gateway 的请求头是只读的,得用 mutate() 复制一份再改
ServerHttpRequest modifiedRequest = exchange.getRequest()
.mutate()
.header(TRACE_ID, traceId)
.build();
// 3. 把 traceId 塞到 MDC 里,方便网关自己的日志打印(比如路由日志、异常日志)
MDC.put(TRACE_ID, traceId);
// 4. 继续执行链路,把修改后的请求传下去
// 注意:响应结束后要清除 MDC,避免内存泄漏(响应式编程用 doFinally 处理)
return chain.filter(exchange.mutate().request(modifiedRequest).build())
.doFinally(signalType -> {
// 请求处理完,清除 MDC
MDC.remove(TRACE_ID);
});
}
@Override
public int getOrder() {
return ORDER;
}
}
第二步:配置 Gateway 日志格式(可选)
如果想让 Gateway 自己的日志也显示 traceId,和业务服务一样,改一下 Gateway 的日志配置(比如 logback.xml):
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - gateway-traceId=%X{traceId} - %msg%n</pattern>
</encoder>
</appender>
这样 Gateway 的日志里就会有 gateway-traceId=xxxx,能和下游服务的 traceId 对应上,排查网关层问题也方便。
3.3 下游业务服务:不用再生成,直接拿网关传的 traceId
之前业务服务的拦截器(TraceIdInterceptor)里,已经有 “从请求头拿 traceId” 的逻辑了,现在有了 Gateway 之后,业务服务就不用自己生成了 —— 直接用 Gateway 传过来的 traceId 就行,拦截器代码不用改!
举个例子:用户请求经过 Gateway,Gateway 生成 traceId=abc123 并塞到请求头,然后请求传到订单服务,订单服务的 TraceIdInterceptor 从请求头拿到 abc123,塞到 MDC 里,订单服务的日志就会带这个 traceId;之后订单服务调支付服务,Feign 拦截器把 abc123 塞到请求头,支付服务也用这个 traceId—— 全链路统一,完美!
四、服务内部怎么玩?三步搞定!
先从单个服务入手,让同一个请求的所有日志(不管是同步方法,还是异步线程)都带上同一个 traceId。
4.1 第一步:配日志格式,让 traceId 显示出来
首先得让日志能打印出 traceId,我用的是 logback(Spring Boot 默认就是它),其他日志框架(比如 log4j2)也差不多。
先确认项目里有这两个依赖(一般 Spring Boot 项目不用额外加,默认就带):
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
</dependency>
然后改 logback.xml(没这个文件就新建一个,放 resources 目录下),在日志格式里加个 %X{traceId},比如这样:
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- 关键就是加了 traceId=%X{traceId} 这部分,其他不变 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - traceId=%X{traceId} - %msg%n</pattern>
</encoder>
</appender>
这样启动服务后,日志里就会多一列 traceId=xxxx,再也不用瞎猜哪个日志对应哪个请求了。
4.2 第二步:请求进来时,自动获取网关传的 traceId
请求刚进服务的时候,直接从请求头拿 Gateway 传的 traceId,塞到 MDC 里(不用自己生成了)。
不用改 Controller 代码,写个拦截器就行,所有请求都会走这个拦截器:
@Component
public class TraceIdInterceptor implements HandlerInterceptor {
// 统一用 "traceId" 当key,和网关保持一致,不然拿不到
private static final String TRACE_ID = "traceId";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 从请求头拿网关传的 traceId(现在几乎不会为空,除非绕开网关调用)
String traceId = request.getHeader(TRACE_ID);
// 防止极端情况(比如本地测试没走网关),还是加个判断,为空就生成一个
if (StringUtils.isEmpty(traceId)) {
traceId = UUID.randomUUID().toString().replaceAll("-", "");
}
// 塞到 MDC 里,后面所有日志都能拿到
MDC.put(TRACE_ID, traceId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 特别重要!请求结束后一定要清掉 MDC,不然线程池复用会导致 traceId 串了
// 比如线程A处理请求1,没清 MDC,再处理请求2时,会带着请求1的 traceId
MDC.remove(TRACE_ID);
}
}
然后把拦截器注册到 Spring 里,让它生效(不然写了也白写):
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private TraceIdInterceptor traceIdInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 所有请求都走这个拦截器,不用一个个接口加
registry.addInterceptor(traceIdInterceptor).addPathPatterns("/**");
}
}
这样一来,不管你调哪个接口(比如 /order/create、/pay/callback),请求进来就有 traceId 了,日志里自动带上,爽!
4.3 第三步:异步线程不丢 traceId
刚才说了,用 @Async 搞异步的时候,ThreadLocal 会失效(因为换了线程),MDC 里的 traceId 也会跟着丢 —— 比如下单成功后,异步发通知,通知的日志里没有 traceId,根本没法和下单日志串起来。
这时候得给线程池加个 “装饰器”,把当前线程的 MDC 上下文复制到异步线程里。
先写个装饰器类:
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 先把当前线程的 MDC 上下文 copy 一份,别直接用引用
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
// 异步线程里,把 copy 的上下文恢复回去
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
// 执行异步任务(比如发通知、算积分)
runnable.run();
} finally {
// 任务结束,清掉异步线程的 MDC,避免串线
MDC.clear();
}
};
}
}
然后配置异步线程池,把装饰器加上(别用默认线程池,自己定义一个):
@Configuration
@EnableAsync // 别忘了加这个注解,不然 @Async 没用
public class AsyncConfig {
@Bean
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
// 线程数根据自己项目调,别瞎设太多
executor.setCorePoolSize(5); // 核心线程数
executor.setMaxPoolSize(10); // 最大线程数
executor.setQueueCapacity(20); // 队列容量
// 关键:把 MDC 装饰器加上
executor.setTaskDecorator(new MdcTaskDecorator());
executor.initialize();
return executor;
}
}
之后用 @Async 注解异步方法时,日志里的 traceId 就不会丢了,和主线程的 traceId 一模一样~比如:
// 异步发通知
@Async("asyncExecutor") // 指定咱们配置的线程池
public void sendOrderNotice(String orderNo) {
log.info("给用户发订单通知,订单号:{}", orderNo); // 日志里会带 traceId
}
五、服务之间怎么传?Feign/RestTemplate 都搞定
单个服务玩明白了,接下来是跨服务调用 —— 比如服务 A(订单)调服务 B(支付),怎么让 B 的日志也用 A 的 traceId?
核心思路很简单:A 调用 B 的时候,把自己 MDC 里的 traceId(也就是网关传的)放到请求头里,B 收到请求后,通过刚才写的拦截器,从请求头里拿到 traceId 再塞到 MDC,完美闭环。
5.1 Feign 调用:加个拦截器就行
Feign 是 Spring Cloud 里最常用的调用工具,不用改调用代码,加个 Feign 拦截器就行 —— 每次发请求前,把 traceId 塞到请求头。
@Component
public class FeignTraceIdInterceptor implements RequestInterceptor {
private static final String TRACE_ID = "traceId";
@Override
public void apply(RequestTemplate template) {
// 从当前线程的 MDC 里拿 traceId(网关传的),有就加到请求头
String traceId = MDC.get(TRACE_ID);
if (StringUtils.isNotEmpty(traceId)) {
template.header(TRACE_ID, traceId);
}
}
}
不用额外配置!Feign 会自动扫描这个拦截器,每次调用都会带上 traceId 头 —— 比如 A 调 B 的 /pay/deduct 接口,请求头里就会有 traceId: abc123(网关生成的),B 收到后,拦截器会自动把这个 traceId 塞到 MDC,B 的日志里就有了。
5.2 RestTemplate 调用:同样加拦截器
如果项目里还用 RestTemplate 调用(比如老项目),也一样,给 RestTemplate 加个拦截器就行:
@Configuration
public class RestTemplateConfig {
private static final String TRACE_ID = "traceId";
@Bean
public RestTemplate restTemplate() {
RestTemplate restTemplate = new RestTemplate();
// 加个拦截器,往请求头塞 traceId
restTemplate.setInterceptors(Collections.singletonList((request, body, execution) -> {
String traceId = MDC.get(TRACE_ID);
if (StringUtils.isNotEmpty(traceId)) {
request.getHeaders().add(TRACE_ID, traceId);
}
// 继续执行请求,不用改其他逻辑
return execution.execute(request, body);
}));
return restTemplate;
}
}
之后用这个 RestTemplate 调其他服务,比如:
@Autowired
private RestTemplate restTemplate;
// 调用支付服务
public void callPayService(String orderNo) {
String url = "http://pay-service/pay/deduct?orderNo=" + orderNo;
restTemplate.getForObject(url, String.class); // 请求头里会自动带 traceId
}
支付服务的日志里,就能看到同一个 traceId(abc123)了。
这样我们就得到了一个统一traceId的log,不需要在因为日志里没有traceId然后对着好几十万行的日志大海捞针了。