找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1887

积分

0

好友

248

主题
发表于 5 天前 | 查看: 15| 回复: 0

在微服务架构或高并发的 Spring Boot 项目中,排查问题往往面临一个痛点:当请求量巨大或调用链路复杂时,海量的日志交织在一起,难以区分哪些日志属于同一个请求链路。

虽然 Spring Cloud 生态中有 Zipkin、SkyWalking 等成熟的分布式链路追踪方案,但对于单体应用或轻量级微服务,引入这些组件显得过于厚重。为了解决日志追踪问题,我们可以利用 TraceId(追踪ID)将一次请求全链路的日志串联起来。

本文将深入探讨如何利用 Spring 自带的 MDC(Mapped Diagnostic Context)工具,在不引入额外繁重依赖的情况下,实现以下场景的全链路 TraceId 透传:

  • HTTP 请求
  • 消息队列(RabbitMQ
  • 线程池异步任务
  • 定时任务(XXL-JOB)

MDC 实现原理

MDC 是 SLF4J 和 Logback 提供的一种线程级日志上下文存储机制。其核心原理是利用 ThreadLocal<Map<String, String>> 来保存当前线程的上下文信息。

  • 写入:当我们在代码中执行 MDC.put("traceId", "xxx") 时,TraceId 会被存入当前线程的 ThreadLocal Map 中。
  • 读取:日志框架(如 Logback)在输出日志时,会自动从 MDC 中查找对应的 Key(如 traceId),并将其填充到日志模板的指定位置。
  • 隔离:由于基于 ThreadLocal,不同线程的 MDC 数据是完全隔离的,互不干扰。

Logback 配置文件适配

要让 TraceId 显示在日志中,我们需要修改 Logback 的配置文件,在日志输出格式(Pattern)中添加占位符。

1. 控制台输出(开发环境)

logback-spring.xml 文件中,找到控制台日志的 property 配置。我们需要在 pattern 中添加 [traceId:%X{traceId}]。其中 %X{traceId} 即代表从 MDC 中获取名为 traceId 的值。

<property name="CONSOLE_LOG_PATTERN"
          value="${DEFAULT_CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} [traceId:%X{traceId}] %clr(${LOG_LEVEL_PATTERN:-%5p})
          %clr(${PID:- }){magenta} %clr(---){faint} %clr([%t]){faint} %clr(%-40.40logger{39}){cyan}
          %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>

修改后,控制台输出将包含 TraceId,如下图所示:

控制台日志示例

2. 文件输出(测试/生产环境)

对于输出到文件的日志(通常包含 infoerror 级别),同样需要在 pattern 标签中添加 TraceId 的配置。例如在 JSON 格式日志中添加 "traceId": "%X{traceId}",或者在普通文本格式中添加 %X{traceId}

文件日志配置示例1

文件日志配置示例2

HTTP 请求链路透传

对于 HTTP 请求,最简单的做法是使用过滤器(Filter)。在请求进入时生成或获取 TraceId 并放入 MDC,在请求结束时清理 MDC,防止内存泄漏或污染线程池。

import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;

/**
 * HTTP请求 TraceId 过滤器
 *
 * @author: Czw
 * @create: 2025-11-06 10:31
 **/
@Component
public class TraceIdFilter extends OncePerRequestFilter {
    private static final String TRACE_ID = "traceId";

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 跳过预检请求(OPTIONS)
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            filterChain.doFilter(request, response);
            return;
        }

        try {
            // 实际场景中,优先从 Header 中获取上游传来的 TraceId,若无则生成
            String traceId = UUID.randomUUID().toString().replace("-", "");
            MDC.put(TRACE_ID, traceId);
            filterChain.doFilter(request, response);
        } finally {
            // 必须清除,防止线程复用导致 TraceId 混乱
            MDC.remove(TRACE_ID);
        }
    }
}

线程池异步任务透传

由于 MDC 是基于 ThreadLocal 的,子线程无法自动继承父线程的 MDC 上下文。当业务逻辑中使用线程池异步执行任务时,TraceId 会丢失。

解决方案是对 RunnableCallable 进行包装,在任务提交时捕获当前主线程的 MDC 上下文,并在子线程执行任务前将上下文注入,任务结束后清理。

/**
 * 异步任务执行器封装
 */
public void execute(Runnable task) {
    defaultThreadPoolExecutor.execute(wrap(task, MDC.getCopyOfContextMap()));
}

/**
 * 提交带返回值的异步任务
 */
public <T> Future<T> submit(Callable<T> task) {
    return defaultThreadPoolExecutor.submit(wrap(task, MDC.getCopyOfContextMap()));
}

/**
 * 封装 Runnable,复制 MDC 上下文
 */
private Runnable wrap(Runnable task, Map<String, String> contextMap) {
    return () -> {
        Map<String, String> previous = MDC.getCopyOfContextMap();
        if (contextMap != null) {
            MDC.setContextMap(contextMap);
        } else {
            MDC.clear();
        }
        try {
            task.run();
        } finally {
            // 恢复子线程原来的 MDC(如果有),避免影响线程池复用
            if (previous != null) {
                MDC.setContextMap(previous);
            } else {
                MDC.clear();
            }
        }
    };
}

/**
 * 封装 Callable,复制 MDC 上下文
 */
