1. 前文回顾和优化需求分析
前面三篇文章已经让《手写链路追踪》有一定适应能力了
前文1:手写链路追踪
前文2:手写链路追踪优化-自动全局追踪代替局部手动追踪
前文3:手写链路追踪优化-多线程追踪
我们前面的努力虽然能实现日志追踪了,但是在api请求中用trace id替换线程名,我们无法在日志中看到线程名了,我们先来看看日志中有线程名的好处吧。
1.1 线程名本身也可以做为一个trace id,是请求追踪的核心标识
用于:
- 关联同一请求的所有日志:即使请求经过多个服务或线程,都能通过traceId关联
- 问题排查:快速定位特定请求的所有处理过程
- 性能分析:跟踪请求在系统中的完整处理路径和耗时
1.2 线程名包含线程类型信息
默认线程名包含的信息
- 线程池类型:如http-nio-8080-exec-5表示HTTP处理线程
- 线程编号:帮助识别并发处理情况
- 线程用途:区分不同用途的线程(HTTP、异步、定时任务等)
1.3 并发处理信息
识别并发问题
通过线程名可以识别:
- 并发处理数量:同时处理多少个请求
- 线程竞争:是否存在线程阻塞或竞争
- 负载分布:请求在不同线程间的分布情况
1.4 异步处理追踪
跨线程追踪
在异步处理场景中,线程名帮助:
- 跟踪异步任务:从主线程到异步线程的完整调用链
- 关联父子线程:虽然使用了InheritableThreadLocal,但线程名提供了额外的可视化信息
1.5 调试和监控价值
问题诊断
- 请求路径追踪:快速查看请求经过的所有组件
- 性能瓶颈定位:识别处理慢的环节
- 异常影响范围:确定异常影响的请求范围
监控分析
- 请求流量统计:通过traceId统计独立请求量(线程名和trace id 联合使用)
- 服务依赖分析:分析服务间的调用关系
- 错误模式识别:识别特定类型请求的错误模式
1.6 总结
综合以上分析,线程名在日志追踪里面有举足重轻的作用,我们应该在日志里面保留线程名,如果能再加入一个串连上下游服务的trace id,那就更理想了。接下来我准备用MDC来实现它, 我们先来了解一下什么是MDC。
2. 什么是MDC
MDC(Mapped Diagnostic Context,映射调试上下文)是日志框架(如Log4j、Logback)提供的一个功能,用于在日志输出中添加诊断信息。
2.1 MDC的基本概念
定义
MDC是NDC(Nested Diagnostic Context)的改进版本,它允许开发者以键值对的形式将诊断信息存储在当前线程中,这些信息会自动包含在该线程生成的所有日志消息中。
核心特点
- 线程本地存储:每个线程都有独立的MDC副本
- 自动传播:与InheritableThreadLocal类似,子线程可以继承父线程的MDC
- 日志集成:日志框架自动从MDC中获取信息并添加到日志输出中
2.2 MDC vs ThreadLocal
特性 | MDC | ThreadLocal |
---|---|---|
用途 | 专门为日志诊断设计 | 通用线程本地存储 |
日志集成 | 原生支持,无需额外配置 | 需要手动处理 |
自动输出 | 日志框架自动包含在日志中 | 需要显式获取和使用 |
清理机制 | 框架自动管理部分资源 | 需要手动清理 |
2.3. MDC的使用方法
基本操作
import org.slf4j.MDC;
// 设置诊断信息
MDC.put("traceId", "abc123def456");
MDC.put("userId", "user123");
// 获取诊断信息
String traceId = MDC.get("traceId");
// 移除诊断信息
MDC.remove("traceId");
// 清空所有诊断信息
MDC.clear();
2.4 MDC在分布式追踪中的应用
多维度追踪信息
// 可以存储多个诊断信息
MDC.put("traceId", "abc123def456"); // 全局追踪ID
MDC.put("spanId", "xyz789"); // 当前操作ID
MDC.put("userId", "user123"); // 用户ID
MDC.put("requestUri", "/api/login"); // 请求路径
日志输出效果
2025-08-30 10:15:23.123 [http-nio-8080-exec-5] [traceId=abc123def456] [spanId=xyz789] [userId=user123] INFO c.s.l.c.LoginController - processing login
2.5 注意事项
-
及时清理:必须在请求结束时调用
MDC.clear()
防止内存泄漏 - 异步处理:在异步线程中需要重新设置MDC
- 线程池:使用线程池时要特别注意MDC的清理
- 性能影响:MDC操作非常轻量,性能影响可以忽略
MDC是实现分布式链路追踪的标准做法,相比直接修改线程名更加规范和灵活。
MDC已经了解这么多了,接下来我们看看怎么用它来给我的链路追踪做优化吧。
3. 用MDC实现链路追踪的优化
3.1 添加一个MDC工具类
添加一个工具类,避免调用MDC的时候到处使用"traceId" key的魔法值
package com.sandwich.logtracing.util;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.MDC;
/**
* @Author 公众号: IT三明治
* @Date 2025/8/31
* @Description: MDC工具类
*/
public class MdcUtils {
public static final String TRACE_ID = "traceId";
public static void setTraceId(String traceId) {
if (StringUtils.isBlank(traceId)) {
setTraceId();
return;
}
MDC.put(TRACE_ID, traceId);
}
public static void setTraceId() {
String traceId = RandomStrUtils.generateRandomString(15);
MDC.put(TRACE_ID, traceId);
}
public static String getTraceId() {
return MDC.get(TRACE_ID);
}
public static void removeTraceId() {
MDC.remove(TRACE_ID);
}
}
3.2 ThreadLocalContext增加一些清除trace id的日志
package com.sandwich.logtracing.util;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
/**
* @Author 公众号: IT三明治
* @Date 2025/8/30
* @Description: 这是一个用于在线程间存储和传递追踪ID(traceId)的上下文工具类,主要目的是实现请求链路追踪。
*/
@Slf4j
public class ThreadLocalContext {
/**
* 线程本地存储,用于存储追踪ID
* 使用 InheritableThreadLocal 而不是普通的 ThreadLocal
* InheritableThreadLocal 允许子线程继承父线程的值,这对于异步处理场景非常重要
*/
private static final InheritableThreadLocal<String> TRACE_ID = new InheritableThreadLocal<>();
public static String getTraceId() {
return TRACE_ID.get();
}
public static void setTraceId(String traceId) {
TRACE_ID.set(traceId);
}
/**
* 清除当前线程的traceId,防止内存泄漏
*/
public static void clearTraceId() {
if (StringUtils.isBlank(TRACE_ID.get())) {
log.info("traceId is empty from thread local already");
return;
}
TRACE_ID.remove();
log.info("traceId removed from thread local");
}
}
3.3 Filter改造
package com.sandwich.logtracing.filter;
import com.sandwich.logtracing.util.MdcUtils;
import com.sandwich.logtracing.util.RandomStrUtils;
import com.sandwich.logtracing.util.ThreadLocalContext;
import jakarta.servlet.*;
import lombok.extern.slf4j.Slf4j;
import org.apache.catalina.connector.RequestFacade;
import org.apache.commons.lang3.StringUtils;
import java.io.IOException;
/**
* @Author 公众号: IT三明治
* @Date 2025/8/30
* @Description: log filter, to update the log thread name with a trace id
*/
@Slf4j
public class LogFilter implements Filter {
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
String traceId = ((RequestFacade) servletRequest).getHeader("x-request-correlation-id");
//if the request header don't have a trace id,then generate a random one
if (StringUtils.isBlank(traceId)) {
traceId = RandomStrUtils.generateRandomString(15);
}
try {
//set trace id to mdc
MdcUtils.setTraceId(traceId);
//set trace id to thread local
ThreadLocalContext.setTraceId(traceId);
filterChain.doFilter(servletRequest, servletResponse);
} finally {
//remove trace id from thread local for main thread
ThreadLocalContext.clearTraceId();
//remove trace id from mdc
MdcUtils.removeTraceId();
}
}
}
注意finally里面清除thread local里面的trace id先,再清除MDC里面的trace id, 否则清除thread local trace id的日志会缺少trace id 值。
3.4 Aspect改造
package com.sandwich.logtracing.aspect;
import com.sandwich.logtracing.util.MdcUtils;
import com.sandwich.logtracing.util.ThreadLocalContext;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
/**
* @Author 公众号: IT三明治
* @Date 2025/8/30
* @Description: 这是一个用于在异步线程中保持traceId一致性的AOP切面,主要目的是在使用@Async注解的方法执行时,将主线程中的traceId传递到异步线程中
* 主线程是父线程,异步线程是子线程, 因此需要使用ThreadLocal来保存traceId, 以便在异步线程中保持一致性
*/
@Slf4j
@Aspect
@Component
public class TraceIdAspect {
@Around("@annotation(org.springframework.scheduling.annotation.Async)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
String traceId = ThreadLocalContext.getTraceId();
if (traceId == null) {
// 执行目标方法并返回
return joinPoint.proceed();
}
try {
MdcUtils.setTraceId(traceId);
// 执行目标方法并返回
return joinPoint.proceed();
} finally {
// clear trace id from thread local
ThreadLocalContext.clearTraceId();
// remove trace id from mdc
MdcUtils.removeTraceId();
}
}
}
注意finally里面清除thread local里面的trace id先,再清除MDC里面的trace id, 否则清除thread local trace id的日志会缺少trace id 值, 这里跟filter一样。
3.5 API改造
3.6 添加日志管理文件(logback-spring.xml)
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- 属性配置 -->
<property name="LOG_HOME" value="./logs"/>
<property name="APP_NAME" value="log-tracing"/>
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n"/>
<property name="MAX_FILE_SIZE" value="100MB"/>
<property name="MAX_HISTORY" value="30"/>
<!-- 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- INFO级别日志文件 -->
<appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${APP_NAME}-info.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>INFO</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/${APP_NAME}-info.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>${MAX_FILE_SIZE}</maxFileSize>
<maxHistory>${MAX_HISTORY}</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- WARN级别日志文件 -->
<appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${APP_NAME}-warn.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>WARN</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/${APP_NAME}-warn.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>${MAX_FILE_SIZE}</maxFileSize>
<maxHistory>${MAX_HISTORY}</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- ERROR级别日志文件 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${APP_NAME}-error.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>${MAX_FILE_SIZE}</maxFileSize>
<maxHistory>${MAX_HISTORY}</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 全量日志文件 -->
<appender name="ALL_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${APP_NAME}-all.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/${APP_NAME}-all.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>${MAX_FILE_SIZE}</maxFileSize>
<maxHistory>${MAX_HISTORY}</maxHistory>
<totalSizeCap>20GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- 异步包装器 -->
<appender name="ASYNC_INFO" class="ch.qos.logback.classic.AsyncAppender">
<discardingThreshold>0</discardingThreshold>
<queueSize>512</queueSize>
<includeCallerData>false</includeCallerData>
<appender-ref ref="INFO_FILE"/>
</appender>
<appender name="ASYNC_WARN" class="ch.qos.logback.classic.AsyncAppender">
<discardingThreshold>0</discardingThreshold>
<queueSize>512</queueSize>
<includeCallerData>false</includeCallerData>
<appender-ref ref="WARN_FILE"/>
</appender>
<appender name="ASYNC_ERROR" class="ch.qos.logback.classic.AsyncAppender">
<discardingThreshold>0</discardingThreshold>
<queueSize>512</queueSize>
<includeCallerData>false</includeCallerData>
<appender-ref ref="ERROR_FILE"/>
</appender>
<appender name="ASYNC_ALL" class="ch.qos.logback.classic.AsyncAppender">
<discardingThreshold>0</discardingThreshold>
<queueSize>512</queueSize>
<includeCallerData>false</includeCallerData>
<appender-ref ref="ALL_FILE"/>
</appender>
<!-- 开发环境配置 -->
<springProfile name="dev">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_ALL"/>
</root>
</springProfile>
<!-- 测试环境配置 -->
<springProfile name="test">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_INFO"/>
<appender-ref ref="ASYNC_WARN"/>
<appender-ref ref="ASYNC_ERROR"/>
</root>
</springProfile>
<!-- 生产环境配置 -->
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="ASYNC_INFO"/>
<appender-ref ref="ASYNC_WARN"/>
<appender-ref ref="ASYNC_ERROR"/>
</root>
</springProfile>
<!-- 默认配置 -->
<springProfile name="default">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_ALL"/>
</root>
</springProfile>
<!-- 应用特定包的日志级别 -->
<logger name="com.sandwich.logtracing" level="DEBUG" additivity="false">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_ALL"/>
</logger>
<!-- Spring框架日志级别 -->
<logger name="org.springframework" level="INFO"/>
<!-- 数据库相关日志级别 -->
<logger name="org.hibernate" level="WARN"/>
<logger name="org.springframework.jdbc" level="INFO"/>
</configuration>
这个日志文件是用一个文件满足不同环境的配置的,根据不同的active profile自动匹配不同环境的日志输出配置。前面手写链路追踪我一直没有添加日志管理文件,都是用springboot系统默认的。
3.7 公共yml file改造(application.yml)
spring:
application:
name: log-tracing
# 日志配置
logging:
level:
com.sandwich.logtracing: DEBUG
org.springframework: INFO
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %X{traceId:-} %-5level %logger{50} - %msg%n"
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %X{traceId:-} %-5level %logger{50} - %msg%n"
file:
name: ./logs/log-tracing.log
logback:
rollingpolicy:
max-file-size: 100MB
max-history: 30
这里的pattern是为了验证yml的配置优先级高还是xml的配置优先级更高。
yml的pattern如下:
"%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %X{traceId:-} %-5level %logger{50} - %msg%n"
xml的pattern如下:
"%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n"
xml的trace id 配置[%X{traceId}]是我故意留有缺陷的,它会使得traceId为空的时候显示一个空括号[], 这不仅影响API请求日志
3.8 增加一个dev场的profile(application-dev.yml)
server:
port: 8080
这个配置比较简单,只是为了验证active profile可以自动匹配对应的日志配置而新增的一个profile
4.测试验证
-
用dev profile启动项目
用shell client发送 API 请求
$ ./login.sh
Generated traceId from client side: A7Ua41KRLabtzUo
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 144 0 100 100 44 125 55 --:--:-- --:--:-- --:--:-- 180
Response from login API:
{
"responseCode": 200,
"message": "success",
"data": "Sandwich login success",
"traceId": "A7Ua41KRLabtzUo"
}
- 验证日志
由上图日志可以看出:
- 1.用MDC的方式能保留多线程的不同线程名
- 2.以上实现也能追踪异步线程
- 3.trace id只影响了API请求的线程,并不影响[main]线程,[main]线程日志并没有因为trace id为空而出现[ ], 所以日志pattern生效的是yml里面的配置,它的优先级更高,能取代xml里面关于pattern的配置
- 4.thread local里面的trace id在API异步线程的API主线程里面分别被removed了一遍,先removed了子线程的,再removed父线程的。保留线程名的好处在这个分析里面进一步体现了它的好处
- 5.MDC中的trace id,可以在请求上下文中任何节点取出来,如本次API在response前直接get出来
- 6.logback配置不同环境的配置,可以通过active profile自动匹配
5. 总结
本文用MDC的方式优化了手写链路追踪,MDC在日志上下文的使用非常方便灵活,你可以在java代码中对它进行get, set, 也可以把它读出来,通过xml/yml配置的日志管理方式把它写进日志里面。到目前为止,我们的链路追踪还只是集中在trace id, 后面我们继续研究它的其他妙用。如果您有好的建议,请在评论区与我分享。请关注我,我们下期继续。