Spring Boot 统一日志上下文

在项目开发中,一个请求往往要经过多个层次的处理:

java 复制代码
@GetMapping("/order/{id}")
public Order getOrder(@PathVariable Long id) {
    log.info("查询订单, orderId={}", id);  // Controller
    return orderService.getById(id);
}

@Service
public class OrderService {
    public Order getById(Long id) {
        log.info("开始查询, id={}", id);  // Service
        return orderMapper.selectById(id);
    }
}

当线上出现问题时,需要从日志中找到这个请求的完整执行链路。但如果日志中没有统一的标识,只能通过时间、orderId 等信息去匹配,效率很低。

更复杂的情况是,请求处理过程中会启动异步任务、使用线程池、调用下游服务,这些日志如何关联起来?

基本思路

在日志中引入统一的上下文信息,最关键的是 traceId(追踪 ID)。一个请求的所有日志都带上相同的 traceId,就能通过 grep 或日志系统快速定位问题。

SLF4J 提供了 MDC(Mapped Diagnostic Context)机制来解决这个问题:

java 复制代码
MDC.put("traceId", "123456");
log.info("这条日志会自动带上 traceId");
MDC.clear();

配置 logback 输出格式:

xml 复制代码
<pattern>%d{HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>

输出效果:

ini 复制代码
12:34:56.789 [http-nio-8080-exec-1] [123456] INFO  c.e.OrderController - 查询订单, orderId=1
12:34:56.890 [http-nio-8080-exec-1] [123456] INFO  c.e.OrderService - 开始查询, id=1

但直接在每个方法里写 MDC.put/clear 不现实,需要统一处理。

设计上下文数据结构

先定义上下文要包含哪些信息:

java 复制代码
@Getter
@Setter
public class LogContext {

    private String traceId;
    private String userId;
    private String clientId;
    private String uri;

    public static LogContext create() {
        LogContext context = new LogContext();
        context.setTraceId(UUID.randomUUID().toString().replace("-", ""));
        return context;
    }

    public static LogContext from(String traceId) {
        LogContext context = new LogContext();
        context.setTraceId(traceId);
        return context;
    }
}

用 ThreadLocal 存储上下文:

java 复制代码
public class LogContextHolder {

    private static final ThreadLocal<LogContext> CONTEXT = new ThreadLocal<>();

    public static void setContext(LogContext context) {
        CONTEXT.set(context);
        updateMDC(context);
    }

    public static LogContext getContext() {
        return CONTEXT.get();
    }

    public static String getTraceId() {
        LogContext context = CONTEXT.get();
        return context != null ? context.getTraceId() : null;
    }

    public static void clear() {
        CONTEXT.remove();
        MDC.clear();
    }

    private static void updateMDC(LogContext context) {
        if (context != null) {
            MDC.put("traceId", context.getTraceId());
            MDC.put("userId", context.getUserId());
            MDC.put("clientId", context.getClientId());
        }
    }
}

Web 请求统一处理

通过 Filter 在请求入口生成 traceId,请求结束时清理:

java 复制代码
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class LogContextFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {

        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;

        try {
            // 从请求头获取 traceId,没有则生成新的
            String traceId = req.getHeader("traceId");
            LogContext context = StringUtils.hasText(traceId)
                ? LogContext.from(traceId)
                : LogContext.create();

            // 设置其他上下文信息
            context.setUri(req.getRequestURI());
            String userId = getUserIdFromRequest(req);
            if (userId != null) {
                context.setUserId(userId);
            }

            LogContextHolder.setContext(context);

            // 将 traceId 写入响应头,方便前端追踪
            res.setHeader("traceId", context.getTraceId());

            chain.doFilter(request, response);

        } finally {
            LogContextHolder.clear();
        }
    }

    private String getUserIdFromRequest(HttpServletRequest request) {
        // 从 token 或 session 中获取 userId
        // ...
        return null;
    }
}

这样,Controller、Service、Repository 层的所有日志都会自动带上 traceId,无需手动处理。

