第 49 章:日志体系
学习目标
- 理解 SLF4J + Logback 的关系
- 掌握 logback-spring.xml 完整配置
- 学会 MDC 链路追踪
- 掌握日志规范与脱敏
一、日志框架关系
// ✅ 代码里只用 SLF4J 接口,换实现不用改代码
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger log = LoggerFactory.getLogger(UserService.class);
// ✅ Lombok 简化
@Slf4j
public class UserService {
public void doWork() {
log.info("处理中");
}
}⚠️ 绝不要直接依赖 Logback 的 API(如
ch.qos.logback.*)。那样就绑死了实现,失去了门面模式的意义。
二、日志级别
TRACE < DEBUG < INFO < WARN < ERROR| 级别 | 用途 | 生产环境 |
|---|---|---|
TRACE | 极详细的追踪 | 关闭 |
DEBUG | 调试信息、SQL、参数 | 关闭 |
INFO | 关键业务节点(下单成功、支付回调) | 开启 |
WARN | 可恢复的异常、降级、重试 | 开启 |
ERROR | 需要人工介入的错误 | 开启 |
判断标准:
ERROR意味着「需要有人半夜爬起来处理」。用户输入错误、业务校验失败一律用WARN或INFO,否则告警会被淹没。
三、完整的 logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- ① 读取 application.yml 中的配置 -->
<springProperty scope="context" name="APP_NAME"
source="spring.application.name" defaultValue="app"/>
<springProperty scope="context" name="LOG_PATH"
source="logging.file.path" defaultValue="logs"/>
<!-- ② 日志格式:含 traceId 便于链路追踪 -->
<property name="CONSOLE_PATTERN"
value="%d{HH:mm:ss.SSS} %highlight(%-5level) [%thread] %cyan(%logger{36}) [%X{traceId}] - %msg%n"/>
<property name="FILE_PATTERN"
value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{50} [%X{traceId}] - %msg%n"/>
<!-- ③ 控制台输出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- ④ 全量日志文件:按天 + 按大小滚动 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${APP_NAME}-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize> <!-- 单文件最大 -->
<maxHistory>30</maxHistory> <!-- 保留 30 天 -->
<totalSizeCap>10GB</totalSizeCap> <!-- 总大小上限,防打满磁盘 -->
</rollingPolicy>
<encoder>
<pattern>${FILE_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- ⑤ 错误日志单独一个文件:便于快速定位问题 -->
<appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}-error.log</file>
<filter class="ch.qos.logback.classic.filter.LevelFilter">
<level>ERROR</level>
<onMatch>ACCEPT</onMatch>
<onMismatch>DENY</onMismatch> <!-- 只要 ERROR -->
</filter>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_PATH}/${APP_NAME}-error-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
<maxHistory>90</maxHistory> <!-- 错误日志保留更久 -->
</rollingPolicy>
<encoder>
<pattern>${FILE_PATTERN}</pattern>
<charset>UTF-8</charset>
</encoder>
</appender>
<!-- ⑥ 异步写入:日志不阻塞业务线程 -->
<appender name="ASYNC_FILE" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>2048</queueSize> <!-- 队列大小 -->
<discardingThreshold>0</discardingThreshold> <!-- 0 = 队列满也不丢日志 -->
<neverBlock>false</neverBlock> <!-- false = 队列满时阻塞(不丢日志) -->
<includeCallerData>false</includeCallerData> <!-- ⚠️ true 会严重影响性能 -->
<appender-ref ref="FILE"/>
</appender>
<!-- ⑦ 分环境配置 -->
<springProfile name="dev">
<logger name="com.taskflow" level="DEBUG"/>
<logger name="com.taskflow.mapper" level="DEBUG"/> <!-- 打 SQL -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<springProfile name="prod">
<logger name="com.taskflow" level="INFO"/>
<root level="WARN">
<appender-ref ref="ASYNC_FILE"/>
<appender-ref ref="ERROR_FILE"/>
</root>
</springProfile>
</configuration>⑥ 为什么必须异步? 同步写日志是磁盘 IO,一次约 0.1~1ms。一个接口打 10 条日志就是 10ms。QPS 高时日志会成为瓶颈。异步后业务线程只是往内存队列丢一下,微秒级。
⑥
discardingThreshold=0很关键。默认值是queueSize/5,意思是队列剩余不足 20% 时丢弃所有 INFO 及以下的日志。生产排查问题时发现日志断了却找不到原因,往往就是这个。
四、MDC 链路追踪
问题:并发请求下,日志是交错的,无法分辨哪几条属于同一个请求。
10:00:01 INFO 开始查询用户
10:00:01 INFO 开始创建订单 ← 这是另一个请求的
10:00:02 INFO 用户查询完成解决:给每个请求分配唯一 traceId。
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TraceIdFilter extends OncePerRequestFilter {
public static final String TRACE_ID = "traceId";
private static final String HEADER = "X-Trace-Id";
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
try {
// ① 优先用上游传来的 traceId(微服务链路打通的关键)
String traceId = request.getHeader(HEADER);
if (!StringUtils.hasText(traceId)) {
traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
MDC.put(TRACE_ID, traceId); // ② 放入 MDC
response.setHeader(HEADER, traceId); // ③ 返回给前端,便于报障时提供
chain.doFilter(request, response);
} finally {
MDC.clear(); // ④ 必须清理!
}
}
}④ 为什么必须
MDC.clear()? MDC 底层是ThreadLocal。Tomcat 的线程是复用的,不清理的话下一个请求会继承上一个请求的 traceId,日志就串了。
效果:
10:00:01 INFO [a1b2c3d4] 开始查询用户
10:00:01 INFO [e5f6g7h8] 开始创建订单
10:00:02 INFO [a1b2c3d4] 用户查询完成现在 grep a1b2c3d4 app.log 就能拿到这个请求的完整日志。
异步线程中传递 MDC
// ❌ 子线程拿不到 MDC(ThreadLocal 不跨线程)
pool.execute(() -> log.info("异步任务")); // traceId 为空
// ✅ 手动传递
Map<String, String> context = MDC.getCopyOfContextMap();
pool.execute(() -> {
if (context != null) MDC.setContextMap(context);
try {
log.info("异步任务");
} finally {
MDC.clear();
}
});
// ✅ 更优雅:装饰线程池
public class MdcTaskDecorator implements TaskDecorator {
@Override
public Runnable decorate(Runnable runnable) {
Map<String, String> context = MDC.getCopyOfContextMap();
return () -> {
try {
if (context != null) MDC.setContextMap(context);
runnable.run();
} finally {
MDC.clear();
}
};
}
}
// 注册到线程池
executor.setTaskDecorator(new MdcTaskDecorator());五、日志编写规范
// ① 用占位符,不要字符串拼接
log.info("用户 {} 下单成功,订单号 {}", userId, orderNo); // ✅
log.info("用户 " + userId + " 下单成功"); // ❌ 无论级别是否开启都会执行拼接
// ② 打异常必须传异常对象作为最后一个参数
log.error("订单创建失败, orderNo={}", orderNo, e); // ✅ 有完整堆栈
log.error("订单创建失败: " + e.getMessage()); // ❌ 没堆栈,等于没打
// ③ 不要用 e.printStackTrace()
e.printStackTrace(); // ❌ 输出到 stderr,不进日志文件
// ④ 大对象日志用 isDebugEnabled 包一层
if (log.isDebugEnabled()) {
log.debug("完整数据: {}", JsonUtils.toJson(hugeObject)); // ✅ 避免无谓的序列化
}
// ⑤ 循环内不要打日志
for (Order order : orders) {
log.info("处理订单 {}", order.getId()); // ❌ 1 万条订单刷 1 万行
}
log.info("批量处理 {} 条订单完成,失败 {} 条", total, failed); // ✅ 聚合后打一条
// ⑥ 日志要有上下文,能定位问题
log.error("查询失败"); // ❌ 什么都查不到
log.error("查询用户失败, userId={}, deptId={}", userId, deptId, e); // ✅六、敏感信息脱敏
public final class MaskUtils {
/** 手机号:138****8888 */
public static String phone(String phone) {
if (phone == null || phone.length() != 11) return phone;
return phone.substring(0, 3) + "****" + phone.substring(7);
}
/** 身份证:110***********1234 */
public static String idCard(String id) {
if (id == null || id.length() < 8) return id;
return id.substring(0, 3) + "*".repeat(id.length() - 7) + id.substring(id.length() - 4);
}
/** 银行卡:**** **** **** 1234 */
public static String bankCard(String card) {
if (card == null || card.length() < 4) return card;
return "**** **** **** " + card.substring(card.length() - 4);
}
private MaskUtils() { }
}自动脱敏(Jackson 序列化时):
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@JacksonAnnotationsInside
@JsonSerialize(using = MaskSerializer.class)
public @interface Mask {
MaskType value();
}
@Data
public class UserVO {
@Mask(MaskType.PHONE)
private String phone; // 序列化时自动变成 138****8888
}法规要求:《个人信息保护法》《GDPR》都要求日志中不得留存明文个人敏感信息。这不是「最好做」而是「必须做」。
绝不能进日志的:密码、身份证号、银行卡号、CVV、Token、API Secret、完整手机号、家庭住址。
七、日志采集与查询
输出 JSON 格式便于采集:
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<version>7.4</version>
</dependency><appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_PATH}/${APP_NAME}.json</file>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<customFields>{"app":"${APP_NAME}","env":"prod"}</customFields>
</encoder>
</appender>| 方案 | 特点 |
|---|---|
| ELK | Elasticsearch + Logstash + Kibana,功能全但吃资源 |
| EFK | 用 Fluentd 替代 Logstash,更轻量 |
| Loki + Grafana | Grafana 出品,只索引标签不索引全文,成本低很多 |
| 云日志服务 | 阿里云 SLS / AWS CloudWatch,省运维 |
中小团队推荐 Loki:ELK 存 1TB 日志需要的机器成本可能是 Loki 的 5-10 倍。
八、本章小结
| 要点 | 关键 |
|---|---|
| SLF4J | 只依赖门面,用 @Slf4j |
| ERROR | 只用于「需要人工介入」的错误 |
| 异步 | AsyncAppender + discardingThreshold=0 |
| 滚动 | 按天+大小,设 totalSizeCap 防打满磁盘 |
| 错误日志 | 单独文件,保留更久 |
| MDC | traceId 串联请求,必须 clear |
| 占位符 | log.info("{}", x),不要拼接 |
| 异常 | 最后一个参数传异常对象 |
| 脱敏 | 法规要求,不是可选项 |
动手练习
练习 1:基础题
配置 logback-spring.xml,实现:控制台彩色输出、文件按天滚动保留 7 天、ERROR 单独文件。
练习 2:进阶题
实现 TraceIdFilter,并写一个装饰过的线程池,验证异步任务中 traceId 能正确传递。
下一章:第 50 章:统一响应封装 →