CodeGym /课程 /JAVA 25 SELF /多线程与 Web 应用中的日志记录

多线程与 Web 应用中的日志记录

JAVA 25 SELF
第 63 级 , 课程 2
可用

1. 日志记录的线程安全性

在单线程程序里一切都很简单:一个线程写日志,没人打扰它。而在真实应用中——Web 服务、微服务——同时会有几十上百个线程在运行。想象一下,若有几个人同时在同一行纸上用笔写字,结果可以说是相当难以阅读。

线程安全(thread safety)意味着即便有 100500 个线程同时写日志,消息也不会混淆、粘连或丢失。

库是如何实现的?

现代的日志库(如 Log4j 2Logbackjava.util.logging)从设计之初就考虑了线程安全。这意味着:

  • 每个线程都可以安全地调用日志记录器的方法。
  • 库内部使用同步与队列,保证消息互不干扰。
  • 即便多个线程同时写入同一个文件,日志也不会被打乱。

重要:日志记录器本身(例如来自 SLF4JLog4jLogger 对象)可以作为类中的 static final 字段使用——这不会引发线程问题。

示例

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MultiThreadedLoggerExample {
    private static final Logger logger = LoggerFactory.getLogger(MultiThreadedLoggerExample.class);

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 5; i++) {
                logger.info("线程 {} 正在写入消息 {}", Thread.currentThread().getName(), i);
            }
        };

        Thread t1 = new Thread(task, "一号");
        Thread t2 = new Thread(task, "二号");
        t1.start();
        t2.start();
    }
}

在日志中你会看到来自两个线程的整齐消息——没有混乱与重叠。

2. 日志上下文:MDC(Mapped Diagnostic Context)

设想:你的应用同时处理上百个请求,每个请求在自己的线程中。日志里信息闪来闪去,但不清楚哪些消息属于哪个请求。我们希望看到的不仅是“发生了什么”,还要知道“发生在谁身上、发生在哪个请求里”。

MDC(Mapped Diagnostic Context,映射诊断上下文)是一种机制,允许将与当前线程相关的附加信息“绑定”到日志中。该线程写下的每条消息都会自动附带这些数据。

示例:记录请求标识

在 Web 应用中,可以为每个请求分配一个唯一 ID(例如 UUID)。通过 MDC,该 ID 会自动添加到处理该请求的线程所写的所有日志中。

代码示例(SLF4J + Logback):

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

import java.util.UUID;

public class MdcExample {
    private static final Logger logger = LoggerFactory.getLogger(MdcExample.class);

    public static void main(String[] args) {
        Runnable task = () -> {
            // 生成唯一的请求标识
            String requestId = UUID.randomUUID().toString();
            MDC.put("requestId", requestId); // 加入 MDC

            logger.info("正在处理请求");
            doSomeWork();
            logger.info("处理完成");

            MDC.clear(); // 结束后务必清理!
        };

        Thread t1 = new Thread(task, "线程-1");
        Thread t2 = new Thread(task, "线程-2");
        t1.start();
        t2.start();
    }

    static void doSomeWork() {
        logger.debug("正在执行工作...");
    }
}

日志格式配置(例如 logback.xml):

<encoder>
    <pattern>%d{HH:mm:ss} [%thread] %-5level %logger{36} [requestId=%X{requestId}] - %msg%n</pattern>
</encoder>

结果:

12:01:23 [线程-1] INFO  MdcExample [requestId=ad8d...f3] - 正在处理请求
12:01:23 [线程-1] DEBUG MdcExample [requestId=ad8d...f3] - 正在执行工作...
12:01:23 [线程-1] INFO  MdcExample [requestId=ad8d...f3] - 处理完成

重要!

  • MDC 只在单个线程范围内有效。如果你将工作转交给另一个线程(例如通过线程池),需要手动传递 MDC 的值(或使用能自动处理的专用库)。
  • 不要忘记清理 MDC!若不清理,数据可能会在同一线程中“泄漏”到下一个请求(例如在 Web 服务器的线程池中)。请在 finally 块中调用 MDC.clear()

3. Web 应用中的日志记录

Web 应用不只是“启动并运行”的程序。它更像一条装配线:请求到达、处理、响应发出,而且是并发、成百上千地进行。在这里,日志不是奢侈品,而是必需品!

在 Web 应用中应记录什么?

  • HTTP 请求与响应:方法、URL、参数、响应状态、处理耗时。
  • 错误与异常:所有意外故障、堆栈追踪(stack trace)。
  • 业务事件:注册、登录、下单、支付等。
  • 技术细节:与数据库、外部服务的交互,操作执行时间等。

首要原则:以一种方式记录日志,使得一个月后当某个问题在凌晨 3 点出现时,你仍能找出哪里出了问题。

示例:记录 HTTP 请求(Spring Boot)

最简单的方式是使用过滤器或切面,记录每个进入的请求。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.UUID;

@Component
public class RequestLoggingFilter implements Filter {
    private static final Logger logger = LoggerFactory.getLogger(RequestLoggingFilter.class);

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        String requestId = UUID.randomUUID().toString();
        MDC.put("requestId", requestId);

        HttpServletRequest httpRequest = (HttpServletRequest) request;
        logger.info("请求: {} {}", httpRequest.getMethod(), httpRequest.getRequestURI());

