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

1835

积分

0

好友

226

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

在分布式系统或复杂的单体应用中,当请求量激增或调用链路变长时,一个常见的问题是:如何在海量日志中快速定位属于同一次请求的所有记录?如果每条日志都是孤立的,排查问题就像大海捞针。

虽然 Spring Cloud 生态中有 Zipkin、OpenTelemetry 等成熟的分布式追踪解决方案,但对于纯 Spring Boot 项目,或者希望以更轻量、更简单的方式实现请求跟踪的场景,利用 Spring 自带的 MDC(Mapped Diagnostic Context)来生成和传递 TraceId 是一个高效且实用的选择。

通过为每个请求分配唯一的 TraceId 并将其贯穿于整个处理链路,你只需通过某一条关键日志找到对应的 TraceId,就能一键筛选出该请求的所有相关日志,极大提升问题排查效率。本文将详细介绍如何基于 MDC 在 Spring Boot 项目中实现 TraceId 的全链路透传,并覆盖以下核心场景的处理方案:

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

MDC 实现原理简述

MDC 是 SLF4J/Logback 提供的一种线程级别的诊断上下文存储机制。其内部本质是一个依附于线程的 ThreadLocal<Map<String, String>>

  • 当你在某个线程中调用 MDC.put("traceId", "xxx") 时,这个键值对就会被存储在当前线程独有的上下文中。
  • 日志框架(如 Logback)在输出日志时,可以识别日志模式中的特定占位符(如 %X{traceId}),并自动从当前线程的 MDC 中取出对应的值进行填充。
  • 由于基于 ThreadLocal,不同线程之间的 MDC 上下文是相互隔离的,互不干扰。

第一步:配置 Logback 日志输出

要让 TraceId 能够打印出来,首先需要在日志配置文件中进行相应配置。

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

修改 logback-spring.xml 文件,在定义控制台日志模式的 property 标签中,加入 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] INFO ...

控制台日志模式配置,包含traceId占位符

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

对于输出到日志文件的 Appender(如全量日志、错误日志),如果希望以结构化格式(例如 JSON)收集日志,需要在对应的 JSON 模式(pattern)中添加 TraceId 字段。

以下是一个全量日志 Appender 的配置示例,注意在 pattern 中添加了 "traceId": "%X{traceId}"

<appender name="rootLog" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <file>${logPath}/${springAppName}/root/log_root.log</file>
    <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
        <fileNamePattern>${logPath}/${springAppName}/root/history/log_root.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
        <maxFileSize>100MB</maxFileSize>
        <maxHistory>30</maxHistory>
        <totalSizeCap>10GB</totalSizeCap>
    </rollingPolicy>
    <encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
        <providers>
            <timestamp>
                <timeZone>GMT+8</timeZone>
            </timestamp>
            <pattern>
                <pattern>
{
  "appName": "${springAppName:-}",
  "level": "%Level",
  "pid": "${PID:-}",
  "thread": "%thread",
  "class": "%logger{40}",
  "message": "%msg%ex",
  "traceId": "%X{traceId}"
}
                </pattern>
            </pattern>
        </providers>
    </encoder>
</appender>

配置了traceId字段的JSON格式全量日志Appender

同样地,错误日志的 Appender 也需要进行类似配置,确保在排查错误时也能关联到 TraceId。

配置了traceId字段的JSON格式错误日志Appender

第二步:处理 HTTP 请求

对于 HTTP 请求,最直接的方式是使用一个过滤器(Filter),在请求进入时生成并设置 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 是基于 ThreadLocal 的,所以主线程的 TraceId 不会自动传递到子线程。我们需要手动包装任务,实现上下文传递。

核心思路是:在提交任务时,复制当前线程的 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 {
            // 恢复线程池线程原来的 MDC,避免影响下一次任务
            if (previous != null) {
                MDC.setContextMap(previous);
            } else {
                MDC.clear();
            }
        }
    };
}

第四步:处理消息队列(RabbitMQ)

对于 RabbitMQ 这类消息中间件,透传 TraceId 需要生产者和消费者配合。

1. 生产者发送消息时携带 TraceId

在发送消息时,将当前线程的 TraceId(如果不存在则生成一个)放入消息的 Header 中。

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

2. 消费者统一接收 TraceId

利用 Spring AMQP 的 Advice 机制,可以创建一个全局的拦截器,在所有 @RabbitListener 方法执行前,从消息 Header 中取出 TraceId 并设置到当前消费线程的 MDC 中。

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

@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,无需在每个任务方法中手动编写。

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import java.util.UUID;

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

实践效果验证

编写测试接口,验证上述场景下 TraceId 的透传是否一致。

1. 测试异步任务

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

调用该接口,观察日志,可以看到主线程与各个异步任务线程打印的 TraceId 完全相同。

HTTP请求及异步任务日志,显示相同的traceId

2. 测试消息队列

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

调用接口后,查看消息发送端和消费者端的日志,它们的 TraceId 也应保持一致。

MQ生产者与消费者日志,显示相同的traceId

下图进一步展示了线程池中多种任务提交方式下,TraceId 的一致性。

线程池多种任务执行日志,显示相同的traceId

3. 测试定时任务

对于 XXL-Job 任务,执行后可以在日志平台中,通过切面自动生成的 TraceId 来关联查看该次任务执行的所有日志。

日志分析平台中,通过traceId筛选XXL-Job任务日志

总结

通过上述步骤,我们无需引入复杂的分布式追踪组件,仅利用 Spring Boot 原生支持的 MDC 和 SLF4J/Logback,就实现了 TraceId 在 HTTP、异步任务、消息队列和定时任务等多种常见场景下的全链路透传。这套方案轻量、易实现,能显著提升在复杂调用链路下的日志排查效率,是中小型项目或追求简洁架构场景下的一个绝佳选择。

希望这篇实践指南能对你有所帮助。更多关于系统架构、Java 高级特性及后端开发的深度讨论,欢迎访问 云栈社区 与广大开发者共同交流。




上一篇:TimeLens重塑视频时间定位:8B开源模型超越GPT-5与Gemini 2.5 Flash
下一篇:OpenWrt路由器2025年盘点:热门芯片选购指南与刷机推荐
您需要登录后才可以回帖 登录 | 立即注册

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

GMT+8, 2026-1-10 18:23 , Processed in 0.272264 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

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