本节目标:把图书服务的日志从「配好了」推进到「用对了」——记该记的、护住敏感的、写对异常、控制开销,并落成一份团队可执行的规范。
适用版本: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 单元测试 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。