《Spring Boot 入门》16.3 日志实践要点

日志的价值不在数量而在质量。本节讲清该记什么、不该记什么(密码、令牌、身份证、完整卡号绝不上日志,并给出脱敏示例),如何为事件选对级别,为什么用参数化写法记录异常才不会丢堆栈,占位符 {} 的延迟求值与 isDebugEnabled 的真正用途,异步日志的收益与丢日志风险,并附一份可直接落地的日志规范 checklist。

本节目标:把图书服务的日志从「配好了」推进到「用对了」——记该记的、护住敏感的、写对异常、控制开销,并落成一份团队可执行的规范。
适用版本:Spring Boot 4.1.x(Java 21)

16.3 日志实践要点

16.1 教会我们怎么配,16.2 教会我们怎么让日志结构化。但配得再好,如果记的是废话、泄了密钥、或者把异常堆栈弄丢,日志照样没用。本节讲的是判断力——什么该记、怎么记、记多少。

16.3.1 该记什么、不该记什么

一条日志只有两个用途:复盘已经发生的事,和还原出问题的现场。用这个标准筛一遍:

该记不该记
关键流程的进入与完成(下单、借书、支付)每个 getter/setter 的进出
外部调用的目标、耗时、结果码循环体里的逐条流水
状态变更(订单从待付款到已支付)完全正常的中间变量
异常与失败,含上下文已经由框架记过的重复异常
安全相关事件(登录失败、越权)密码、令牌、密钥、验证码

绝不记录任何敏感信息,这是红线。具体包括:

  • 密码、密码哈希、盐值;
  • 令牌(JWT、sessionId、API Key、refresh token);
  • 身份证号、护照号;
  • 银行卡号、CVV、有效期;
  • 完整手机号(视合规要求,通常至少中间四位打码)。

反面例子:

// 严重错误:密码和令牌直接进日志
log.info("用户登录请求: username={}, password={}", username, password);
log.debug("携带令牌: {}", authorizationHeader);

正确做法是只记标识、不记凭证,并在需要记录部分内容时脱敏:

package com.example.library.util;

public final class MaskUtil {

    private MaskUtil() {
    }

    /** 138****5678 */
    public static String phone(String value) {
        if (value == null || value.length() < 7) {
            return "***";
        }
        return value.substring(0, 3) + "****" + value.substring(value.length() - 4);
    }

    /** 6222 **** **** 1234 */
    public static String card(String value) {
        if (value == null || value.length() < 8) {
            return "****";
        }
        return value.substring(0, 4) + " **** **** " + value.substring(value.length() - 4);
    }

    /** 只保留首尾,中间打码 */
    public static String generic(String value) {
        if (value == null || value.length() <= 2) {
            return "***";
        }
        return value.charAt(0) + "***" + value.charAt(value.length() - 1);
    }
}
log.info("用户登录: username={}, phone={}", username, MaskUtil.phone(phone));
log.debug("调用支付网关: card={}, amount={}", MaskUtil.card(cardNo), amount);

更要紧的是别让日志泄露请求体。很多框架的默认行为会把整个请求体打进日志,一旦请求体里有密码字段就全完了。解决办法是给敏感 DTO 的 toString() 做脱敏,或在记录前显式过滤。一个务实的原则:日志里出现什么字段,是你在写代码时就要决定的,不是运行时才发现的。

16.3.2 日志级别怎么选

16.1 给过一句话原则,这里补上每个级别的判断标准:

级别判断标准举例
ERROR有人必须处理,否则功能受损或数据不一致支付回调失败、数据库连接耗尽、未捕获异常
WARN需要关注,但系统仍可运行重试成功、降级返回缓存、即将过期的配置
INFO记录「系统做了什么」,平时不看,出事必查服务启动、请求摘要、状态变更、定时任务完成
DEBUG只在排查特定问题时才需要方法入参、分支走向、SQL 参数
TRACE更细,通常交给框架逐帧协议解析

两个常见误区:

  • 把 INFO 当 DEBUG 用:一个循环里 log.info 打 10000 行,生产日志直接爆炸。凡是「每请求多条」的,基本都该降到 DEBUG。
  • 把 WARN 当 INFO 用:什么鸡毛蒜皮都 WARN,导致真正的告警阈值被淹没。WARN 应该稀缺——如果 WARN 天天刷屏,要么它该降级,要么它其实该被修掉。