跨线程场景

普通的 ThreadLocal 在子线程中无法获取父线程的值,需要特殊处理。

更多ThreadLocal父子线程传值方案可参考:mp.weixin.qq.com/s/Aw9UPkY6M...

线程池装饰器

使用阿里巴巴的 TransmittableThreadLocal(TTL):

xml 复制代码
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>2.14.3</version>
</dependency>

改造 LogContextHolder:

java 复制代码
public class LogContextHolder {

    // 改用 TransmittableThreadLocal
    private static final TransmittableThreadLocal<LogContext> CONTEXT =
        new TransmittableThreadLocal<>();

    // 其他方法不变
}

创建 TTL 装饰的线程池:

java 复制代码
@Configuration
public class ExecutorConfig {

    @Bean("ttlExecutor")
    public Executor ttlExecutor() {
        TtlExecutors.getTtlExecutor(new ThreadPoolExecutor(
            10, 20, 60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(100),
            new ThreadFactoryBuilder().setNameFormat("ttl-pool-%d").build()
        ));
    }
}

使用装饰后的线程池:

java 复制代码
@Service
public class OrderService {

    @Autowired
    @Qualifier("ttlExecutor")
    private Executor ttlExecutor;

    public void processAsync(Long orderId) {
        ttlExecutor.execute(() -> {
            // 这里的日志会自动带上父线程的 traceId
            log.info("异步处理订单, orderId={}", orderId);
        });
    }
}

CompletableFuture

CompletableFuture 需要用 TTL 包装:

java 复制代码
public CompletableFuture<Order> getOrderAsync(Long id) {
    return TtlCompletable.get(() ->
        CompletableFuture.supplyAsync(() -> {
            log.info("异步查询订单, id={}", id);  // 有 traceId
            return orderMapper.selectById(id);
        }, ttlExecutor)
    );
}

@Async 异步任务

Spring 的 @Async 需要配置线程池装饰器:

java 复制代码
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        return TtlExecutors.getTtlExecutor(new ThreadPoolExecutor(
            5, 10, 60L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(100)
        ));
    }
}

使用:

java 复制代码
@Service
public class OrderService {

    @Async
    public void sendNotification(Long orderId) {
        // 这里的日志会自动带上主线程的 traceId
        log.info("发送订单通知, orderId={}", orderId);
    }
}

HTTP 调用传递 traceId

调用下游服务时,需要把 traceId 传递过去,形成完整的调用链。

RestTemplate

java 复制代码
@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate() {
        RestTemplate template = new RestTemplate();
        template.setInterceptors(Collections.singletonList(new TraceIdInterceptor()));
        return template;
    }
}

public class TraceIdInterceptor implements ClientHttpRequestInterceptor {

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) {
        String traceId = LogContextHolder.getTraceId();
        if (traceId != null) {
            request.getHeaders().set("traceId", traceId);
        }
        return execution.execute(request, body);
    }
}

WebClient

java 复制代码
@Configuration
public class WebClientConfig {

    @Bean
    public WebClient webClient() {
        return WebClient.builder()
            .filter((request, next) -> {
                String traceId = LogContextHolder.getTraceId();
                if (traceId != null) {
                    request.headers().set("traceId", traceId);
                }
                return next.exchange(request);
            })
            .build();
    }
}

Feign

java 复制代码
@Configuration
public class FeignConfig {

    @Bean
    public RequestInterceptor traceIdInterceptor() {
        return template -> {
            String traceId = LogContextHolder.getTraceId();
            if (traceId != null) {
                template.header("traceId", traceId);
            }
        };
    }
}

完整示例

Controller:

java 复制代码
@RestController
@RequestMapping("/order")
public class OrderController {

    @Autowired
    private OrderService orderService;

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        log.info("接收查询请求, orderId={}", id);  // 有 traceId
        return orderService.getById(id);
    }

    @PostMapping
    public Order create(@RequestBody OrderCreateDTO dto) {
        log.info("接收创建请求, dto={}", dto);  // 有 traceId
        return orderService.create(dto);
    }
}

