日志打印最佳实践

#java #最佳实践 #日志

总结
  • 日志要包含业务 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、无异常信息
        }
    }
}

三个硬伤:

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 → 日志丢失 → 关键线索消失。

几个原则:

<!-- 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。