private <T> Callable<T> wrap(Callable<T> task, Map<String, String> contextMap) {
    return () -> {
        Map<String, String> previous = MDC.getCopyOfContextMap();
        if (contextMap != null) {
            MDC.setContextMap(contextMap);
        } else {
            MDC.clear();
        }
        try {
            return task.call();
        } finally {
            if (previous != null) {
                MDC.setContextMap(previous);
            } else {
                MDC.clear();
            }
        }
    };
}

消息队列(RabbitMQ)透传

MQ 的链路透传分为两步:生产者发送时注入 Header,消费者消费时提取 Header。

1. 生产者发送处理

在发送消息时,从当前 MDC 获取 TraceId,并将其放入消息的 Header 中。

/**
 * 发送 MQ 消息,自动携带 TraceId
 */
public <T> void sendMq(MqEnum.TypeEnum typeEnum, MqMessage<T> message) {
    rabbitTemplate.convertAndSend(MqEnum.Exchange.EXCHANGE_NAME, typeEnum.getRoutingKey(), message,
            msg -> {
                String traceId = MDC.get(TRACE_ID);
                if (traceId == null) {
                    traceId = UUID.randomUUID().toString().replace("-", "");
                    MDC.put(TRACE_ID, traceId);
                }
                // 将 TraceId 放入消息头
                msg.getMessageProperties().getHeaders().put(TRACE_ID, traceId);
                return msg;
            });
}

2. 消费者统一处理(AOP/Advice)

利用 Spring AMQP 提供的 Advice 机制,可以在消息监听器执行前拦截消息,提取 Header 中的 TraceId 并写入 MDC。这样无需修改具体的 @RabbitListener 业务代码。

/**
 * RabbitMQ 消费者切面,用于提取 TraceId
 */
@Bean
public Advice traceIdAdvice() {
    return (MethodInterceptor) invocation -> {
        Object[] args = invocation.getArguments();
        String traceId = null;

        for (Object arg : args) {
            if (arg instanceof Message message) {
                traceId = (String) message.getMessageProperties().getHeaders().get(TRACE_ID);
                break;
            }
        }

        if (traceId != null) {
            MDC.put(TRACE_ID, traceId);
        }

        try {
            return invocation.proceed();
        } finally {
            MDC.remove(TRACE_ID);
        }
    };
}

/**
 * 配置 ListenerContainerFactory 以应用 Advice
 */
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
        ConnectionFactory connectionFactory,
        Advice traceIdAdvice) {
    SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
    factory.setConnectionFactory(connectionFactory);
    factory.setAdviceChain(traceIdAdvice);
    return factory;
}

定时任务(XXL-JOB)透传

对于 XXL-JOB 等定时任务,每次执行都是一个新的起点。我们可以利用 AOP 切面拦截 @XxlJob 注解的方法,在任务开始前生成 TraceId。

/**
 * XXL-JOB 切面,自动生成 TraceId
 */
@Aspect
@Component
public class XxlJobTraceAspect {

    private static final String TRACE_ID = "traceId";

    @Pointcut("@annotation(com.xxl.job.core.handler.annotation.XxlJob)")
    public void xxlJobMethods() {}

    @Around("xxlJobMethods()")
    public Object aroundXxlJob(ProceedingJoinPoint joinPoint) throws Throwable {
        String traceId = UUID.randomUUID().toString().replace("-", "");
        MDC.put(TRACE_ID, traceId);
        try {
            return joinPoint.proceed();
        } finally {
            MDC.remove(TRACE_ID);
        }
    }
}

最终效果演示

测试接口代码

@GetMapping(value = "/test/traceId/async")
public Result<NullResult> traceId() {
    log.info("主 traceId");
    // 异步执行
    asyncExecutors.execute(() -> log.info("execute traceId"));

    // 带返回值的异步执行
    asyncExecutors.submit(() -> {
        log.info("submit traceId");
        return "ok";
    });

    // 批量任务
    List<Runnable> list = new ArrayList<>();
    list.add(() -> log.info("execute list traceId"));
    asyncExecutors.execute(list);
    return Result.buildSuccess();
}

@GetMapping(value = "/test/traceId/mq")
public Result<NullResult> mq() {
    log.info("主mq traceId");
    MqMessage<String> message = new MqMessage<>();
    message.setData(JSON.toJSONString(Collections.emptyList()));
    mqSender.sendMq(MqEnum.TypeEnum.PROP_SEND, message);
    return Result.buildSuccess();
}

1. HTTP 请求与线程池效果

可以看到,主线程与线程池中的子线程共享了同一个 TraceId。

HTTP与线程池日志效果

2. MQ 跨服务透传效果

生产者发送消息时的 TraceId 被消费者成功继承。

MQ日志效果

3. 复杂线程池场景

四种不同类型的线程池任务均保持了 TraceId 的一致性。

复杂线程池日志效果

4. 定时任务效果

XXL-JOB 任务启动时自动生成了独立的 TraceId。

定时任务日志效果




上一篇:万卡GPU集群调度实战:360 HBox算力平台架构深度解析
下一篇:网易云音乐Doris实战:统一OLAP底座,查询延迟降至800ms
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-10 09:25 , Processed in 0.246439 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表