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

1233

积分

0

好友

165

主题
发表于 3 天前 | 查看: 9| 回复: 0

在 Spring Boot 项目中排查线上问题时,查看日志是必不可少的步骤。然而,当系统并发量增大或接口调用链路过长时,不同请求的日志混杂在一起,很难快速定位属于某一次特定请求的所有日志。

虽然微服务框架有 Zipkin、OpenTelemetry 等成熟的分布式追踪解决方案,但对于单体或简单的微服务应用,我们可以采用更轻量级的方式:为每个请求分配一个唯一的 traceId。通过这个 traceId,可以轻松地串联起一次请求在系统中的完整执行路径,无论是 HTTP 请求、异步任务还是消息消费,极大地提升了问题排查的效率。

本文将介绍如何利用 Spring Boot 自带的 MDC(Mapped Diagnostic Context)功能来实现 traceId 的透传。MDC 基于 SLF4J/Logback,无需引入额外依赖。文章将涵盖以下典型场景的 traceId 处理:

  • HTTP 请求
  • MQ(以 RabbitMQ 为例)
  • 线程池异步任务
  • 定时任务(以 XXL-Job 为例)

MDC实现原理

MDC 是 SLF4J/Logback 提供的一个线程级别的日志上下文存储工具。其内部核心是一个 ThreadLocal<Map<String, String>>,用于保存上下文键值对。

  • 当我们在某个线程中执行 MDC.put("traceId", "xxx") 时,这个 traceId 就会被存入当前线程的 ThreadLocal 变量中。
  • 日志框架(如 Logback)在输出日志时,会自动从当前线程的 MDC 中读取 traceId 等值,并填充到预设的日志模板中。
  • 由于 ThreadLocal 的特性,不同线程的 MDC 上下文是相互隔离的,不会产生干扰。

Logback 配置文件的处理

要让 traceId 出现在日志中,首先需要在 Logback 配置文件中进行相应配置。

控制台输出(开发环境)

修改 logback-spring.xml 文件,在控制台日志输出模式 CONSOLE_LOG_PATTERN 中添加 traceId 的占位符 [traceId:%X{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:7e01902b18ef4c2b9f49609c57d769fa]

Logback控制台日志Pattern配置截图

文件输出(测试、生产环境)

对于输出到日志文件的 Appender(如 RollingFileAppender),也需要在 JSON 或 Pattern 格式中添加 traceId 字段,以便日志收集系统(如 ELK)能够索引和检索。这里以 JSON 格式为例,在 pattern 部分添加 "traceId": "%X{traceId}"

全量日志 Appender 配置示例:
Logback文件Appender(rootLog)配置截图

错误日志 Appender 配置示例:
Logback文件Appender(errorLog)配置截图

HTTP 请求处理

为 HTTP 请求添加 traceId 最直接的方式是使用过滤器(Filter)。我们创建一个 OncePerRequestFilter,在每个请求开始时生成并设置 traceId,在请求结束后清理。

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;

/**
 * 日志traceId功能过滤器
 */
@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 {
            String traceId = UUID.randomUUID().toString().replace("-", "");
            MDC.put(TRACE_ID, traceId);
            filterChain.doFilter(request, response);
        } finally {
            MDC.remove(TRACE_ID);
        }
    }
}

线程池异步任务处理

当业务使用线程池执行异步任务时,由于任务会在不同的线程中执行,直接使用 MDC.put 设置的 traceId 无法传递到子线程。我们需要对提交给线程池的 RunnableCallable 任务进行包装,在任务执行前传递父线程的 MDC 上下文,并在执行后清理。

/**
 * 异步执行
 *
 * @param task 任务
 */
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 {
            // 恢复线程池线程原来的 MDC,避免影响下一次任务
            if (previous != null) {
                MDC.setContextMap(previous);
            } else {
                MDC.clear();
            }
        }
    };
}

在处理多线程和异步编程时,这种上下文传递机制对于保持日志链路的完整性至关重要。

MQ(RabbitMQ)消息处理

对于消息队列,我们需要在消息发送端将当前 traceId 放入消息头,在消费端再从消息头中取出并设置到消费线程的 MDC 中。

消息生产者处理

在发送消息时,通过 MessagePostProcessortraceId 添加到消息的 Header 中。

/**
 * 同步发送mq
 */
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);
                }
                msg.getMessageProperties().getHeaders().put(TRACE_ID, traceId);
                return msg;
            });
}

消息消费者处理

利用 Spring AMQP 提供的 Advice 机制,我们可以为所有 @RabbitListener 方法添加一个统一的环绕增强,避免在每个 Consumer 方法中重复编写代码。

/**
 * 透传MDC的Advice
 * sendMq时设置MDC到header中,消费端统一处理
 */
@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);
        }
    };
}

/**
 * 设置自定义的traceIdAdvice到监听器容器工厂
 */
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(
        ConnectionFactory connectionFactory,
        Advice traceIdAdvice) {
    SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
    factory.setConnectionFactory(connectionFactory);
    factory.setAdviceChain(traceIdAdvice);
    return factory;
}

通过上述配置,所有基于 @RabbitListener 的消息消费都会自动完成 traceId 的透传,使得与RabbitMQ等消息中间件交互的日志也能被有效追踪。

定时任务(XXL-Job)处理

对于定时任务,我们可以利用 AOP 切面为所有标记了 @XxlJob 注解的方法自动添加 traceId,无需修改业务代码。

/**
 * XXL-Job定时任务TraceId切面
 */
@Aspect
@Component
public class XxlJobTraceAspect {

    @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);
        }
    }
    private static final String TRACE_ID = "traceId";
}

效果验证

我们可以编写测试接口来验证以上各场景的 traceId 透传效果。

测试接口示例:

@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();
}

请求日志效果:
HTTP请求及业务逻辑日志追踪效果
可以看到,一次 HTTP 请求内部的所有业务日志都带有相同的 traceId

MQ 消费日志效果:
RabbitMQ消息生产与消费日志追踪效果
消息发送端(Controller)和消费端(Consumer)的日志共享同一个 traceId

线程池异步任务日志效果:
线程池异步任务日志追踪效果
主线程提交的异步任务,其内部日志也成功继承了主线程的 traceId

定时任务(XXL-Job)日志效果:
XXL-Job定时任务日志追踪效果
每次触发的 XXL-Job 任务都拥有独立的 traceId,方便在日志系统中检索该次任务执行的全部记录。

总结

通过结合 Spring Boot 的 MDC 功能以及对 HTTP、异步任务、消息队列和定时任务等场景的针对性处理,我们实现了一套轻量级但非常实用的全链路日志追踪方案。这套方案能显著提升在复杂调用链路下的问题定位效率,是每个追求可观测性的后台系统应该考虑的基础设施。希望本文的实践对你有所帮助,也欢迎在技术社区交流更多关于系统设计和后端开发的经验。




上一篇:Deep-Live-Cam:一张照片,把换脸做进视频和摄像头画面里
下一篇:C++内存泄漏检测实战:无工具环境下的手动方案与应急排查
您需要登录后才可以回帖 登录 | 立即注册

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

GMT+8, 2026-1-14 17:17 , Processed in 0.273951 second(s), 37 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

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