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日志图片1

二、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 开箱即用,零配置即可运行)。

image

三、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();

注意: 如果参数本身就是简单的 Stringintlong,占位符天然就是惰性的,无需额外守卫。只有参数涉及方法调用或复杂计算时才需要。

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. 1. 这是不是状态变更? (订单创建、支付完成、退款发起)→ 必须打 INFO

  2. 2. 这是不是有副作用的操作? (扣库存、锁优惠券、发消息)→ 必须打 INFO

  3. 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 中可以直接按 traceIdorderIdlevel 精确检索。

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 后端实战干货。

25a96b0e4dcb41d9a616050c4968516b

posted @ 2026-08-17 16:57  ccm03  阅读(6)  评论(0)    收藏  举报