文章

Spring Boot + MDC 实现全链路调用日志跟踪

前言

日志追踪是一种追踪请求在系统中流转过程的技术,可以帮助我们定位问题、分析性能瓶颈以及进行故障排查。但在多线程环境中,若没有相关框架的支持,想要实现日志追踪,就需要编码实现将主线程的日志参数传递给子线程,本文就在线程池场景下借助 MDC 实现了 traceId 参数的透传。

MDC 介绍

MDCMapped Diagnostic Context,映射调试上下文)是 log4jlogback 提供的一种方便在多线程条件下记录日志的功能。MDC 可以看成是一个与当前线程绑定的哈希表,可以往其中添加键值对。MDC 中包含的内容可以被同一线程中执行的代码所访问。当需要记录日志信息时,只需要从 MDC 中获取所需的信息即可。MDC 的内容由程序在适当的时候保存进去,对于 Web 应用来说,通常在请求被处理的最开始保存这些信息(如 通过拦截器将 traceId 写入 MDC)。

1. Api 使用

  • clear() :移除所有 MDC
  • get (String key) :获取当前线程 MDC 中指定 key 的值
  • getContext() :获取当前线程 MDC 的 MDC
  • put(String key, Object o) :往当前线程的 MDC 中存入指定的键值对
  • remove(String key) :删除当前线程 MDC 中指定的键值对

2. 依赖坐标

Log4j2 为例

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-log4j2</artifactId>
    <version>2.7.1</version>
</dependency>

MDC 使用

web 请求开始处理的时候,利用 Srping 提供的拦截器将 traceId 写入到 MDC 中。

1. 添加拦截器

public class MDCInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        this.setTraceId(request);
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {MDC.clear();
    }

    private void setTraceId(HttpServletRequest request) {
        String traceId = request.getHeader(MDCUtil.TRACE_ID);
        if (StringUtils.isBlank(traceId)) MDCUtil.setTraceIdIfAbsent();
        else MDC.put(MDCUtil.TRACE_ID, traceId);
    }
}

2. 注册拦截器

@Slf4j
@Configuration
public class InterceptorConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        log.info("[注册拦截器] MDCInterceptor");
        registry.addInterceptor(new MDCInterceptor());}
}

3. 修改日志配置

核心配置,注意 %X{TRACE_ID} 中的 TRACE_ID 需要和放入 MDC 的 key 相同。

<PatternLayout pattern="[%d{yyyy-MM-dd HH:mm:ss}][%p][%X{TRACE_ID}][%t][%c.%M] %m%n"/>

完整示例:log4j2-spring.xml

<?xml version="1.0" encoding="UTF-8"?>
<!--
    status : 这个用于设置 log4j2 自身内部的信息输出, 可以不设置, 当设置成 trace 时, 会看到 log4j2 内部各种详细输出
    monitorInterval : Log4j 能够自动检测修改配置文件和重新配置本身, 设置间隔秒数。
-->
<Configuration status="INFO" monitorInterval="30">
    <Properties>
        <Property name="log-directory" value="./logs"/>
        <Property name="history-log-directory" value="./logs/history"/>
        <Property name="split-size">100MB</Property>
        <Property name="file-age">7d</Property>
        <Property name="trace-id">TRACE_ID</Property>
    </Properties>
    <Appenders>
        <!-- 这个输出控制台的配置 -->
        <Console name="Console" target="SYSTEM_OUT">
            <ThresholdFilter level="info" onMatch="ACCEPT" onMismatch="DENY"/>
            <!-- 输出日志的格式 -->
            <PatternLayout pattern="[%d{yyyy-MM-dd HH:mm:ss}][%p][%X{TRACE_ID}][%t][%c] %m%n"/>
        </Console>

        <!-- 这个会打印出所有的 info 及以下级别的信息,每次大小超过 size,则这 size 大小的日志会自动存入按年份 - 月份建立的文件夹下面并进行压缩,作为存档 -->
        <RollingFile name="RollingFileInfo" fileName="${log-directory}/trace.log"
                     filePattern="${history-log-directory}/trace-%d{yyyy-MM-dd}-%i.log.gz">
            <!-- 控制台只输出 level 及以上级别的信息(onMatch),其他的直接拒绝(onMismatch)-->
            <ThresholdFilter level="info" onMatch="ACCEPT" onMismatch="DENY"/>
            <PatternLayout pattern="[%d{yyyy-MM-dd HH:mm:ss}][%p][%X{TRACE_ID}][%t][%c.%M] %m%n"/>
            <Policies>
                <TimeBasedTriggeringPolicy/>
                <!-- 不是日志文件的最大保存数目,而是和 filePattern 中的 %i 有关 -->
                <SizeBasedTriggeringPolicy size="${split-size}"/>
            </Policies>
        </RollingFile>

        <RollingFile name="RollingFileError" fileName="${log-directory}/error.log"
                     filePattern="${history-log-directory}/error-%d{yyyy-MM-dd}-%i.log.gz">
            <ThresholdFilter level="error" onMatch="ACCEPT" onMismatch="DENY"/>
            <PatternLayout pattern="[%d{yyyy-MM-dd HH:mm:ss}][%p][%X{TRACE_ID}][%t][%c.%M] %m%n"/>
            <!-- 日志文件切分策略 -->
            <Policies>
                <TimeBasedTriggeringPolicy/>
                <SizeBasedTriggeringPolicy size="${split-size}"/>
            </Policies>
            <!-- 默认日志滚动策略 -->
            <DefaultRolloverStrategy>
                <Delete basePath="${log-directory}" maxDepth="2">
                    <IfFileName glob="*/trace-*.log.gz"/>
                    <IfLastModified age="${file-age}"/>
                </Delete>
            </DefaultRolloverStrategy>
        </RollingFile>
    </Appenders>
    <!-- 然后定义 logger,只有定义了 logger 并引入的 appender,appender 才会生效 -->
    <Loggers>
        <!-- 过滤掉 spring 和 mybatis 的一些无用的 DEBUG 信息 -->
        <logger name="org.springframework" level="INFO"/>
        <logger name="org.mybatis" level="INFO"/>
        <root level="all">
            <appender-ref ref="Console"/>
            <appender-ref ref="RollingFileInfo"/>
            <appender-ref ref="RollingFileError"/>
        </root>
    </Loggers>
