Java 日志最佳实践:SLF4J 详解与实战避坑指南
一、前言
凌晨两点,告警把你从睡梦中炸醒。用户下单后扣了款,但订单状态一直是"待支付"。你打开日志系统,搜 orderId=2025071600382,只找到一条:
2026-07-16 02:07:33 ERROR something went wrong
没有订单号,没有用户ID,没有金额,没有堆栈。四十分钟过去,你从 Nginx 翻到数据库 binlog,最终发现是支付回调 MQ 消息序列化失败——但如果当时的日志写的是 支付回调处理失败, orderId=xxx, channel=alipay, rawBody={...} 加上完整异常堆栈,三分钟就能定位。
日志不是写给编译器看的,是写给凌晨两点被叫醒的你自己看的。本文从 SLF4J 出发,用反例与正例逐条对比,帮你建立一套可直接落地的日志规范。

二、Java 日志体系全景
Java 日志框架的历史,是一部"各自造轮子,然后抽象轮子"的历史:
|
框架
|
诞生年份
|
特点
|
现状
|
| --- | --- | --- | --- |
| java.util.logging
(JUL)
|
2002 (JDK 1.4)
|
JDK 内置,零依赖
|
API 弱,几乎不在生产使用
|
|
Log4j 1.x
|
2001
|
Java 日志开山之作
| 已停止维护
,存在严重安全漏洞
|
|
Logback
|
2006
|
Log4j 作者新作,零额外配置
|
活跃维护,Spring Boot 默认绑定
|
|
Log4j 2.x
|
2014
|
全新架构,异步性能极强(LMAX Disruptor)
|
活跃维护,大型系统常用
|
问题来了:如果你的代码里直接 import org.apache.log4j.Logger,哪天要切换框架,几千个文件全得改。
SLF4J(Simple Logging Facade for Java) 就是为解决这个问题而生的——它不是日志实现,是日志接口层:
你的业务代码只依赖 SLF4J 的 API,具体用 Logback 还是 Log4j2,部署时通过引入不同的 JAR 切换。
类比一下:SLF4J 是 JDBC 接口,Logback 是 MySQL 驱动,Log4j2 是 PostgreSQL 驱动。业务代码只写 JDBC,换数据库只换驱动。
推荐组合:SLF4J + Logback(Spring Boot 开箱即用,零配置即可运行)。