        long start = System.currentTimeMillis();
        try {
            chain.doFilter(request, response); // 继续向下传递(到控制器)
        } finally {
            long duration = System.currentTimeMillis() - start;
            logger.info("已发送响应,处理耗时: {} 毫秒", duration);
            MDC.clear();
        }
    }
}

错误与异常的日志记录

在 Web 框架(如 Spring)中,通常使用专门的错误处理器(@ExceptionHandler)来优雅地记录所有意外故障。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
public class GlobalExceptionHandler {
    private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(Exception.class)
    public String handleException(Exception ex) {
        logger.error("发生错误:", ex); // 记录完整的 stack trace!
        return "error"; // 返回错误页
    }
}

与 Web 框架的集成

几乎所有现代 Web 框架(SpringJakarta EEMicronaut 等)都“开箱即用”地集成了日志记录。通常只需在项目中加入 SLF4J/Logback 依赖,框架的标准消息(应用启动、请求处理、错误)就会自动记录。

4. 实战:多线程任务中的日志示例

让我们在教学应用(例如订单处理服务)中加入多线程处理,看看日志如何帮助我们保持清晰。

示例:使用 MDC 在多个线程中处理订单

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;

import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class OrderProcessingApp {
    private static final Logger logger = LoggerFactory.getLogger(OrderProcessingApp.class);

    public static void main(String[] args) {
        ExecutorService executor = Executors.newFixedThreadPool(3);

        for (int i = 1; i <= 5; i++) {
            final int orderId = i;
            executor.submit(() -> {
                String requestId = UUID.randomUUID().toString();
                MDC.put("requestId", requestId);

                try {
                    logger.info("开始处理订单 {}", orderId);
                    processOrder(orderId);
                    logger.info("订单 {} 处理成功", orderId);
                } catch (Exception ex) {
                    logger.error("处理订单 " + orderId + " 时发生错误", ex);
                } finally {
                    MDC.clear();
                }
            });
        }
        executor.shutdown();
    }

    static void processOrder(int orderId) throws InterruptedException {
        if (orderId % 2 == 0) {
            throw new RuntimeException("为偶数订单模拟错误");
        }
        Thread.sleep(500); // 模拟工作
    }
}

发生了什么:

  • 每个订单在独立线程中处理。
  • 为每个线程创建唯一的 requestId(通过 MDC)。
  • 可以通过该标识找到同一订单的所有日志。
  • 错误会记录完整堆栈。

日志格式已配置为展示 requestId

5. 重要细节与注意事项

  • 线程、线程池与 MDC。 如果使用线程池(你很可能在用),务必记住:池中的线程会被复用!若忘记清理 MDC,一个请求的数据可能进入另一个请求的日志。请始终在工作结束时调用 MDC.clear()
  • MDC 与异步任务。 在异步 Web 框架(例如 Spring WebFlux)中,MDC 不一定能“开箱即用”,因为请求处理可能在不同线程之间跳转。对此可使用专用扩展或适配器。
  • 微服务中的日志记录。 在微服务架构中,不仅记录本地请求标识,还常记录全局的 traceId,它会在服务之间传递。这使得可以追踪请求在整个系统中的路径(分布式追踪)。常用的系统有 ZipkinJaegerOpenTelemetry

6. 演示:System.out.println 与日志记录的区别

System.out.println 只是将字符串打印到控制台。在多线程环境中:

  • 消息可能相互交错。
  • 没有时间、线程、级别、上下文信息。
  • 无法配置输出到文件、格式、以及按级别过滤。

日志记录器 会写入结构化消息,考虑线程、级别与格式,支持输出到不同目的地(文件、控制台、网络)。

对比示例

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class PrintVsLogger {
    private static final Logger logger = LoggerFactory.getLogger(PrintVsLogger.class);

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("System.out: " + Thread.currentThread().getName() + " 步 " + i);
                logger.info("Logger: 步 {}", i);
            }
        };
        new Thread(task, "T1").start();
        new Thread(task, "T2").start();
    }
}

结论:

  • System.out —— 消息可能交错,没有时间与级别。
  • 日志记录器 —— 每条消息都包含时间、线程、级别,可过滤并快速定位所需信息。

7. 多线程与 Web 应用日志中的常见错误

错误 №1:使用 System.out.println 代替日志记录器。 在多线程环境中会导致控制台“乱作一团”,无法按条件筛选消息,也缺失上下文信息。

错误 №2:忽视或错误使用 MDC。 如果不使用 MDC 来传递请求/用户标识,日志会变得毫无意义——无法判断错误属于哪个请求。若忘记清理 MDC,数据可能“泄漏”到其他请求。

错误 №3:将日志记录器作为局部变量创建。 最好使用 private static final Logger——这样记录器按类只创建一次,不浪费内存,也降低出错风险。

错误 №4:记录敏感数据。 日志中不应出现密码、银行卡号、个人信息等——这是安全违规!

错误 №5:只使用 “INFO” 或只使用 “ERROR”。 请使用合适的级别:DEBUG 用于调试,INFO 用于业务事件,ERROR 用于错误。不要把所有内容都写成同一等级——否则日志会失去意义。

错误 №6:不记录异常的堆栈追踪。 如果只是写 logger.error("错误: " + ex.getMessage()),会丢失错误原因的信息。应始终记录完整异常:logger.error("错误", ex)

错误 №7:自研日志记录器而未考虑线程安全。 如果有人“自己写一个日志器”但没有做同步——在多线程环境中几乎注定会出现日志丢失或破坏。

评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION