Skip to content
🔴🟠🟡🟢🔵🟣🟤⚫⚪

(二十八)基于MDC的日志追踪

基础项目地址:

https://gitee.com/springzb/admin-boot

ThreadLocal 适用于变量在线程间隔离,而在方法或类间共享的场景

这里基于拦截器(Intercptor)思想去做,进入之前前置处理preHandle将 ThreadLocal 本地变量中添加 traceId, 结束后afterCompletion删除对应线程的 traceId

ThreadLocal 适用于变量在线程间隔离,而在方法或类间共享的场景

利用ThreadLocal共享变量,存储 tranceId (即用uuid),在拦截器里面为每个请求设置请求id

创建tranceId工具类

java
package cn.mesmile.admin.common.log;

import java.util.UUID;

/**
 * @author zb
 * @Description
 */
public class RequestIdUtils {

    private static final ThreadLocal<UUID> requestIdHolder = new ThreadLocal<>();

    private RequestIdUtils() {
    }

    public static void generateRequestId() {
        requestIdHolder.set(UUID.randomUUID());
    }

    public static void generateRequestId(UUID uuid) {
        requestIdHolder.set(uuid);
    }

    public static UUID getRequestId() {
        return (UUID)requestIdHolder.get();
    }

    public static void removeRequestId() {
        requestIdHolder.remove();
    }

}

新建拦截器,将请求id加入到MDC中去

程序运行在 Tomcat 中,执行程序的线程是 Tomcat 的工作线程,而 Tomcat 的工作线程是基于线程池的

线程池会重用固定的几个线程,一旦线程重用,那么很可能首次从 ThreadLocal 获取的值是之前其他用户的请求遗留的值。这时,ThreadLocal 中的用户信息就是其他用户的信息

因为线程的创建比较昂贵,所以 Web 服务器往往会使用线程池来处理请求,这就意味着线程会被重用。这时,使用类似 ThreadLocal 工具来存放一些数据时,需要特别注意在代码运行完后,显式地去清空设置的数据。如果在代码中使用了自定义的线程池,也同样会遇到这个问题。

java
package cn.mesmile.admin.common.log;

import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.UUID;

/**
 * @author zb
 * @Description
 */
@Slf4j
public class RequestLogInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String servletPath = request.getServletPath();
        // 生成RequestId
        String requestId = this.getRequestId();
        // 配置日志文件打印 REQUEST_ID
        MDC.put("REQUEST_ID", requestId);

        log.info("servletPath:{}", servletPath);
        log.info("preHandle 前置处理----------");
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("preHandle 处理中----------");
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String servletPath = request.getServletPath();
        
        // 最后显示的 清空 ThreadLocal 设置的值
        log.info("preHandle 后置处理----------");
        log.info("servletPath:{}", servletPath);
        RequestIdUtils.removeRequestId();
        MDC.clear();
    }

    /**
     * 获取RequestId
     * 优先从header头获取,如果没有则自己生成
     * @return RequestId
     */
    private String getRequestId(){
        // 因为如果有网关,则一般会从网关传递过来,所以优先从header头获取
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if(attributes != null && StringUtils.hasText(attributes.getRequest().getHeader("x-request-id"))) {
            HttpServletRequest request = attributes.getRequest();
            String requestId = request.getHeader("x-request-id");
            UUID uuid = UUID.fromString(requestId);
            RequestIdUtils.generateRequestId(uuid);
            return requestId;
        }
        UUID existUUID = RequestIdUtils.getRequestId();
        if(existUUID != null){
            return existUUID.toString();
        }
        RequestIdUtils.generateRequestId();
        return RequestIdUtils.getRequestId().toString();
    }
}

注册拦截器

java
package cn.mesmile.admin.common.repeat;

import cn.mesmile.admin.common.log.RequestLogInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

/**
 * @author zb
 * @Description
 */
@Configuration(
        proxyBeanMethods = false
)
public class WebInterceptorConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 日志请求id
        registry.addInterceptor(new RequestLogInterceptor()).addPathPatterns("/**");
    }
 }

解决线程异步场景下RequestId的打印问题

程序运行在 Tomcat 中,执行程序的线程是 Tomcat 的工作线程,而 Tomcat 的工作线程是基于线程池的

线程池会重用固定的几个线程,一旦线程重用,那么很可能首次从 ThreadLocal 获取的值是之前其他用户的请求遗留的值。这时,ThreadLocal 中的用户信息就是其他用户的信息

因为线程的创建比较昂贵,所以 Web 服务器往往会使用线程池来处理请求,这就意味着线程会被重用。这时,使用类似 ThreadLocal 工具来存放一些数据时,需要特别注意在代码运行完后,显式地去清空设置的数据。如果在代码中使用了自定义的线程池,也同样会遇到这个问题。

java
package cn.mesmile.admin.common.log;

import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;

import java.util.concurrent.Executor;

/**
 * @author zb
 * @Description 解决线程异步场景下RequestId的打印问题
 */
@Slf4j
public class MdcExecutor implements Executor {

    private Executor executor;

    public MdcExecutor(Executor executor) {
        this.executor = executor;
    }

    @Override
    public void execute(Runnable command) {
        final String requestId = MDC.get("REQUEST_ID");
        executor.execute(() -> {
            MDC.put("REQUEST_ID", requestId);
            try {
                command.run();
            } finally {
                MDC.remove("REQUEST_ID");
            }
        });
    }
}

以logback为例在日志配置文件中加入以下内容:

java
日志格式中加入以下格式: [%X{REQUEST_ID}] 
    
    <!--控制台日志格式-->
    <property name="CONSOLE_LOG_PATTERN" value="${CONSOLE_LOG_PATTERN:-%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) [%X{REQUEST_ID}] %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
    <!--文件日志格式-->
    <property name="FILE_LOG_PATTERN" value="${FILE_LOG_PATTERN:-%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}} ${LOG_LEVEL_PATTERN:-%5p} [%X{REQUEST_ID}] ${PID:- } --- [%t] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>

日志示例, 可以看见日志中的 tranceId 为 uuid 值:

Java
2022-10-15 17:00:34.670  INFO [466aed02-0527-4829-8782-252e10a50ead] 7184 --- [  XNIO-1 task-1] c.m.a.common.log.RequestLogInterceptor   : servletPath:/doc.html
2022-10-15 17:00:34.670  INFO [466aed02-0527-4829-8782-252e10a50ead] 7184 --- [  XNIO-1 task-1] c.m.a.common.log.RequestLogInterceptor   : preHandle 前置处理----------
2022-10-15 17:00:34.689  INFO [466aed02-0527-4829-8782-252e10a50ead] 7184 --- [  XNIO-1 task-1] c.m.a.common.log.RequestLogInterceptor   : preHandle 处理中----------
2022-10-15 17:00:34.689  INFO [466aed02-0527-4829-8782-252e10a50ead] 7184 --- [  XNIO-1 task-1] c.m.a.common.log.RequestLogInterceptor   : preHandle 后置处理----------
2022-10-15 17:00:34.689  INFO [466aed02-0527-4829-8782-252e10a50ead] 7184 --- [  XNIO-1 task-1] c.m.a.common.log.RequestLogInterceptor   : servletPath:/doc.html