三、SLF4J 基础用法
3.1 Logger 的声明方式
反例 ❌ — 在方法内部创建 Logger
public class OrderService {
public void createOrder(OrderDTO dto) {
// ❌ 每次调用方法都创建一个 Logger 实例,完全浪费
Logger log = LoggerFactory.getLogger(OrderService.class);
log.info("创建订单");
}
}
反例 ❌ — 复制粘贴忘改 Class
public class OrderService {
// ❌ 从 UserService 复制过来忘改了,日志打出的类名是 UserService,排查时误导人
private static final Logger log = LoggerFactory.getLogger(UserService.class);
}
正例 ✅ — 标准声明
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class OrderService {
// ✅ static:类级别共享,只创建一次;final:防止被意外重新赋值
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
public void createOrder(OrderDTO dto) {
log.info("创建订单, orderId={}", dto.getOrderId());
}
}
正例 ✅ — Lombok 注解(推荐)
import lombok.extern.slf4j.Slf4j;
@Slf4j // ✅ 编译期自动生成 private static final Logger log = ...
public class OrderService {
public void createOrder(OrderDTO dto) {
log.info("创建订单, orderId={}", dto.getOrderId());
}
}
为什么必须是
static final?
LoggerFactory.getLogger()内部是一个 ConcurrentHashMap 查找,虽然开销不大,但完全没必要重复执行。static保证类级别共享一个实例,final防止被意外覆盖。这个规范没有例外。
3.2 字符串拼接 vs 占位符
反例 ❌ — 字符串 + 拼接
// ❌ 即使当前日志级别是 INFO(DEBUG 不输出),拼接依然会执行
// user.getId() 的 toString()、StringBuilder 拼接、内存分配——全白做了
log.debug("用户登录成功: userId=" + user.getId()
+ ", ip=" + request.getRemoteAddr()
+ ", 耗时=" + cost + "ms");
正例 ✅ — {} 占位符
// ✅ 如果当前级别不输出,SLF4J 不会调用参数的 toString(),零开销
log.debug("用户登录成功: userId={}, ip={}, 耗时={}ms",
user.getId(), request.getRemoteAddr(), cost);
正例 ✅ — SLF4J 2.x Fluent API
// ✅ 链式调用,可按条件附加字段,语义更清晰
log.atDebug()
.setMessage("用户登录成功")
.addKeyValue("userId", user.getId())
.addKeyValue("ip", request.getRemoteAddr())
.addKeyValue("costMs", cost)
.log();
性能差异原理:
+拼接是即时求值(eager)——方法调用前就完成了字符串构建,无论日志是否输出。{}占位符是惰性求值(lazy)——SLF4J 内部先判断级别是否匹配,确认要输出后才调用toString()。生产环境通常只开 INFO,大量 DEBUG 日志存在时,差距可达数量级。
3.3 日志级别的正确使用
|
级别
|
一句话定义
|
| --- | --- |
| ERROR |
系统出了故障,需要人立即介入处理
|
| WARN |
异常情况,系统还能运行,但需要关注
|
| INFO |
关键业务节点,用于审计和状态追踪
|
| DEBUG |
开发调试细节,生产环境默认关闭
|
| TRACE |
比 DEBUG 更细粒度,极少使用
|
ERROR
反例 ❌
// ❌ 用户手机号格式不对,这是业务校验,不是系统故障
log.error("手机号格式不正确: {}", phone);
正例 ✅
// ✅ 数据库挂了——需要人介入
log.error("数据库连接池耗尽, active={}, max={}", pool.getActive(), pool.getMax(), e);
WARN
反例 ❌
// ❌ 用户密码错了——正常业务行为,不是警告
log.warn("用户密码错误, userId={}", userId);
正例 ✅
// ✅ 主库连不上切到从库——能跑,但得关注
log.warn("主库不可用, 已切换到从库, primaryUrl={}", primaryUrl);
INFO
反例 ❌
// ❌ 每个方法入口出口都打 INFO,一个请求 20 条日志
log.info("进入 createOrder 方法");
正例 ✅
// ✅ 业务里程碑:一看就知道什么业务、什么结果、关键数据
log.info("订单创建成功, orderId={}, userId={}, amount={}元, items={}",
orderId, userId, amount, items.size());
DEBUG / TRACE
正例 ✅
// ✅ 批量任务:一条 INFO 汇总,单条明细用 DEBUG
log.info("批量扣款开始, totalCount={}", orders.size());
for (Order order : orders) {
log.debug("处理订单, orderId={}, amount={}", order.getId(), order.getAmount());
}
log.info("批量扣款完成, success={}, fail={}", successCount, failCount);
黄金法则: 生产环境 INFO 级别的日志应该让人能通读。如果多到看不过来,说明太多日志应该降为 DEBUG。
3.4 异常日志的记录
这是出错率最高的地方。逐一拆解:
反例 ❌ — 只打 e.getMessage(),丢失堆栈
try {
paymentService.pay(orderId);
} catch (Exception e) {
// ❌ NPE 的 getMessage() 是 null,打出 "支付失败: null"
log.error("支付失败: " + e.getMessage());
}
反例 ❌ — 只传异常对象,没有业务上下文
try {
paymentService.pay(orderId);
} catch (Exception e) {
// ❌ 有堆栈了,但不知道是哪笔订单、哪个用户
log.error("支付失败", e);
}
反例 ❌ — 吞掉异常
try {
paymentService.pay(orderId);
} catch (Exception e) {
// ❌ 用户扣了款但订单没更新,事后排查无从下手
}
正例 ✅ — 业务上下文 + 异常堆栈
try {
paymentService.pay(orderId);
} catch (Exception e) {
// ✅ 业务上下文 + 完整异常堆栈,一次到位
log.error("支付失败, orderId={}, userId={}, amount={}元, channel={}",
orderId, userId, amount, payChannel, e);
}
关键:为什么异常对象必须是最后一个参数?
SLF4J 约定:方法签名最后一个参数如果是
Throwable类型,会自动打印其完整堆栈。放在中间会被当作普通{}参数处理,堆栈就丢了。
3.5 条件日志与惰性求值
反例 ❌ — 无条件执行昂贵操作
// ❌ 即使 DEBUG 关闭,toJSON() 序列化照样执行
log.debug("请求体: {}", JSON.toJSONString(requestBody));
// ❌ Stream 拼接同理
log.debug("查询参数: " + params.entrySet().stream()
.map(e -> e.getKey() + "=" + e.getValue())
.collect(Collectors.joining("&")));
正例 ✅ — 用 isDebugEnabled() 守卫
// ✅ 先判断级别,跳过昂贵计算
if (log.isDebugEnabled()) {
log.debug("请求体: {}", JSON.toJSONString(requestBody));
}
正例 ✅ — SLF4J 2.x Lambda 惰性求值(最推荐)
// ✅ Lambda 只在日志真正输出时才执行
log.atDebug()
.setMessage("请求体")
.addKeyValue("body", () -> JSON.toJSONString(requestBody)) // 惰性!
.log();
注意: 如果参数本身就是简单的
String、int、long,占位符天然就是惰性的,无需额外守卫。只有参数涉及方法调用或复杂计算时才需要。
3.6 敏感信息脱敏
反例 ❌ — 明文打印敏感数据
// ❌ 手机号、身份证、银行卡全裸奔
log.info("用户注册: phone={}, idCard={}, bankCard={}", phone, idCard, bankCard);
log.info("登录校验: username={}, password={}", username, password);
正例 ✅ — 脱敏后打印
// ✅ 关键字段脱敏处理
log.info("用户注册: phone={}, idCard={}, bankCard={}",
MaskUtils.maskPhone(phone), // 138****5678
MaskUtils.maskIdCard(idCard), // 110***********1234
MaskUtils.maskBankCard(bankCard));
// ✅ 密码永远不进日志
log.info("登录校验: username={}", username);
脱敏工具类示例:
public final class MaskUtils {
private MaskUtils() {}
/** 手机号:138****5678 */
public static String maskPhone(String phone) {
if (phone == null || phone.length() < 7) return "***";
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
/** 身份证:110***********1234 */
public static String maskIdCard(String idCard) {
if (idCard == null || idCard.length() < 8) return "***";
return idCard.substring(0, 3)
+ "*".repeat(idCard.length() - 7)
+ idCard.substring(idCard.length() - 4);
}
/** 银行卡:6222 **** **** 1234 */
public static String maskBankCard(String cardNo) {
if (cardNo == null || cardNo.length() < 8) return "***";
return cardNo.substring(0, 4) + " **** **** " + cardNo.substring(cardNo.length() - 4);
}
}
四、日志内容规范
一条好日志应该像一份微型事故报告:看一眼就知道什么时候、什么地方、出了什么事、关键数据是什么。
|
字段
|
说明
|
示例
|
| --- | --- | --- |
|
时间戳
|
ISO 8601,含毫秒和时区
| 2025-07-16T02:07:33.456+08:00 |
|
日志级别
|
ERROR / WARN / INFO / DEBUG
| ERROR |
|
线程名
|
定位并发问题的关键信息
| http-nio-8080-exec-3 |
|
Logger 名
|
缩写类名,精确定位代码位置
| c.e.s.s.OrderService |
|
TraceId / SpanId
|
链路追踪 ID,跨服务串联请求
| traceId=a1b2c3d4 |
|
业务关键字
|
随业务上下文动态添加
| orderId=2025071600382 |
|
日志消息
|
简洁、自解释的描述
| 支付回调处理失败:验签不通过 |
对应logback-spring.xml基础配置:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="LOG_PATTERN"
value="%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} [%-5level] [%thread] [%logger{36}] [traceId=%X{traceId:-}] %msg%n" />
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE" />
</root>
</configuration>
输出效果:
2025-07-16T02:07:33.456+08:00 [ERROR] [http-nio-8080-exec-3] [c.e.s.s.OrderService] [traceId=a1b2c3d4] 支付回调处理失败, orderId=2025071600382, channel=alipay
java.lang.RuntimeException: 签名校验不通过
at com.example.shop.service.PaymentCallbackService.verify(PaymentCallbackService.java:78)
...
五、反模式清单
以下是我在 Code Review 中最常抓到的 11 个日志问题。
反模式 1:用 System.out.println 代替日志框架
现象: 项目中散落着 System.out.println / System.err.println。
错误代码 ❌
public void process(Order order) {
System.out.println("开始处理订单: " + order.getId());
System.err.println("处理异常: " + e.getMessage());
}
问题分析
-
• 没有日志级别,无法过滤
-
• 没有时间戳、线程、类名
-
• 输出到 stdout/stderr,不进日志文件,不被 Filebeat / Fluentd 捕获
-
•
println内部是同步synchronized,高并发下拖慢性能
正确代码 ✅
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
public void process(Order order) {
log.info("处理订单, orderId={}", order.getId());
log.error("处理异常, orderId={}", order.getId(), e);
}
一句话总结:
System.out.println是main()里的调试玩具,不是日志方案。
反模式 2:字符串拼接打日志
现象: 用 + 号拼接日志消息。
错误代码 ❌
log.debug("查询用户: id=" + userId + ", name=" + userName + ", age=" + age);
问题分析
+ 拼接在方法调用前就完成了字符串构建。生产环境 DEBUG 关闭,但 toString()、StringBuilder 分配、拼接照样执行。
正确代码 ✅
log.debug("查询用户: id={}, name={}, age={}", userId, userName, age);
一句话总结: 永远用
{}占位符,不要用 + 拼接——性能差,还难看。
反模式 3:日志级别滥用
现象: 所有日志都打 INFO,或者把非故障场景标记为 ERROR。
错误代码 ❌
// ❌ 所有日志都是 INFO——一天 50GB,有用信息淹没在噪声里
log.info("变量 a = " + a);
log.info("进入方法");
// ❌ 用户输错密码不是系统故障
log.error("用户密码错误, userId={}", userId);
正确代码 ✅
log.debug("变量 a = {}", a); // 调试 → DEBUG
log.info("订单创建成功, orderId={}, amount={}", orderId, amount); // 业务里程碑 → INFO
log.warn("密码连续错误{}次, userId={}", failCount, userId); // 需关注 → WARN
log.error("Redis 集群连接失败", e); // 系统故障 → ERROR
一句话总结: ERROR 是"请立刻看手机"的告警级别,不是用来高亮文本的。
反模式 4:吞掉异常(空 catch 块)
现象: catch 了异常但什么都不做。
错误代码 ❌
try {
rabbitTemplate.convertAndSend("order.exchange", "order.create", message);
} catch (Exception e) {
// ❌ 消息丢了,你完全不知道
}
正确代码 ✅
try {
rabbitTemplate.convertAndSend("order.exchange", "order.create", message);
} catch (Exception e) {
log.error("MQ 消息发送失败, exchange={}, routingKey={}, orderId={}",
"order.exchange", "order.create", orderId, e);
}
一句话总结: 空 catch 块 = 在黑暗中拆炸弹然后假装炸弹不存在。
反模式 5:异常日志缺少上下文信息
现象: 打了异常,但没说是什么业务场景。
错误代码 ❌
catch (Exception e) {
log.error("操作失败", e); // ❌ 哪个操作?谁的?什么参数?
}
正确代码 ✅
catch (Exception e) {
log.error("订单支付失败, orderId={}, userId={}, amount={}, channel={}",
orderId, userId, amount, payChannel, e);
}
一句话总结: 没有上下文的异常日志就像报警电话只说了"出事了"——在哪?什么事?什么都没说。
反模式 6:日志中包含敏感信息
现象: 用户隐私数据明文出现在日志中。
错误代码 ❌
log.info("用户下单, phone={}, idCard={}, creditCard={}", phone, idCard, creditCard);
log.info("登录校验, username={}, password={}", username, password);
正确代码 ✅
log.info("用户下单, phone={}, idCard={}, creditCard={}",
MaskUtils.maskPhone(phone),
MaskUtils.maskIdCard(idCard),
MaskUtils.maskBankCard(creditCard));
log.info("登录校验, username={}", username); // 密码永远不进日志
一句话总结: 日志系统不是保险箱,密码和密钥进日志等于裸奔。
反模式 7:循环中打大量日志
现象: 在 for 循环体内打 INFO 甚至 ERROR。
错误代码 ❌
for (Long userId : userIdList) { // 10 万个用户
try {
notifyService.send(userId);
log.info("通知发送成功, userId={}", userId); // ❌ 10 万条 INFO
} catch (Exception e) {
log.error("通知发送失败, userId={}", userId, e); // ❌ 大量 ERROR 告警轰炸
}
}
正确代码 ✅
int successCount = 0;
int failCount = 0;
List<Long> failedIds = new ArrayList<>();
for (Long userId : userIdList) {
try {
notifyService.send(userId);
successCount++;
log.debug("通知发送成功, userId={}", userId);
} catch (Exception e) {
failCount++;
failedIds.add(userId);
log.warn("通知发送失败, userId={}", userId, e);
}
}
// ✅ 汇总 → INFO
log.info("批量通知完成, total={}, success={}, fail={}, failedSample={}",
userIdList.size(), successCount, failCount,
failedIds.size() <= 20 ? failedIds : failedIds.subList(0, 20));
一句话总结: 循环里打 INFO,日志系统先崩。汇总才有意义。
反模式 8:日志消息含糊不清
现象: 日志消息写得像加密电报。
错误代码 ❌
log.error("error occurred");
log.info("failed");
log.warn("something wrong");
log.error("出错了");
正确代码 ✅
log.error("订单创建失败: 库存不足, orderId={}, productId={}, requestedQty={}, stockQty={}",
orderId, productId, requestedQty, stockQty);
一句话总结: 如果三个月后的你自己看不懂这条日志在说什么,它就是废物。
反模式 9:每个方法入口/出口都打 INFO
现象: 每个方法第一行和最后一行各有一条 INFO 日志。
错误代码 ❌
public Order createOrder(OrderDTO dto) {
log.info("进入 createOrder"); // ❌
// ...
log.info("退出 createOrder"); // ❌
return order;
}
问题分析
-
• 纯粹标记"我进了哪个方法"——看代码就知道,日志毫无信息增量
-
• 一个请求链路调 10 个方法 = 20 条无意义 INFO
-
• 想知道方法调用链路,用链路追踪(SkyWalking / OpenTelemetry),不要用人肉埋点
正确代码 ✅
public Order createOrder(OrderDTO dto) {
Order order = orderBuilder.build(dto);
orderRepository.save(order);
// ✅ 只在关键业务节点打日志,记录代码中看不到的运行时数据
log.info("订单创建成功, orderId={}, userId={}, amount={}元",
order.getId(), order.getUserId(), order.getAmount());
return order;
}
一句话总结: 日志不是手动 AOP。想知道调用链路,用链路追踪;想知道业务结果,打业务日志。
反模式 10:使用 e.printStackTrace()
现象: 异常直接打印到 stderr。
错误代码 ❌
try {
httpClient.execute(request);
} catch (IOException e) {
e.printStackTrace(); // ❌ 输出到 stderr,不进日志文件
}
问题分析
-
• 输出到
System.err,不进日志文件,不被 Filebeat / Fluentd 捕获 -
• 格式不可控,没有时间戳、线程名
-
•
System.err内部是同步锁,高并发下拖慢性能
正确代码 ✅
try {
httpClient.execute(request);
} catch (IOException e) {
log.error("HTTP 请求失败, url={}, method={}", url, method, e);
}
一句话总结:
e.printStackTrace()是System.err.println的表兄弟,都是日志体系的叛徒。
反模式 11:关键路径缺少日志——出了事无迹可寻
现象: 方法逻辑完整实现了,但在关键分支和状态变化处没有打日志。出问题后完全没有排查线索。
下面用一个完整的订单创建流程展示:什么叫"在关键点打日志"。
错误代码 ❌ — 关键路径没有日志
@Service
public class OrderService {
public Order createOrder(CreateOrderRequest request) {
// ❌ 没有日志:不知道收到了什么请求
User user = userRepository.findById(request.getUserId());
// ❌ 没有日志:优惠券是否命中、扣减是否成功,完全无记录
Coupon coupon = null;
if (request.getCouponId() != null) {
coupon = couponService.lock(request.getCouponId());
}
// ❌ 没有日志:不知道商品价格、计算结果
BigDecimal totalPrice = calculateTotal(request.getItems());
BigDecimal finalAmount = coupon == null ? totalPrice
: couponService.apply(totalPrice, coupon);
Order order = new Order();
order.setOrderId(generateOrderId());
order.setUserId(request.getUserId());
order.setAmount(finalAmount);
order.setStatus(OrderStatus.CREATED);
orderRepository.save(order);
// ❌ 没有日志:订单写库了都不知道
return order;
}
}
问题分析
这段代码功能完全正确,但运营反馈"部分用户扣了优惠券但订单没生成"时,你翻遍日志什么也找不到——因为整个流程没有一条日志。
正确代码 ✅ — 在每个关键节点打日志
@Service
@Slf4j
public class OrderService {
public Order createOrder(CreateOrderRequest request) {
// ✅ 业务事件:收到创建订单请求,记录关键参数(不是"进入方法")
log.info("收到创建订单请求, userId={}, itemCount={}, couponId={}",
request.getUserId(), request.getItems().size(), request.getCouponId());
User user = userRepository.findById(request.getUserId());
log.debug("用户查询完成, userId={}, userType={}", user.getId(), user.getUserType());
// ✅ 有副作用的操作:优惠券锁定,必须记录结果
Coupon coupon = null;
if (request.getCouponId() != null) {
try {
coupon = couponService.lock(request.getCouponId());
log.info("优惠券锁定成功, couponId={}, discount={}元",
coupon.getId(), coupon.getDiscount());
} catch (CouponLockException e) {
log.warn("优惠券锁定失败, couponId={}, reason={}",
request.getCouponId(), e.getMessage());
// 根据业务决定:是抛异常中断,还是忽略优惠券继续下单
}
}
// ✅ 资金相关:每个数字都要留痕
BigDecimal totalPrice = calculateTotal(request.getItems());
log.info("订单金额计算完成, userId={}, totalPrice={}元, couponDiscount={}元",
request.getUserId(), totalPrice,
coupon == null ? BigDecimal.ZERO : coupon.getDiscount());
BigDecimal finalAmount = (coupon == null)
? totalPrice
: couponService.apply(totalPrice, coupon);
String orderId = generateOrderId();
Order order = new Order();
order.setOrderId(orderId);
order.setUserId(request.getUserId());
order.setAmount(finalAmount);
order.setStatus(OrderStatus.CREATED);
orderRepository.save(order);
// ✅ 状态变更:订单落库是核心节点
log.info("订单创建成功, orderId={}, userId={}, finalAmount={}元, couponId={}",
orderId, request.getUserId(), finalAmount, request.getCouponId());
return order;
}
}
如何判断一个地方该不该打日志?问自己三个问题:
1. 这是不是状态变更? (订单创建、支付完成、退款发起)→ 必须打 INFO
2. 这是不是有副作用的操作? (扣库存、锁优惠券、发消息)→ 必须打 INFO
3. 这里如果出 bug,我需要什么信息才能排查? → 把那个信息打出来
一句话总结: 关键路径没日志 = 闭眼开车。日志的功能是"让发生过的事情可追溯"。
shiyo
六、生产环境进阶配置
6.1 完整生产级 logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">
<!-- ==================== 变量 ==================== -->
<property name="APP_NAME" value="shop-service" />
<property name="LOG_HOME" value="./logs/${APP_NAME}" />
<property name="LOG_PATTERN"
value="%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} [%-5level] [%thread] [%logger{36}] [traceId=%X{traceId:-}] [spanId=%X{spanId:-}] %msg%n" />
<!-- ==================== 控制台 ==================== -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- ==================== 普通日志文件(按日+大小滚动) ==================== -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${APP_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- ==================== ERROR 独立文件 ==================== -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${APP_NAME}-error.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch>
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/${APP_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>2GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>${LOG_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- ==================== 异步 Appender ==================== -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>1024</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
<appender-ref ref="FILE" />
</appender>
<appender name="ASYNC_ERROR" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>256</queueSize>
<discardingThreshold>0</discardingThreshold>
<neverBlock>true</neverBlock>
<appender-ref ref="ERROR_FILE" />
</appender>
<!-- ==================== JSON 结构化日志 ==================== -->
<appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_HOME}/${APP_NAME}-json.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_HOME}/${APP_NAME}-json.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>15</maxHistory>
<totalSizeCap>3GB</totalSizeCap>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>spanId</includeMdcKeyName>
<customFields>{"service":"shop-service"}</customFields>
</encoder>
</appender>
<!-- ==================== 环境区分 ==================== -->
<springProfile name="dev">
<root level="DEBUG">
<appender-ref ref="CONSOLE" />
</root>
</springProfile>
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="ASYNC_FILE" />
<appender-ref ref="ASYNC_ERROR" />
<appender-ref ref="JSON_FILE" />
</root>
</springProfile>
</configuration>
关键配置解读:
|
配置项
|
作用
|
| --- | --- |
|scan="true" scanPeriod="30s"|
每 30 秒扫描配置变更,支持不重启动态调级
|
|SizeAndTimeBasedRollingPolicy|
按日期 + 文件大小双重滚动,防止单文件过大
|
|AsyncAppender|
日志先入内存队列,独立线程异步写磁盘,避免 I/O 阻塞业务线程
|
|neverBlock="true"|
队列满时直接丢弃日志,宁丢日志也不能阻塞交易
|
|
ERROR 独立文件
|
方便快速定位错误,不用在海量 INFO 中翻找
|
|
JSON_FILE
|
结构化输出,对接 ELK / Grafana Loki
|
6.2 异步日志原理
同步日志写入路径:
业务线程 → 格式化 → 写磁盘 → 返回(业务线程等 I/O 完成)
异步日志写入路径:
业务线程 → 放入内存队列 → 立即返回
↓
异步线程 → 从队列取出 → 写磁盘
核心收益:业务线程不再等待磁盘 I/O。在日志量爆发或磁盘抖动时,同步日志可以直接拖慢接口响应时间。
如果对异步性能有极致要求(每秒数十万条日志),可以考虑 Log4j2 + LMAX Disruptor 的异步 Logger,性能比 Logback 的
AsyncAppender高一个数量级。
6.3 结构化日志(JSON 格式)
对接 ELK / Loki 时,JSON 比纯文本更易解析和检索。
依赖:
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>8.0</version>
</dependency>
输出效果:
{
"@timestamp": "2025-07-16T02:07:33.456+08:00",
"level": "ERROR",
"logger_name": "com.example.shop.service.OrderService",
"thread_name": "http-nio-8080-exec-3",
"message": "支付回调处理失败, orderId=2025071600382, channel=alipay",
"traceId": "a1b2c3d4e5f6",
"stack_trace": "java.lang.RuntimeException: 签名校验不通过\n\tat ...",
"service": "shop-service"
}
在 Kibana / Grafana 中可以直接按 traceId、orderId、level 精确检索。
6.4 基于 MDC 实现 TraceId 全链路追踪
MDC(Mapped Diagnostic Context)是 SLF4J 提供的线程级上下文容器,非常适合存放 TraceId。
Filter 注入 TraceId:
@Component
public class TraceFilter extends OncePerRequestFilter {
private static final String TRACE_ID = "traceId";
private static final String SPAN_ID = "spanId";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// 优先从上游请求头获取(跨服务传递)
String traceId = request.getHeader("X-Trace-Id");
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "");
}
String spanId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
MDC.put(TRACE_ID, traceId);
MDC.put(SPAN_ID, spanId);
response.setHeader("X-Trace-Id", traceId);
try {
chain.doFilter(request, response);
} finally {
// 必须清理!Tomcat 线程池会复用线程,不清理会导致 TraceId 串线
MDC.clear();
}
}
}
异步线程中传递 MDC:
// ❌ 直接提交 Runnable,MDC 上下文会丢失(新线程没有 MDC)
executorService.submit(() -> {
log.info("异步任务开始"); // traceId 为空
});
// ✅ 先捕获上下文,在新线程中恢复
Map<String, String> contextMap = MDC.getCopyOfContextMap();
executorService.submit(() -> {
if (contextMap != null) {
MDC.setContextMap(contextMap);
}
try {
log.info("异步任务开始"); // traceId 正确传递
} finally {
MDC.clear();
}
});
6.5 动态调级
方式一:Spring Boot Actuator(推荐)
management:
endpoints:
web:
exposure:
include: loggers
# 查看指定 Logger 的当前级别
curl http://localhost:8080/actuator/loggers/com.example.shop.service
# 动态调为 DEBUG(无需重启)
curl -X POST http://localhost:8080/actuator/loggers/com.example.shop.service \
-H 'Content-Type: application/json' \
-d '{"configuredLevel": "DEBUG"}'
# 排查完毕,调回 INFO
curl -X POST http://localhost:8080/actuator/loggers/com.example.shop.service \
-H 'Content-Type: application/json' \
-d '{"configuredLevel": "INFO"}'
方式二:Arthas(阿里开源)
java -jar arthas-boot.jar
logger --name com.example.shop.service --level DEBUG
七、总结
日志规范 Checklist
可直接复制到团队 Wiki 或项目 CONTRIBUTING.md:
|
|
检查项
|
级别
|
| --- | --- | --- |
|
1
|
禁止 System.out.println / System.err.println
|
🔴 必须
|
|
2
|
禁止 e.printStackTrace()
|
🔴 必须
|
|
3
|
Logger 声明为 private static final,推荐 @Slf4j
|
🔴 必须
|
|
4
|
使用 {} 占位符,禁止 + 拼接
|
🔴 必须
|
|
5
|
异常日志必须包含业务上下文(orderId、userId 等)
|
🔴 必须
|
|
6
|
异常对象必须作为 log.error() 最后一个参数
|
🔴 必须
|
|
7
|
catch 块中必须打日志或重新抛出,禁止空 catch
|
🔴 必须
|
|
8
|
日志中禁止明文出现密码、完整手机号、身份证号、银行卡号
|
🔴 必须
|
|
9
|
ERROR 只用于需要人介入的系统故障
|
🟡 强烈建议
|
|
10
|
INFO 日志量应控制在可通读范围
|
🟡 强烈建议
|
|
11
|
循环体内用 DEBUG,循环结束后 INFO 打汇总
|
🟡 强烈建议
|
|
12
|
昂贵参数用 isDebugEnabled() 或 Lambda 守卫
|
🟡 强烈建议
|
|
13
|
生产环境使用异步 Appender
|
🟡 强烈建议
|
|
14
|
日志消息应自解释,三个月后仍能看懂
|
🟡 强烈建议
|
|
15
|
关键路径(状态变更、有副作用的操作、资金相关)必须有日志
|
🟡 强烈建议
|
|
16
|
禁止"进入xx方法/退出xx方法"式纯位置标记日志
|
🟡 强烈建议
|
|
17
|
异步线程中通过 MDC 传递 TraceId
|
🟢 建议
|
|
18
|
对接日志平台时使用 JSON 结构化日志
|
🟢 建议
|
好的日志习惯不会让代码跑得更快,但会在系统出问题时,让你比别人快十倍找到根因。
从今天开始,拿这份 Checklist 去 review 你项目里的日志,改掉一个是一个。你的下一个值班夜会感谢现在的你。
如果这篇文章对你有帮助,欢迎 点赞 + 收藏 + 转发,让更多同行少踩日志的坑。
关注微信公众号「技海拾贝」 ,持续获取 Java 后端实战干货。


浙公网安备 33010602011771号