还有一个团队级约定值得写进规范:生产根级别锁死 INFO,需要临时开 DEBUG 时,通过配置中心或 Actuator 的 loggers 端点动态调整,而不是改代码重新发布。

16.3.3 异常日志的正确写法

这是最高频的翻车点。看两种写法:

try {
    repository.save(book);
} catch (DataAccessException e) {
    log.error("保存图书失败: " + e);          // 错误写法
    log.error("保存图书失败: {}", e.getMessage()); // 仍然不好
    log.error("保存图书失败, id={}", book.getId(), e); // 正确写法
}

区别在 SLF4J 的最后一个参数如果是 Throwable,会被当作异常处理,输出完整堆栈:

  • log.error("msg" + e):字符串拼接调用的是 e.toString(),只得到 DataAccessException: xxx 一行,堆栈全丢。排查时你只知道「出错了」,不知道在哪一行。
  • log.error("msg: {}", e.getMessage()):同样只留消息,堆栈还是丢。
  • log.error("msg, id={}", book.getId(), e):占位符照常替换,末尾的 e 触发堆栈打印——这才是正确形态。

不要把异常吞掉。catch 里既不记日志也不重新抛出,是生产事故的头号帮凶:

try {
    doSomething();
} catch (Exception e) {
    // 空 catch:问题被无声吞没,排查时一无所获
}

如果确实要忽略,至少留一行 log.warn("忽略 xxx: {}", e.getMessage()) 并说明原因。

也不要「记了又抛」。下面这种会制造重复堆栈:

catch (IOException e) {
    log.error("读取失败", e);
    throw new ServiceException("读取失败", e); // 上层往往还会再记一次
}

正确策略是二选一:要么就地处理并记日志,要么往上抛、由全局异常处理器统一记录(呼应 10.2 的兜底 @ExceptionHandler)。本书的约定是:业务层只管抛,日志由最外层的全局处理器写一次。

16.3.4 日志性能:占位符与 isDebugEnabled

SLF4J 的 {} 不只是语法糖,它带来延迟求值:

log.debug("图书详情: {}", book.toDetailString());   // 好
log.debug("图书详情: " + book.toDetailString());     // 差
  • 第一种:级别不到 DEBUG 时,book.toDetailString() 根本不会被调用。
  • 第二种:无论级别如何,字符串拼接先执行,toDetailString() 白跑一趟,CPU 和内存都浪费。

所以能用占位符就用占位符,几乎不需要手写 isDebugEnabled 判断。

那什么时候才需要 isDebugEnabled?当参数构造本身很贵、又没法用占位符表达时:

if (log.isDebugEnabled()) {
    String snapshot = buildExpensiveSnapshot(order); // 遍历大量数据、序列化
    log.debug("订单快照: {}", snapshot);
}

判断标准很简单:占位符能解决的,就别加 if。加 if 只在「不判断就会付出显著代价」时才划算,滥用只会让代码变丑。

16.3.5 异步日志:收益与风险

日志写盘是 I/O,会阻塞业务线程。异步日志把日志事件丢进内存队列,由单独线程落盘,业务线程几乎零等待。

用 Logback 的 AsyncAppender 包一层即可:

<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE"/>
    <queueSize>1024</queueSize>
    <discardingThreshold>0</discardingThreshold>
    <neverBlock>false</neverBlock>
</appender>

<root level="INFO">
    <appender-ref ref="ASYNC"/>
</root>
参数含义建议
queueSize队列容量视吞吐定,默认 256 偏小,可设 1024 以上
discardingThreshold队列剩余量低于此值时丢弃低级别日志设 0 表示不丢弃(最安全)
neverBlock队列满时是否阻塞业务线程false(默认,宁可慢也不丢)

异步日志的代价是丢日志风险:进程被 kill -9、断电、容器被强制停止时,队列里还没落盘的事件会永久丢失。这正是它不适合承载审计类日志的原因——审计要求「记下来就一定能查到」,必须同步落盘。

选择建议:

  • 高吞吐的应用日志(INFO/DEBUG)→ 可以用异步,换取性能;
  • 审计、资金、安全事件 → 用同步,宁可慢一点也不能丢;
  • 排障关键期 → 临时切回同步,避免「最需要日志的时候它丢了」。

16.3.6 日志与链路追踪的关系

