在单体应用时代,排查一个问题通常只需要在日志文件里按时间顺序翻找即可。但切换到微服务架构后,一次请求可能先后经过网关、订单服务、库存服务、消息队列,日志被分散在十几台机器上,彼此之间毫无关联,排查一次线上故障可能要花上几个小时。解决这个问题的核心思路是:给每个请求分配一个全局唯一的 TraceId,并让它在整个调用链路中传递,所有打出的日志都带上这个标识。本文将以 Spring Boot 为例,讲解如何用 AOP 切面自动完成 TraceId 的生成、注入和清理,实现业务代码零侵入的日志串联方案。

一、TraceId 的核心原理与 MDC 机制
TraceId 的概念并不复杂:请求进入系统的第一个入口点(通常是网关或第一个被调用的服务)生成一个唯一字符串,后续所有日志都携带它。真正需要解决的问题有两个:一是如何让日志框架自动打印这个值,而不是在每个日志语句里手动拼接;二是如何在跨服务调用、跨线程执行时保证它不丢失。
第一个问题依靠 SLF4J 提供的 MDC(Mapped Diagnostic Context)来解决。MDC 本质上是一个基于 ThreadLocal 的键值对容器,我们在请求入口把 TraceId 放进去,日志框架输出时会自动读取。只需要在 logback.xml 中配置 %X{traceId} 占位符,日志格式里就会自动出现 TraceId,业务代码完全不用修改。
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>需要注意的是,MDC 基于 ThreadLocal 意味着它只在当前线程可见。如果业务中使用了线程池、@Async 异步方法或者 CompletableFuture,子线程是拿不到 TraceId 的,这是链路追踪方案中最常见的坑,后文会专门给出解决方案。
二、用 AOP 切面实现零侵入的 TraceId 管理
如果靠手动编码在每个 Controller 方法开头写入 MDC、在 finally 里清理,代码重复且容易遗漏。更好的做法是利用 Spring AOP 定义一个环绕切面,拦截入口方法,统一完成 TraceId 的生成与清理。这样业务开发者无需感知,新增接口也自动具备追踪能力。
先定义一个管理 TraceId 的工具类,封装 MDC 的读写操作,并预留一个基于请求头的获取逻辑:如果上游已经传了 TraceId 就复用,否则自己生成一个。这样跨服务串联才有基础。
public class TraceIdUtil {
public static final String TRACE_ID_KEY = "traceId";
public static final String TRACE_ID_HEADER = "X-Trace-Id";
public static String getTraceId() {
return MDC.get(TRACE_ID_KEY);
}
public static void setTraceId(String traceId) {
MDC.put(TRACE_ID_KEY, traceId);
}
public static void generateIfAbsent() {
if (MDC.get(TRACE_ID_KEY) == null) {
MDC.put(TRACE_ID_KEY, UUID.randomUUID().toString().replace("-", ""));
}
}
public static void clear() {
MDC.remove(TRACE_ID_KEY);
}
}接着编写切面。这里以拦截所有 Controller 层方法为例,使用 @Around 环绕通知,在方法执行前处理 TraceId,在 finally 块中清理,防止线程复用导致的数据串扰——这一点在 Tomcat 的线程池模型下尤为重要,因为线程会被下一个请求重复使用,如果不清理,下个请求可能打出别人的 TraceId。
@Aspect
@Component
public class TraceIdAspect {
@Pointcut("execution(* com.example.controller..*(..))")
public void controllerPointcut() {}
@Around("controllerPointcut()")
public Object around(ProceedingJoinPoint pjp) throws Throwable {
try {
// 从请求头获取上游传递的 TraceId,没有则生成新的
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
if (attrs instanceof ServletRequestAttributes) {
HttpServletRequest request =
((ServletRequestAttributes) attrs).getRequest();
String traceId = request.getHeader(TraceIdUtil.TRACE_ID_HEADER);
if (StringUtils.hasText(traceId)) {
TraceIdUtil.setTraceId(traceId);
}
}
TraceIdUtil.generateIfAbsent();
return pjp.proceed();
} finally {
// 必须清理,防止线程池复用线程导致 TraceId 串扰
TraceIdUtil.clear();
}
}
}相比使用 Filter 或 Interceptor 的方案,AOP 的优势在于可以灵活地按包名、注解来圈定拦截范围,比如你希望给对外暴露的 RPC 接口也加上追踪,只需增加一个切点表达式,或者自定义一个 @Trace 注解配合 @within 切点使用,灵活度更高。当然,Filter 方案执行时机更早,如果需要覆盖静态资源等非 Spring 管理的请求,两者可以结合使用。
三、跨服务传递:HTTP 调用与异步场景的透传
单个服务内部的日志串联只是第一步,微服务链路追踪的关键在于 TraceId 能否跨服务边界流转。对于使用 RestTemplate 的调用,最直接的方式是给它配置一个拦截器,在每次请求发出前从 MDC 中取出当前 TraceId 塞进请求头:
@Component
public class TraceIdRestTemplateInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String traceId = TraceIdUtil.getTraceId();
if (traceId != null) {
request.getHeaders().set(TraceIdUtil.TRACE_ID_HEADER, traceId);
}
return execution.execute(request, body);
}
}如果使用 OpenFeign,思路完全相同,实现一个 RequestInterceptor 即可。这样下游服务收到请求后,前文的切面会从请求头中读取到上游的 TraceId 并直接复用,整条链路的日志就串起来了。在生产环境排查问题时,从网关日志拿到 TraceId,再去 ELK 或日志平台搜索,所有相关服务的日志会按时间排列出来。
异步场景是最容易踩坑的地方。前面提到 MDC 依赖 ThreadLocal,当业务代码把任务提交到自定义线程池时,子线程的 MDC 是空的,日志里 TraceId 直接消失。解决办法是在任务提交前捕获当前 MDC 上下文,在子线程执行时恢复,执行完毕后清理。可以封装一个装饰器来简化这个操作:
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
// 主线程捕获当前 MDC 内容
Map<String, String> contextMap = MDC.getCopyOfContextMap();
return () -> {
try {
if (contextMap != null) {
// 子线程恢复 MDC 上下文
MDC.setContextMap(contextMap);
}
runnable.run();
} finally {
MDC.clear();
}
};
}
}
// 配置线程池时应用装饰器
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(16);
executor.setTaskDecorator(new MdcTaskDecorator());
executor.initialize();
return executor;
}对于消息队列场景,比如 RabbitMQ 或 Kafka,传递方式类似:生产者发消息时把 TraceId 写入消息属性或 Header,消费者收到后取出并设置到 MDC,处理完成再清理。核心思想不变——让 TraceId 跟随数据一起跨边界流动。
四、方案小结与进阶方向
到这里,一套轻量级的日志追踪体系已经成型:AOP 切面负责入口的 TraceId 生成与清理,MDC 配合日志格式实现自动打印,HTTP 拦截器完成跨服务透传,TaskDecorator 解决异步线程的上下文丢失。整套方案不引入任何重量级组件,几百行代码就能落地,对于中小规模的微服务系统已经足够使用。
不过当服务规模继续扩大、调用链路变得非常深时,建议考虑更专业的分布式追踪系统,比如 Micrometer Tracing、SkyWalking 或 Jaeger。它们在 TraceId 之外还提供 SpanId(标识链路中的单个处理节点)、调用拓扑图、耗时分析等能力,且通常基于字节码增强实现,对业务代码的侵入度更低。但在引入这类系统之前,先理解 TraceId 的传递原理永远不亏——因为所有追踪系统的底层逻辑都是一致的:给请求打上标识,让标识随调用链流动,再把过程记录下来。