</Configuration>

多线程间使用

1. MDC 线程工具类

自定义 MDC 工具类,支持 RunnableCallable 两种,目的就是为了把父线程的 traceId 设置给子线程

public class MDCUtil {
    public static final String TRACE_ID = "TRACE_ID";

     public static <T> Callable<T> wrap(final Callable<T> callable, final Map<String,String> context){
         return ()-> {MDCUtil.setCurThreadMDCContextIfAbsent(context);
             try {
                 return callable.call();} finally {MDC.clear();
             }
         };
     }

     public static Runnable wrap(final Runnable runnable, final Map<String, String> context){
         return ()-> {MDCUtil.setCurThreadMDCContextIfAbsent(context);
             try {runnable.run();
             } finally {MDC.clear();
             }
         };
     }

    public static void setTraceIdIfAbsent() {
        if (MDC.get(TRACE_ID) == null){MDC.put(TRACE_ID, MDCUtil.getTraceId());
        }
    }

    public static String getTraceId() {
        return UUID.randomUUID().toString().replace("-", "");}

    public static void setCurThreadMDCContextIfAbsent(final Map<String, String> context){
        if (CollectionUtils.isEmpty(context)) MDC.clear();
        else MDC.setContextMap(context);
        MDCUtil.setTraceIdIfAbsent();}

}

2. 线程池装饰器

通过 org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor 提供的线程装饰器 (TaskDecorator),在执行线程的时候,将当前线程的 MDC 信息信息传递给子线程,实现父子线程之间的 MDC 传递

@Configuration
public class CommonThreadTaskExecutorConfig {

    public static final String COMMON_THREAD_TASK_EXECUTOR_NAME = "common_thread_task_executor";
    private static final int CORE_POOL_SIZE = 4;
    private static final int MAX_POOL_SIZE = 8;
    private static final int KEEP_ALIVE_SECONDS = 30;
    private static final int QUEUE_CAPACITY = 1024;
    private final AtomicLong atomicLong = new AtomicLong(0);

    @Bean(name = COMMON_THREAD_TASK_EXECUTOR_NAME)
    public ThreadPoolTaskExecutor commonThreadTaskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(CORE_POOL_SIZE);
        executor.setMaxPoolSize(MAX_POOL_SIZE);
        executor.setKeepAliveSeconds(KEEP_ALIVE_SECONDS);
        executor.setQueueCapacity(QUEUE_CAPACITY);
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        // 装饰器
        executor.setTaskDecorator(runnable -> MDCUtil.wrap(runnable, MDC.getCopyOfContextMap()));
        executor.setThreadNamePrefix(COMMON_THREAD_TASK_EXECUTOR_NAME);
        executor.initialize();
        return executor;
    }
}

3. 测试样例

@RestController
@Slf4j
public class TestController {
    @Autowired
    @Qualifier(CommonThreadTaskExecutorConfig.COMMON_THREAD_TASK_EXECUTOR_NAME)
    private ThreadPoolTaskExecutor executor;

    @RequestMapping(value = "about")
    @ResponseBody
    public Map<String,String> about(@RequestHeader(value = MDCUtil.TRACE_ID, required = false)String traceId) throws InterruptedException {
        Map<String, String> res = new HashMap<>();
        log.info("RequestHeader TRACE_ID {}", traceId);
        res.put(MDCUtil.TRACE_ID,traceId);
        log.info("MDC: {}", MDC.getCopyOfContextMap());
        log.info("打印日志,开始测试");
        for (int i = 0; i < 100; i++){executor.submit(() -> log.info("子线程打印日志,时间: {}", System.currentTimeMillis()));}
        Thread.sleep(3000);
        log.info("打印日志,结束测试");
        res.put("version","1.1");
        return res;
    }
}

后记:http 调用过程中可能会存在 traceId 丢失的情况,解决方法是将 traceId 放到请求头中,至于如何优雅的实现,待完善

License:  CC BY 4.0