到目前为止,requestId 只能串联单个服务内部的日志。一旦请求跨过网关、经过图书服务、又调了库存服务,就需要链路追踪(tracing):为整个调用链生成一个 traceId,每一跳生成一个 spanId,所有服务的日志都带上这两个 ID,就能在追踪系统里还原完整的调用树。

它和日志的关系是互补:

维度日志链路追踪
粒度事件级,可任意详细调用级,记录耗时与拓扑
用途看「发生了什么」看「慢在哪一跳、谁调了谁」
串联靠 requestId 手动穿靠 traceId/spanId 自动传播

Spring Boot 4 提供了 spring-boot-starter-opentelemetry,配合 Micrometer Tracing 可以把 traceId、spanId 自动注入 MDC,与本节的结构化日志无缝衔接。这块内容属于可观测性专题,本书不展开;你现在只需要知道:日志里的 traceId 字段不是自己造的,是追踪框架塞进 MDC 的。

16.3.7 把图书服务的日志配置完整落地

把本章三节的东西合成图书服务的一份可提交配置。

application.yml 中的日志基线:

logging:
  level:
    root: INFO
    com.example.library: DEBUG
    org.hibernate.SQL: INFO
  file:
    name: logs/book-service.log
  logback:
    rollingpolicy:
      max-file-size: 10MB
      max-history: 30
      total-size-cap: 1GB
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n"

生产 profile 覆盖为 JSON 输出与更保守的级别:

logging:
  level:
    root: INFO
    com.example.library: INFO
  file:
    name: logs/book-service.log

配合 16.2 的 RequestIdFilter 与 logback-spring.xml,图书服务至此具备:

  • 请求级串联:每个请求一个 requestId,写进 MDC,贯穿到响应头;
  • 结构化输出:prod 环境 JSON,可直接被日志平台采集;
  • 文件与轮转:单文件 10MB、保留 30 天、总量封顶 1GB;
  • 级别可控:本地开 DEBUG,生产锁 INFO;
  • 脱敏:手机号、卡号经 MaskUtil 处理后入日志。

16.3.8 日志规范 checklist

把上面所有结论压成一份可以贴进团队 wiki 的清单:

#检查项合格标准
1敏感信息密码/令牌/身份证/完整卡号零出现;手机号等脱敏
2异常记录用 log.error("msg, x={}", x, e),末尾传 Throwable,不丢堆栈
3异常传播不空 catch;不「记了又抛」,由最外层统一记一次
4级别选择ERROR 要人处理、WARN 稀缺、INFO 记关键流程、DEBUG 排查时开
5字符串拼接一律用 {} 占位符;isDebugEnabled 只在构造昂贵时用
6上下文关键日志带 requestId;跨线程用 TaskDecorator 传递 MDC
7文件轮转设 max-file-size / max-history / total-size-cap,防磁盘打满
8生产级别根级别锁 INFO,临时调试用动态调整而非改代码
9审计日志审计/资金/安全事件用同步,不用异步 Appender
10配置文件名用 logback-spring.xml,不用 logback.xml

小结

  • 记日志的唯一标准是「能否用于复盘或还原现场」;敏感信息(密码、令牌、身份证、完整卡号)绝不上日志,必要时用 MaskUtil 脱敏。
  • 级别按「需不需要人行动」选:ERROR 必处理、WARN 要稀缺、INFO 记关键流程、DEBUG 排查时开;生产根级别锁 INFO。
  • 异常用 log.error("msg, x={}", x, e),末尾的 Throwable 才会输出堆栈;字符串拼接和 getMessage() 都会丢堆栈。
  • 不空 catch、不「记了又抛」;本书约定业务层只管抛,日志由最外层全局处理器写一次。
  • 用 {} 占位符实现延迟求值;isDebugEnabled 只在参数构造昂贵时才需要。
  • 异步日志(AsyncAppender)提升吞吐但有丢日志风险,适合应用日志、不适合审计日志。
  • requestId 串联单服务,traceId/spanId 串联跨服务;4.x 用 spring-boot-starter-opentelemetry 自动注入 MDC。
  • 收尾的 10 条 checklist 可直接作为团队日志规范。

到这里,日志这一章收工:图书服务有了生产可用的日志体系。下一章我们换一个角度验证它——不再靠肉眼看日志,而是用自动化测试把「它应该这样运行」固化下来,从单元测试一路写到集成测试。

阅读导航:上一节:16.2 结构化日志 · 下一节:17.1 单元测试 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计