Service:

java 复制代码
@Service
public class OrderService {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    @Qualifier("ttlExecutor")
    private Executor executor;

    public Order getById(Long id) {
        log.info("开始查询, id={}", id);  // 有 traceId
        Order order = orderMapper.selectById(id);

        // 异步记录日志
        executor.execute(() -> {
            log.info("记录查询日志, orderId={}", order.getId());  // 有 traceId
        });

        return order;
    }
}

日志输出:

ini 复制代码
2025-01-08 12:34:56.789 [http-nio-8080-exec-1] [a1b2c3d4e5f6] INFO  c.e.OrderController - 接收查询请求, orderId=1
2025-01-08 12:34:56.890 [http-nio-8080-exec-1] [a1b2c3d4e5f6] INFO  c.e.OrderService - 开始查询, id=1
2025-01-08 12:34:56.990 [ttl-pool-1] [a1b2c3d4e5f6] INFO  c.e.OrderService - 记录查询日志, orderId=1

三条日志的 traceId 都是 a1b2c3d4e5f6,可以快速关联。

注意事项

1. 内存泄漏

ThreadLocal 必须在请求结束时清理,否则可能会导致内存泄漏。Filter 中的 finally 块很重要:

java 复制代码
try {
    // 处理请求
} finally {
    LogContextHolder.clear();  // 必须清理
}

2. 线程池命名

给线程池设置有意义的名字,方便从日志中识别:

java 复制代码
new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build()

3. MDC 的性能

MDC 读写有少量开销,但对性能影响很小。如果是超高并发场景,可以考虑按需开启。

4. traceId 生成策略

几种常见方案:

方案 优点 缺点
UUID 简单、无需协调 较长、无序
雪花算法 性能高、趋势递增 需要机器 ID 配置
Redis incr 全局唯一、递增 依赖 Redis
请求入口自增 简单 单机内唯一

大多数场景用 UUID 就够了。

5. 分布式追踪

如果是微服务架构,可以考虑接入 SkyWalking、Zipkin 等分布式追踪系统,它们提供更强大的链路追踪能力,包括调用关系图、耗时分析等。

但接入成本较高,中小项目用 MDC + traceId 通常就够了。

总结

统一日志上下文的核心是:

  • 1. 入口生成:Filter/Interceptor 拦截请求,生成 traceId
  • 2. 线程传递:用 TTL 解决跨线程问题
  • 3. 服务传递:HTTP 调用通过请求头传递 traceId
  • 4. 出口清理:请求结束时清理 ThreadLocal

这套方案不依赖第三方框架,实现简单,适合大多数单体应用或中小型微服务项目。

如果项目规模较大,对链路追踪要求高,可以考虑接入专业的 APM 系统。

相关推荐
麦兜*8 分钟前
Spring Boot 整合 Apache Doris:实现海量数据实时OLAP分析实战
大数据·spring boot·后端·spring·apache
源代码•宸10 分钟前
Golang基础语法(go语言指针、go语言方法、go语言接口、go语言断言)
开发语言·经验分享·后端·golang·接口·指针·方法
Bony-11 分钟前
Golang 常用工具
开发语言·后端·golang
pyniu12 分钟前
Spring Boot车辆管理系统实战开发
java·spring boot·后端
love_summer13 分钟前
深入理解Python控制流:从if-else到结构模式匹配,写出更优雅的条件判断逻辑
后端
牛奔14 分钟前
GVM:Go 版本管理器安装与使用指南
开发语言·后端·golang
武子康15 分钟前
大数据-207 如何应对多重共线性:使用线性回归中的最小二乘法时常见问题与解决方案
大数据·后端·机器学习
颜酱16 分钟前
用填充表格法-继续吃透完全背包及其变形
前端·后端·算法
pathfinder同学16 分钟前
Node.js 框架的 10 个写法痛点,以及更优雅的解决方案
后端
gelald20 分钟前
AQS 解析:从原理到实战
java·后端