1. 为什么需要TraceId日志追踪?
在分布式系统或高并发场景下,排查问题就像在嘈杂的菜市场找人说悄悄话。想象一下:你的SpringBoot应用每秒处理100个请求,每个请求会经过Controller、Service、DAO多个层级,还可能调用外部服务。当某个用户反馈问题时,你需要在海量日志中找出这个用户请求的所有相关日志,传统方式只能通过时间戳模糊搜索,效率极低。
TraceId(追踪标识)就是为解决这个问题而生。它就像快递单号,从用户请求进入系统的第一刻就被生成,并随着请求的流动传递到每个处理环节。无论这个请求经过多少线程、多少服务,只要日志中带着相同的TraceId,我们就能用这个"单号"快速收集所有相关日志。
2. 实现方案核心设计
2.1 MDC机制——线程级上下文存储
MDC(Mapped Diagnostic Context)是SLF4J提供的线程绑定存储空间,原理类似于ThreadLocal,但专为日志设计。它的关键特点:
- 线程隔离:每个线程有独立的存储,互不干扰
- 自动继承:子线程默认继承父线程的MDC内容
- 轻量级:底层使用Map结构,性能损耗极小
典型使用方式:
java复制// 存入数据
MDC.put("traceId", "123abc");
// 获取数据
String traceId = MDC.get("traceId");
// 移除数据
MDC.remove("traceId");
2.2 拦截器方案选型
常见的三种实现方式对比:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Servlet Filter | 处理最早,性能最好 | 无法获取Spring上下文 | 纯HTTP请求场景 |
| HandlerInterceptor | 可获取Handler信息 | 无法处理静态资源请求 | MVC控制器场景 |
| AOP切面 | 最灵活,可切任意方法 | 性能开销较大 | 需要追踪非HTTP请求时 |
我们选择Filter方案,因为:
- 日志追踪需要最早介入请求(甚至早于Spring MVC)
- 性能敏感场景需要最小化开销
- 使用OncePerRequestFilter避免重复处理
3. 完整实现步骤
3.1 基础环境搭建
pom.xml关键依赖:
xml复制<dependencies>
<!-- Web基础 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Lombok简化代码 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
3.2 TraceId工具类封装
创建MdcUtil.java:
java复制public class MdcUtil {
private static final String TRACE_ID = "X-Trace-Id";
private static final String TRACE_ID_PREFIX = "TRACE-";
// 生成带前缀的TraceId(示例:TRACE-3a4b5c6d7e8f)
public static String generateTraceId() {
return TRACE_ID_PREFIX + UUID.randomUUID().toString().substring(0, 12);
}
// 从MDC获取或生成新TraceId
public static String getOrGenerate() {
String traceId = MDC.get(TRACE_ID);
return traceId != null ? traceId : generateTraceId();
}
// 设置到当前线程MDC
public static void set(String traceId) {
if (traceId != null && !traceId.startsWith(TRACE_ID_PREFIX)) {
traceId = TRACE_ID_PREFIX + traceId;
}
MDC.put(TRACE_ID, traceId);
}
// 清理MDC
public static void clear() {
MDC.remove(TRACE_ID);
}
}
3.3 实现TraceId过滤器
TraceIdFilter.java核心代码:
java复制@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 1) // 确保最先执行
public class TraceIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws IOException, ServletException {
// 尝试从请求头获取TraceId
String traceId = request.getHeader(MdcUtil.TRACE_ID);
// 不存在则生成新的
if (StringUtils.isEmpty(traceId)) {
traceId = MdcUtil.generateTraceId();
}
// 存入MDC
MdcUtil.set(traceId);
try {
// 添加到响应头方便前端追踪
response.addHeader(MdcUtil.TRACE_ID, traceId);
// 继续执行过滤器链
chain.doFilter(request, response);
} finally {
// 确保清理防止内存泄漏
MdcUtil.clear();
}
}
}
3.4 日志格式配置
logback-spring.xml配置示例:
xml复制<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>
%d{yyyy-MM-dd HH:mm:ss.SSS}
[%thread]
[%X{X-Trace-Id}]
%-5level
%logger{36} - %msg%n
</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
4. 异步场景深度处理
4.1 问题重现
测试代码展示问题:
java复制@GetMapping("/async")
public String asyncExample() {
log.info("主线程开始");
CompletableFuture.runAsync(() -> {
log.info("异步线程日志 - TraceId应该存在吗?");
});
return "查看控制台日志";
}
输出结果:
code复制2023-08-01 10:00:00 [http-nio-8080-exec-1] [TRACE-3a4b5c6d7e8f] INFO Controller - 主线程开始
2023-08-01 10:00:00 [ForkJoinPool-1-worker-1] [] INFO Controller - 异步线程日志
可以看到异步线程丢失了TraceId!
4.2 解决方案:包装线程池
创建MdcThreadPoolTaskExecutor:
java复制public class MdcThreadPoolTaskExecutor extends ThreadPoolTaskExecutor {
@Override
public void execute(Runnable task) {
super.execute(wrap(task, MDC.getCopyOfContextMap()));
}
@Override
public <T> Future<T> submit(Callable<T> task) {
return super.submit(wrap(task, MDC.getCopyOfContextMap()));
}
private static Runnable wrap(Runnable runnable, Map<String, String> context) {
return () -> {
if (context != null) {
MDC.setContextMap(context);
}
try {
runnable.run();
} finally {
MDC.clear();
}
};
}
private static <T> Callable<T> wrap(Callable<T> callable, Map<String, String> context) {
return () -> {
if (context != null) {
MDC.setContextMap(context);
}
try {
return callable.call();
} finally {
MDC.clear();
}
};
}
}
配置异步线程池:
java复制@Configuration
@EnableAsync
public class AsyncConfig {
@Bean
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new MdcThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("Async-");
executor.initialize();
return executor;
}
}
5. 生产环境进阶配置
5.1 TraceId生成策略优化
默认UUID方案的问题:
- 长度过长(32字符)
- 无业务含义
- 无法区分环境
改进方案:
java复制public static String generateTraceId() {
return String.format("%s-%s-%s",
EnvironmentUtils.getEnvShort(), // 环境标识
LocalDateTime.now().format(DateTimeFormatter.ofPattern("MMddHHmm")),
RandomStringUtils.randomAlphanumeric(6).toLowerCase()
);
}
// 示例输出:DEV-08011015-3a4b5c
5.2 跨服务传递实现
RestTemplate拦截器:
java复制public class TraceRestTemplateInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
String traceId = MdcUtil.getOrGenerate();
request.getHeaders().add(MdcUtil.TRACE_ID, traceId);
return execution.execute(request, body);
}
}
Feign客户端配置:
java复制@Bean
public RequestInterceptor feignTraceInterceptor() {
return template -> {
String traceId = MdcUtil.getOrGenerate();
template.header(MdcUtil.TRACE_ID, traceId);
};
}
5.3 日志收集分析
ELK配置示例(logstash.conf):
conf复制filter {
grok {
match => { "message" => "\[%{TIMESTAMP_ISO8601:timestamp}\]
\[%{NOTSPACE:thread}\]
\[%{DATA:trace_id}\]
%{LOGLEVEL:level}
%{DATA:logger} - %{GREEDYDATA:msg}" }
}
}
这样在Kibana中就可以用trace_id字段快速过滤日志。
6. 踩坑实录与性能优化
6.1 常见问题排查
问题1:日志中看不到TraceId
- 检查项:
- 确认logback配置中有
%X{X-Trace-Id} - 确保Filter生效(查看注册顺序)
- 检查是否有其他Filter清除了MDC
- 确认logback配置中有
问题2:异步线程TraceId时有时无
- 解决方案:
- 确认使用包装后的线程池
- 检查线程池是否被覆盖
- 避免在异步任务中手动清除MDC
6.2 性能影响测试
压测对比(JMeter 100并发):
| 场景 | 平均响应时间 | 吞吐量 |
|---|---|---|
| 无TraceId | 23ms | 4200/s |
| 基础实现 | 25ms (+8%) | 4000/s |
| 带异步传递 | 28ms (+21%) | 3800/s |
| 优化版(对象池) | 24ms (+4%) | 4150/s |
优化建议:
- 使用对象池重用TraceId字符串
- 异步场景控制线程池大小
- 避免在高频循环中操作MDC
7. 扩展应用场景
7.1 全链路监控集成
与SkyWalking集成:
java复制@Bean
public TracingFilter skywalkingTraceFilter() {
return new TracingFilter() {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 将SkyWalking的TraceId同步到MDC
String skywalkingTraceId = ContextManager.getGlobalTraceId();
MdcUtil.set(skywalkingTraceId);
try {
filterChain.doFilter(request, response);
} finally {
MdcUtil.clear();
}
}
};
}
7.2 业务打标扩展
在MDC中添加业务标识:
java复制public static void addBusinessTag(String userId, String orderNo) {
MDC.put("userId", userId);
MDC.put("orderNo", orderNo);
}
// 日志格式添加:
// [%X{userId}] [%X{orderNo}]
7.3 安全审计增强
敏感操作日志增强:
java复制@Aspect
@Component
public class AuditLogAspect {
@AfterReturning("execution(* com..service.*Service.*(..))")
public void auditLog(JoinPoint jp) {
String traceId = MdcUtil.getOrGenerate();
String userId = SecurityUtils.getCurrentUserId();
log.info("[AUDIT] {}操作{},TraceId:{}",
userId, jp.getSignature().getName(), traceId);
}
}
