日志打印最佳实践
总结
- 日志要包含业务 ID(orderId、userId),异常要带完整堆栈,不能只打一句话
- 用 SLF4J 占位符
{},不要用字符串拼接,避免无效的性能开销 - 用 MDC 注入 trace_id,让一次请求的所有日志可以串联查询
- 异常转换时必须传递原始 cause,不能丢失根因
- 不要打印复杂对象,高频路径考虑采样,防止日志本身成为故障源
1. 典型的烂日志长什么样
线上偶发 Bug,用户反馈操作失败,打开日志只看到一句:
order process error!
不知道是哪个用户、哪笔订单、什么异常、哪行代码。这种日志等于没打。
问题代码:
@Service
public class OrderService {
public void processOrder(OrderDTO order) {
try {
// 业务逻辑
} catch (Exception e) {
log.error("OrderService#order process error!"); // 三个问题:无堆栈、无业务ID、无异常信息
}
}
}
三个硬伤:
- 没有传
e,堆栈全丢 - 没有 orderId、userId 等业务标识
- 不知道是 NPE、超时还是其他异常
2. 基础规范
用占位符,不要字符串拼接
// 不推荐:即使日志级别关闭,字符串拼接仍然执行
log.info("处理订单,orderId=" + orderId + ", userId=" + userId);
// 推荐:只有真正输出时才拼接
log.info("处理订单,orderId: {}, userId: {}", orderId, userId);
异常必须传给 logger
// 不推荐:堆栈丢失,只有一句错误描述
log.error("创建订单失败: {}", e.getMessage());
// 推荐:完整堆栈 + 业务上下文
log.error("创建订单失败,orderId: {}", order.getId(), e);
SLF4J 约定:最后一个参数如果是 Throwable,会自动打印完整堆栈,不需要额外占位符。
异常转换不能丢 cause
// 不推荐:根因丢失,上层只看到 BizException,不知道底层是什么错
catch (Exception e) {
throw new BizException("创建订单失败");
}
// 推荐:把原始异常作为 cause 传递
catch (Exception e) {
log.error("创建订单核心逻辑异常,orderId: {}", order.getId(), e);
throw new BizException("创建订单失败", e);
}
3. 用 MDC 注入 trace_id
MDC(Mapped Diagnostic Context)基于 ThreadLocal,可以在请求入口注入一个 trace_id,之后这个线程打的所有日志都会自动带上它,不需要每行手动传。
拦截器注入:
public class TraceInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
// 优先从请求头取,支持跨服务透传
String traceId = Optional.ofNullable(request.getHeader("X-Trace-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("trace_id", traceId);
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) {
MDC.clear(); // 请求结束必须清理,防止线程池复用时污染
}
}
logback.xml 配置输出 trace_id:
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level [%X{trace_id}] %logger{36} - %msg%n</pattern>
有了 trace_id,一次用户请求在多个微服务间的所有日志都能一键拉出来,排查效率天壤之别。
4. 结构化日志
纯文本日志难以做聚合查询,输出 JSON 格式可以直接被 ELK、SLS 等系统索引分析。
// 可以快速统计"某优惠券在某地区因库存不足失败的次数"
log.error("{\"event\":\"order_failed\", \"order_id\":\"{}\", \"user_id\":\"{}\", \"reason\":\"{}\"}",
orderId, userId, e.getMessage());
更推荐的方式是在 logback 配置 JSON encoder(如 logstash-logback-encoder),让所有日志自动输出为 JSON,业务代码不需要手动拼。
5. 防止日志成为故障源
日志本身也可能引发故障:Redis 超时 → 每次请求打一条 ERROR → 海量日志撑爆 Logstash → 日志丢失 → 关键线索消失。
几个原则:
- 只打关键 ID 和字段,不要直接
log.info("{}", complexObject)(会触发toString(),可能很慢甚至抛异常) - 高频路径(如每秒万次的心跳、健康检查)用采样,只记录 1% 的日志
- 配置合理的日志轮转策略(按时间或大小),避免单个日志文件无限膨胀
<!-- logback 按天轮转,保留 30 天 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
6. 日志级别选择
| 级别 | 使用场景 |
|---|---|
ERROR |
需要立即关注的故障,通常要触发告警 |
WARN |
潜在问题,系统还能运行但需要关注,如降级、重试 |
INFO |
关键业务节点,如订单创建、支付成功 |
DEBUG |
开发调试用,生产环境关闭 |
TRACE |
极细粒度追踪,一般只在本地用 |
生产环境根包设置 INFO,对需要详细日志的特定包单独设置 DEBUG,不要全局开 DEBUG。