1. 日志记录的线程安全性
在单线程程序里一切都很简单:一个线程写日志,没人打扰它。而在真实应用中——Web 服务、微服务——同时会有几十上百个线程在运行。想象一下,若有几个人同时在同一行纸上用笔写字,结果可以说是相当难以阅读。
线程安全(thread safety)意味着即便有 100500 个线程同时写日志,消息也不会混淆、粘连或丢失。
库是如何实现的?
现代的日志库(如 Log4j 2、Logback、java.util.logging)从设计之初就考虑了线程安全。这意味着:
- 每个线程都可以安全地调用日志记录器的方法。
- 库内部使用同步与队列,保证消息互不干扰。
- 即便多个线程同时写入同一个文件,日志也不会被打乱。
重要:日志记录器本身(例如来自 SLF4J 或 Log4j 的 Logger 对象)可以作为类中的 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 框架(Spring、Jakarta EE、Micronaut 等)都“开箱即用”地集成了日志记录。通常只需在项目中加入 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,它会在服务之间传递。这使得可以追踪请求在整个系统中的路径(分布式追踪)。常用的系统有 Zipkin、Jaeger、OpenTelemetry。
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:自研日志记录器而未考虑线程安全。 如果有人“自己写一个日志器”但没有做同步——在多线程环境中几乎注定会出现日志丢失或破坏。
GO TO FULL VERSION