微服务MDC全局链路追踪

1,947 阅读4分钟

描述

在微服务的体系中,服务应用较多,调用链复杂多变,相应的日志排查难度也随之提升。在整个请求的生命周期,链路追踪能够生成一个全局的唯一标识ID。每一条log日志记录携带整个唯一ID输出到控制台或者日志文件中,就可以快速定位到问题所在的整个请求链。

MDC介绍

MDC(Mapped Diagnostic Context,映射调试上下文),即将一些运行时的上下文数据通过logback打印输出,是一种方便在多线程条件下记录日志的功能。和SiftingAppender一起,可以实现根据运行时的上下文数据,将日志保存到不同的文件中。其底层通过ThreadLocal线程隔离实现的,保证了线程安全问题。但也ThreadLocal类实现了弱引用,可能会导致内存溢出等问题,所以在使用完之后需要进行手动移除。

工具类

/**
 * 链路追踪工具类
 *
 * @author: 苦瓜不苦
 * @date: 2022/6/28 21:09
 **/
public class MdcUtil {

    /**
     * 链路追踪唯一key值
     */
    public final static String UNIQUE_ID = "uniqueId";

    /**
     * 添加
     *
     * @param value 唯一ID
     */
    public static void pull(String value) {
        MDC.put(UNIQUE_ID, Objects.isNull(value) ? IdUtil.objectId() : value);
    }

    /**
     * 添加
     */
    public static void pull() {
        MDC.put(UNIQUE_ID, IdUtil.objectId());
    }


    /**
     * 获取
     *
     * @return
     */
    public static String get() {
        return MDC.get(UNIQUE_ID);
    }

    /**
     * 获取
     *
     * @param key 键值
     * @return
     */
    public static String get(String key) {
        String value = MDC.get(UNIQUE_ID);
        if (Objects.isNull(value)) {
            value = IdUtil.objectId();
        }
        return value;
    }


    /**
     * 获取所有
     *
     * @return
     */
    public static Map<String, String> getMap() {
        return MDC.getCopyOfContextMap();
    }

    /**
     * 添加所有
     *
     * @param contextMap mdc对象
     */
    public static void setMap(Map<String, String> contextMap) {
        if (!contextMap.isEmpty()) {
            MDC.setContextMap(contextMap);
        } else {
            pull();
        }
    }

    /**
     * 清除
     *
     * @param key 键值
     */
    public static void remove(String key) {
        MDC.remove(key);
    }

    /**
     * 清除所有
     */
    public static void clear() {
        MDC.clear();
    }

}

日志文件配置

重点是%X{uniqueId},和MDC中的键名称保持一致。

%d{yyyy-MM-dd HH:mm:ss.SSS} %clr(%-5level){green} %X{uniqueId} %-5.5L %clr(%-40.40logger{36}){cyan} : %clr(%M){magenta} - %msg%xEx%n

拦截器

/**
 * web配置
 *
 * @author: 苦瓜不苦
 * @date: 2022/1/4 20:34
 */
@Slf4j
@Configuration
public class WebMcvConfig implements WebMvcConfigurer {


    /**
     * 重写拦截器
     *
     * @param registry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(interceptors())
                .addPathPatterns("/**")
                .excludePathPatterns("/doc.html", "/webjars/**", "/favicon.ico", "/swagger-resources/**");
    }


    /**
     * 自定义拦截器
     *
     * @return
     */
    private HandlerInterceptor interceptors() {
        return new HandlerInterceptor() {
            @Override
            public boolean preHandle(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler) throws Exception {
                // 获取请求头中的链路追踪唯一ID,避免HTTP远程调用
                MdcUtil.pull(request.getHeader(MdcUtil.UNIQUE_ID));
                // 放行
                return true;
            }

            @Override
            public void afterCompletion(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull Object handler, Exception ex) throws Exception {
                // 清空MDC日志数据,防止内存泄露
                MdcUtil.clear();
            }
            
        };
    }



}

存在问题

至此基本功能已经实现,但是存在以下问题

  • 多线程情况下,开启子线程打印日志会丢失链路追踪唯一ID
  • HTTP跨服务远程调用也会丢失全局唯一ID

线程丢失情况

原因:MDC底层使用ThreadLocal线程隔离,如果切换线程则会造成数据丢失的问题

解决:重写MDCAdapter接口,使其通过阿里所提供的TransmittableThreadLocal类

TransmittableThreadLocal 是 Alibaba 开源的、用于解决 “在使用线程池等会缓存线程的组件情况下传递 ThreadLocal” 问题的 InheritableThreadLocal 扩展。

源码: gitee.com/smallSevenC… 项目名:logback-transmittable-threadLocal

百度云盘: 链接: pan.baidu.com/s/1Y5TJmHzv… 提取码: 7pcq

添加maven依赖

<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>2.12.6</version>
</dependency>
<!--
    gitee地址: https://gitee.com/smallSevenCode/starter.git
    项目名:logback-transmittable-threadLocal
    打包到本地maven仓库中引入,里面对MDCAdapter进行重写
-->
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>logback-transmittable-threadLocal</artifactId>
    <version>1.0-SNAPSHOT</version>
</dependency>

远程调用情况

原因:feign进行HTTP远程调用时,未将唯一ID携带过去。下游服务无法从header请求头中获取到唯一ID值

解决:重写RequestInterceptor接口,将上游的MDC链路追踪的唯一ID存放到HttpServletRequest的header请求头中。这样,下游服务就会从头信息中获取到唯一ID值。下游服务在拦截器中,获取值并且将ID值重新设置到MDC中

/**
 * feign远程调用,请求头下游转发
 *
 * @author: 苦瓜不苦
 * @date: 2021/8/14 0:20
 **/
@Component
public class FeignInterceptor implements RequestInterceptor {

    @Override
    public void apply(RequestTemplate requestTemplate) {

        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

        if (attributes == null) {
            return;
        }

        HttpServletRequest request = attributes.getRequest();
        
        // 获取链路追踪ID值,并设置到下游请求头中
        requestTemplate.header(MdcUtil.UNIQUE_ID,MdcUtil.get());
        
        // cookie
        Cookie[] cookies = request.getCookies();
        if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                requestTemplate.header(cookie.getName(), cookie.getValue());
            }
        }
        // header
        Enumeration<String> headerNames = request.getHeaderNames();
        if (headerNames != null) {
            while (headerNames.hasMoreElements()) {
                String name = headerNames.nextElement();
                String value = request.getHeader(name);
                // 便利请求头属性,转发到下游服务
                requestTemplate.header(name, value);
            }
        }

    }

}

结束语

链路追踪最关键的点在于,保证整个请求的生命周期全局的唯一ID性。从主线程开启子线程以及调用其他的微服务,整个唯一ID值都不能够存在丢失的问题。上诉两种重写方法,解决了将数据传递下去,保证数据丢失,保证链路追踪的请求完整性。最后一点就是在使用完MDC数据后,记得清空数据,避免内存泄漏问题。