Spring Boot实现日志链路追踪
文章目录
- 分析及实现过程:
- 什么是MDC
- 常用API
- 代码实现
- 再谈@Async
目的:有时候一个业务调用链的场景很长,其中调了各种各样的方法,现在需要把同一次业务调用链上的日志串起来,也就是给同一个 trace ID 即可。
分析及实现过程:
什么是MDC
1、MDC(映射诊断上下文)是一个把键值对(Map<String,String>
)绑定到当前线程上的机制。主要用于把请求级别或线程级别的上下文信息(如 traceId、userId等)自动带到日志输出中,便于日志追踪与排查。它是 SLF4J
提供的 API。
2、MDC 在大多数实现里基于 ThreadLocal<Map<String,String>>
。也就是说,MDC 的数据绑定到 线程 上,只有当前线程能看到自己的 MDC。
3、大多数 Spring Boot 项目默认已经包含:spring-boot-starter-logging
(内部包含 Logback 和 slf4j),因此通常无需额外引依赖。
使用:MDC.put("TRACE_ID", "xxx")
,则在日志格式里用 %X{TRACE_ID}
(Logback)或 ${ctx:TRACE_ID}
(Log4j2)取值。
常用API
常见方法(都来自 org.slf4j.MDC
):
MDC.put(String key, String value)
:设置键值对到当前线程 MDC。MDC.get(String key)
:获取当前线程中 key 对应的值。MDC.remove(String key)
:移除某个 key。MDC.clear()
:清空当前线程全部 MDC。MDC.getCopyOfContextMap()
:获取当前线程 MDC 的 拷贝(返回Map<String,String>
或null
)。用于把父线程上下文传给子线程。MDC.setContextMap(Map<String,String>)
:把给定 map 设为当前线程的 MDC(覆盖)。
注意:尽量使用 getCopyOfContextMap()
获取拷贝,避免直接传递可变引用导致并发修改问题
代码实现
可以参考上传到 Gitee 中的代码:完整代码
下面贴出主要代码,并附带分析:
1、再来看看主要的实现代码
1.1 先自定义线程池,并配置线程池
/*** 自定义ThreadPoolTaskExecutor线程池*/
public class MyThreadPoolTaskExecutor extends ThreadPoolTaskExecutor {// 重写父类的方法,进行任务包装:为了将父线程的MDC传进去线程池@Overridepublic void execute(Runnable task) {super.execute(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));}@Overridepublic Future<?> submit(Runnable task) {return super.submit(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));}@Overridepublic <T> Future<T> submit(Callable<T> task) {return super.submit(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));}
}
/*** 线程池配置*/
@Configuration
public class MyThreadPoolTaskExecutorConfig {@Bean("MyExecutor")public Executor asyncExecutor() {MyThreadPoolTaskExecutor myExecutor = new MyThreadPoolTaskExecutor();myExecutor.setCorePoolSize(5);myExecutor.setMaxPoolSize(5);myExecutor.setQueueCapacity(500);myExecutor.setKeepAliveSeconds(60);myExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());myExecutor.setThreadNamePrefix("ZFP");myExecutor.initialize();return myExecutor;}
}
1.2 生成并使用 traceId
public final class ThreadMdcUtil {private static final String TRACE_ID = "TRACE_ID";// 生成唯一标识public static String generateTraceId() {return UUID.randomUUID().toString();}// 假如当前线程没有traceId,就生成一个并设置public static void setTraceIdIfAbsent() {if (MDC.get(TRACE_ID) == null) {MDC.put(TRACE_ID, generateTraceId());}}/*** Callable(异步任务有返回值):父线程向线程池中提交任务时,将自身MDC中的数据复制给子线程*/public static <T> Callable<T> wrap(final Callable<T> callable, final Map<String, String> context) {return () -> {// 如果父线程传进来的(他的MDC)没有值if (context == null) {// 清理子线程的MDC,为下一步做准备MDC.clear();} else {// 有值:父线程的MDC复制给子线程MDC.setContextMap(context);}// context!=null,并且context里面有traceId,// 上面else不是做了,这里为什么还做,为了确保有TRACE_ID,兜底setTraceIdIfAbsent();try {return callable.call();} finally {MDC.clear();}};}/*** Runnable(异步任务没有返回值):父线程向线程池中提交任务时,将自身MDC中的数据复制给子线程*/public static <T> Runnable wrap(final Runnable runnable, final Map<String, String> context) {return () -> {if (context == null) {MDC.clear();} else {MDC.setContextMap(context);}setTraceIdIfAbsent();try {runnable.run();} finally {MDC.clear();}};}
}
分析代码:
① 父线程 和 子线程
- 父线程(调用方):当前正在运行的线程,比如处理HTTP请求的线程、主线程。它决定向线程池提交任务。
- 子线程(执行方):真正在线程池中执行任务的线程。它不是新建的,而是线程池里复用的工作线程。
父线程是“派任务的人”,子线程是“干活的人”。父线程要告诉子线程:“你干活时要带上我的上下文(trace id、userId 等)”
② 为什么要重写线程池中的提交和执行方法?
如果不重新自定义线程池,子线程看不到父线程的MDC(也就是父线程的MDC怎么传给子线程),比如下图:
图中:执行 /insert
接口,流程:主线程在拦截器中设置了 MDC ----> 遇到 @Async
注解的方法,由于没有指定线程池,所以使用默认的线程池执行 ----> 子线程肯定拿不到父线程的 MDC,所以主线程有MDC,子线程为空。
所以要重写线程池中的方法,以便将父线程的 MDC 传给子线程。那怎么传?看 ③ 、④
③ 为什么要写这个 wrap 方法?
- MDC是基于ThreadLocal的 ----> 所以父线程和子线程各自有独立的 MDC 存储空间 ----> 所以子线程默认 拿不到父线程的 MDC 内容。
- 而且如果线程池复用线程,还可能“串号”(也就是:子线程可能残留上一次任务的 MDC)
④ 那怎么理解 wrap 方法?
-
父线程调用 wrap 方法(创建包装);子线程执行包装内部的代码;目的是 把父线程的 MDC 上下文(比如 traceId)传递给子线程使用。
-
代码解释:
-
如果父线程没传任何上下文(
context == null
),那清空子线程的 MDC(为下一步做准备)----> 否则,把父线程的上下文设置到子线程;不管哪种情况,最后都执行一次
setTraceIdIfAbsent()
。 -
当父线程
context
不为null
,MDC.setContextMap(context);
那为什么还需要setTraceIdIfAbsent();
已经重复了 ,为什么?- 如果
context
已经包含了"TRACE_ID"
,那MDC.setContextMap(context)
后,MDC 里肯定已经有这个 key。 这时再调用setTraceIdIfAbsent()
,它检查到MDC.get("TRACE_ID") != null
,就不会再生成新的 traceId。也就是说它不会“重复设置”,但会进行一次判断。 - 兜底检查:
- 假如父线程 context 内容为:
{ "USER_ID": "u001" }
(父线程没 TRACE_ID),子线程setTraceIdIfAbsent()
会生成一个新的 traceId(兜底); - 假如无任何 context(父线程没传 MDC),
MDC.clear()
后执行setTraceIdIfAbsent()
,生成一个新的 traceId
- 假如父线程 context 内容为:
总结:
setTraceIdIfAbsent()
的存在是为了“防御”那些没有带 traceId 的任务场景,保证所有线程都有唯一标识。(虽然在拦截器中设置了,但防止意外) - 如果
-
5、运行验证:
① 正常没有异步任务的接口:/pay
② 有异步任务的接口:/insert
本来是正常的结果,但是有个问题:就是正常实现了,而且我没指定线程池,它自己就使用了我的线程池,按道理来说,应该使用默认的线程池(SimpleAsyncTaskExecutor
,然后实现不了)
再谈@Async
1、当 Spring 处理 @Async
时:如果容器里存在一个(或可识别的)Executor/TaskExecutor
bean,Spring 会把它当作默认执行器来使用。所以你“没显式在 @Async
指定线程池”也会用到你定义的线程池 —— 因为 Spring 自动选了容器里那个合适的 Executor 作为默认。
2、@Async
的执行器来源优先级:
- 如果
@EnableAsync
的配置类实现了AsyncConfigurer
,则getAsyncExecutor()
返回的Executor
会被当作默认 executor。否则,若容器中有唯一的Executor
/TaskExecutor
类型的 bean,Spring 会把它当默认 executor。 - 若容器中有多个
Executor
,但有一个名为taskExecutor
(或有@Primary
)的 bean,则会优先使用它。 - 最后兜底:如果没有找到可用的 executor,Spring 会创建一个
SimpleAsyncTaskExecutor
作为默认(但这通常不是你遇到的情况)。
3、定位是哪一个 Executor
@Component
public class ExecutorInspector {@Autowiredprivate ApplicationContext ctx;@PostConstructpublic void inspect() {Map<String, Executor> execs = ctx.getBeansOfType(Executor.class);System.out.println("===== Executors in context =====");execs.forEach((name, exe) -> {System.out.println("beanName=" + name + ", class=" + exe.getClass().getName());});System.out.println("================================");}
}
4、怎么灵活选择:
@Configuration
public class MyThreadPoolTaskExecutorConfig implements AsyncConfigurer { // 实现这个接口@Bean("MyExecutor")public Executor asyncExecutor() {MyThreadPoolTaskExecutor myExecutor = new MyThreadPoolTaskExecutor();myExecutor.setCorePoolSize(5);myExecutor.setMaxPoolSize(5);myExecutor.setQueueCapacity(500);myExecutor.setKeepAliveSeconds(60);myExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());myExecutor.setThreadNamePrefix("ZFP");myExecutor.initialize();return myExecutor;}// 默认的执行器@Overridepublic Executor getAsyncExecutor() {return new SimpleAsyncTaskExecutor();}
}
结束,记得点赞收藏哦!!!