Spring Boot实现日志链路追踪

文章目录

目的:有时候一个业务调用链的场景很长,其中调了各种各样的方法,现在需要把同一次业务调用链上的日志串起来,也就是给同一个 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 先自定义线程池,并配置线程池

java 复制代码
/**
 * 自定义ThreadPoolTaskExecutor线程池
 */
public class MyThreadPoolTaskExecutor extends ThreadPoolTaskExecutor {

    // 重写父类的方法,进行任务包装:为了将父线程的MDC传进去线程池
    @Override
    public void execute(Runnable task) {
        super.execute(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
    }

    @Override
    public Future<?> submit(Runnable task) {
        return super.submit(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
    }

    @Override
    public <T> Future<T> submit(Callable<T> task) {
        return super.submit(ThreadMdcUtil.wrap(task, MDC.getCopyOfContextMap()));
    }
}
java 复制代码
/**
 * 线程池配置
 */
@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

java 复制代码
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)传递给子线程使用

  • 代码解释:

    1. 如果父线程没传任何上下文(context == null),那清空子线程的 MDC(为下一步做准备)----> 否则,把父线程的上下文设置到子线程;

      不管哪种情况,最后都执行一次 setTraceIdIfAbsent()

    2. 当父线程 context 不为 nullMDC.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

      总结: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

java 复制代码
@Component
public class ExecutorInspector {

    @Autowired
    private ApplicationContext ctx;

    @PostConstruct
    public 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、怎么灵活选择:

java 复制代码
@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;
    }


    // 默认的执行器
    @Override
    public Executor getAsyncExecutor() {
        return new SimpleAsyncTaskExecutor();
    }
}

结束,记得点赞收藏哦!!!

相关推荐
xiaogg36783 小时前
阿里云k8s1.33部署yaml和dockerfile配置文件
java·linux·kubernetes
逆光的July3 小时前
Hikari连接池
java
微风粼粼4 小时前
eclipse 导入javaweb项目,以及配置教程(傻瓜式教学)
java·ide·eclipse
番茄Salad4 小时前
Spring Boot临时解决循环依赖注入问题
java·spring boot·spring cloud
天若有情6734 小时前
Spring MVC文件上传与下载全面详解:从原理到实战
java·spring·mvc·springmvc·javaee·multipart
祈祷苍天赐我java之术4 小时前
Redis 数据类型与使用场景
java·开发语言·前端·redis·分布式·spring·bootstrap
用户21411832636024 小时前
OpenSpec 实战:用规范驱动开发破解 AI 编程协作难题
后端
Olrookie5 小时前
若依前后端分离版学习笔记(二十)——实现滑块验证码(vue3)
java·前端·笔记·后端·学习·vue·ruoyi
LucianaiB5 小时前
招聘可以AI面试,那么我制作了一个AI面试教练不过分吧
后端