在微服务体系里,一次下单请求往往会触发认证、订单、库存、支付、风控等十多个服务之间的调用。当出现超时或数据异常时,开发人员如果只是到各个服务里按关键字搜索日志,不仅效率低,而且很难判断调用先后顺序和耗时瓶颈。给每笔请求分配一个唯一标识 TraceId,并让它在所有经过的服务和日志中透传,是成本较低的链路追踪方案。AOP 可以无侵入地为关键方法补上耗时、入参和异常记录,进一步提升定位效率。本文以 Spring Boot 技术栈为例,说明如何落地这套机制。

一、在入口生成 TraceId 并写入 MDC
TraceId 本质上是一个跨服务唯一的字符串,通常由网关或首个过滤器生成。可以选择 UUID 去掉短横线,也可以使用雪花算法生成更短的值。关键是同一请求在后续所有调用中都复用这个值。Slf4j 提供的 MDC 可以理解为一个线程级的键值存储,日志输出时通过占位符读取其中的内容。我们可以在过滤器里把 TraceId 放入 MDC,后续当前线程打印日志时就能自动附带该值。
下面是一个基于 OncePerRequestFilter 的示例。它先读取上游请求头 X-Trace-Id,如果上游已经生成则直接沿用,避免每个服务都重新生成导致链路断裂。然后写入 MDC,并把它回写到响应头,这样前端或调用方也能拿到问题定位凭据。代码示例如下。
@Component
public class TraceIdFilter extends OncePerRequestFilter {
private static final String TRACE_ID = "traceId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null || traceId.isEmpty()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
MDC.put(TRACE_ID, traceId);
response.setHeader("X-Trace-Id", traceId);
try {
filterChain.doFilter(request, response);
} finally {
MDC.remove(TRACE_ID);
}
}
}
这里需要特别注意 finally 中的清理动作。MDC 底层依赖 ThreadLocal,而 Web 容器通常复用线程处理新请求。如果没有清理,当前线程下一次处理别的请求时,如果那个请求没有及时写入新的 TraceId,就可能把上一个请求的标识写入日志,造成串号。清理动作放在 finally 中能保证无论后续处理是否抛异常都会执行。
如果系统入口是网关层,也可以选择在网关生成并统一透传,下游服务只读取不生成。对于直接暴露给客户端的单体服务,这个过滤器已经足够。对于响应式 WebFlux 或异步任务,还需要借助 Reactor Context 或线程包装机制,不能直接沿用这个基于 ThreadLocal 的方案。
二、用 AOP 丰富方法级追踪信息
过滤器只能记录入口和出口,业务方法内部哪一步慢、哪一步报错仍然需要打点。AOP 可以在不修改业务代码的情况下拦截方法执行,统一记录耗时和异常。Spring AOP 基于代理实现,对 Spring 容器中的 Bean 生效。我们通常把切入点放在 Controller 层或标注了自定义注解的方法上。切面里可以获取方法签名、入参、出参以及执行时间。
以下切面示例以 controller 包下的所有方法为切入点。进入切面时先记录开始时间,然后执行原方法,最后计算耗时。日志内容中只需要输出普通消息,TraceId 会由 MDC 自动附加到每一行日志,不需要手动拼接。
@Aspect
@Component
public class TraceAspect {
private static final Logger log = LoggerFactory.getLogger(TraceAspect.class);
@Around("execution(* com.example.order.controller..*(..))")
public Object aroundController(ProceedingJoinPoint pjp) throws Throwable {
long start = System.currentTimeMillis();
String method = pjp.getSignature().toShortString();
try {
Object result = pjp.proceed();
long cost = System.currentTimeMillis() - start;
log.info("方法 {} 执行成功, 耗时 {} ms, 参数 {}",
method, cost, Arrays.toString(pjp.getArgs()));
return result;
} catch (Throwable e) {
long cost = System.currentTimeMillis() - start;
log.error("方法 {} 执行失败, 耗时 {} ms, 异常 {}",
method, cost, e.getMessage(), e);
throw e;
}
}
}
这段代码会在方法成功和失败时分别输出一条日志,包括方法签名、耗时和入参。异常必须重新抛出去,否则会干扰全局异常处理器和事务回滚逻辑。对于入参和出参,如果包含敏感数据,建议自定义脱敏工具,避免密码、手机号、身份证号等信息被记录。不要在这个切面里清理 MDC,因为过滤器已经负责清理,切面的作用范围只限于方法执行。
性能层面,AOP 会增加一次代理调用和参数序列化成本。对高并发的核心链路,可以缩小切入点范围,例如只切标注 @TraceLog 注解的方法,或只记录执行时间超过阈值的慢方法。这样既能保留关键信息,又不会拖慢整体吞吐。
三、在服务间透传 TraceId
单体服务内部靠线程共享 MDC 就够了,但微服务之间通过 HTTP 或 RPC 调用时,TraceId 必须显式传递。如果下游服务收不到上游的 X-Trace-Id,就会自己生成一个新值,整条链路会被切断。常用的 OpenFeign 和 RestTemplate 都提供了拦截器扩展点,可以统一写入请求头。
以 Feign 为例,实现 RequestInterceptor 接口,在 apply 方法中把当前线程 MDC 里的 TraceId 写入模板请求头。代码如下。
@Component
public class FeignTraceInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String traceId = MDC.get("traceId");
if (traceId != null) {
template.header("X-Trace-Id", traceId);
}
}
}
RestTemplate 则通过 ClientHttpRequestInterceptor 实现类似功能。需要把拦截器注册到 RestTemplate 实例上。如果是通过 Ribbon 或 LoadBalancer 管理的 RestTemplate,也可以在 Bean 初始化时手动调用 setInterceptors 方法。
public class RestTemplateTraceInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String traceId = MDC.get("traceId");
if (traceId != null) {
request.getHeaders().set("X-Trace-Id", traceId);
}
return execution.execute(request, body);
}
}
下游服务的过滤器会优先读取这个请求头,从而继续沿用同一个 TraceId。完整链路可以描述为:入口生成 TraceId 放入 MDC,AOP 记录方法日志,HTTP 客户端拦截器把 MDC 中的值写入请求头,下游过滤器读取并再次放入自己的 MDC。每个环节都不需要业务代码参与,对开发人员透明。
四、配置日志格式并串联查询
要让 TraceId 出现在日志里,还需要在日志框架的输出格式中增加 MDC 占位符。使用 logback 时,编辑 logback.xml,在 pattern 中加入 %X{traceId}。当 MDC 中没有该键时,这个位置会输出空字符串,不影响日志可读性。下面是一个控制台输出的简单配置。
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>
生产环境一般会把日志集中到 Elasticsearch 等平台。日志采集组件会解析每一行内容,%X{traceId} 输出后可以作为一个字段进行检索。当某个用户反馈下单失败并提供了请求编号,或者开发人员从响应头里拿到 X-Trace-Id 后,在日志平台输入该值,就能看到这次请求经过的所有服务和内部方法调用顺序。配合每个服务的耗时日志,还能快速发现瓶颈位于哪个环节。
如果日志量较大,可以进一步在日志平台按 traceId 做索引或聚合,展示出完整的调用时间线。这种方案比完整引入 OpenTelemetry 之类的分布式追踪系统要简单得多,也不需要重建调用树或采集 span 数据。对于中小规模微服务而言,先用 TraceId 串联日志,往往就能解决大部分线上排查问题。
五、落地时需要注意的问题
第一个常见问题是线程切换导致 MDC 丢失。Web 容器的同步请求处理线程通常没有问题,但一旦进入异步线程池、消息监听线程或自定义线程池,ThreadLocal 中保存的 TraceId 就无法自动带到子线程。解决办法是使用装饰器或包装线程池,把 MDC 的内容复制到子线程,并在任务结束后清理。Spring 的 ThreadPoolTaskExecutor 可以通过 setTaskDecorator 来实现,也可以自己实现 Runnable 包装。
第二个问题是 AOP 失效。Spring AOP 只拦截通过代理对象发起的方法调用,类内部的 this.method() 调用不会经过代理,因此切面不生效。另外私有方法、final 方法默认也无法被代理增强。遇到这种情况时,可以把切点放到 Controller 层或独立的 Service 边界,或者改用 AspectJ 编译期织入。切面配置也要避免无限递归,比如切点范围恰好切到切面自身方法。
第三个问题是日志膨胀。全量记录方法入参和出参会成倍增加日志量,尤其是遇到大对象或文件字节数组时。建议生产环境只记录关键参数或业务标识,调试环境再开启详细打印。还可以设置日志级别和采样比例,对健康检查、静态资源等低价值请求跳过记录。只要 TraceId 能贯穿关键调用链,定位能力就不会明显下降。
总体来看,这套方案依赖的是 Spring 生态里已经成熟的组件:过滤器、AOP、MDC 和日志格式化。它不需要引入新的中间件或 SDK,开发和运维成本都很低。当服务规模继续扩大、需要跨进程序列和调用耗时统计时,可以在已有 TraceId 的基础上逐步演进到 OpenTelemetry 或其他分布式追踪标准。