第 08 章(节选) 日志第一现场

本篇来自《Go 程序员的 Java 修炼之路》第 08 章「第 08 章 排错方法论:从异常栈到根因(一套能复用的 SOP)」。 返回:第 08 章索引

08.3 日志:你的第一现场

异常栈是”系统主动告诉你的”,日志是”系统被动记录下来的”。在 Java 里,日志比异常栈更难搞对,因为它不是语言特性,是一坨靠约定和桥接器拼起来的生态。

Java 日志的三层混乱

Java 的日志世界有三个层次,这是理解一切的前提:

层次作用具体东西
门面(Facade)你代码里 import 的那个SLF4J、Commons Logging(JCL)、Log4j 2 API
实现(Implementation)真正往文件里写的那个Logback、Log4j 2 core、java.util.logging(JUL)
桥接器(Bridge)把一个门面的调用转发到另一个门面log4j-to-slf4j、jul-to-slf4j、jcl-over-slf4j、log4j-over-slf4j

为什么需要桥接器? 因为你的 classpath 上有 20 个第三方库,每个库诞生于不同的年代,各自用了不同的日志门面。你想让所有日志最终都走同一个实现(否则你要配五个配置文件、五个滚动策略),就得把其他门面的调用”劫持”过来。

桥接器的做法是:提供一个同名同包的假实现,把调用转发到你的目标门面。比如 jul-to-slf4j 提供一个 java.util.logging.Handler,把所有 JUL 的日志导到 SLF4J。

【思考】为什么 Java 会有这么多日志框架?为什么 Go 生态基本只有 log/slog 和 zap?

这不是一个”吐槽题”,它背后有一条关于标准库的真实教训。

参考答案

直接答案:因为 JDK 自带的日志(JUL)在发布时就不够好,而 Java 生态在它有替代品之前已经过了很多年。

时间线(这段历史很重要,它解释了全部的混乱):

时间事件
1996JDK 1.0,没有任何日志 API。大家用 System.out.println
1999Log4j 1.x 诞生(Ceki Gülcü),成为事实标准
2002JDK 1.4 引入 java.util.logging(JUL)—— 太晚了,且设计平庸
2002Commons Logging(JCL) 出现,试图统一门面,但运行时动态发现实现,导致 ClassLoader 地狱
2005Ceki 离开 Apache,做了 SLF4J(更好的门面,编译期静态绑定)+ Logback(原生实现 SLF4J,性能远超 Log4j 1.x)
2012Apache 重写出 Log4j 2(受 Logback 启发,性能更强,支持无 GC 模式),并自带 Log4j API 作为第三个门面
2014Log4j 2 加入 AsyncLogger(LMAX Disruptor)

于是形成了今天的格局:三个门面 + 三个实现 + 一堆桥接器。

为什么 JUL 不够好? 三个具体原因:

  1. 级别设计反人类:FINEST/FINER/FINE/CONFIG/INFO/WARNING/SEVERE 七个级别,跟社区已经习惯的 TRACE/DEBUG/INFO/WARN/ERROR 对不上
  2. 配置能力弱:.properties 配置,不支持变量替换,没有滚动策略的高级配置
  3. 性能一般:没有参数化日志的字符串优化
// JUL:字符串拼接,级别关闭时照样拼接
logger.log(Level.FINE, "order " + orderId + " paid");
 
// SLF4J:参数化,级别关闭时 args 直接被丢弃
log.debug("order {} paid", orderId);

Go 为什么没这个问题?

Go 出现得晚(2009),吸取了两件事的教训:

  1. 标准库从第一天就有 log(虽然是 Print/Printf/Fatal 三板斧,简陋,但存在)
  2. Go 1.21(2023)把 log/slog 收进标准库,提供了结构化日志的标准接口,第三方实现(zap、zerolog)只要实现 slog.Handler 就能无缝替换
// Go 的日志抽象:一个 Handler 接口,非常薄
type Handler interface {
    Enabled(context.Context, Level) bool
    Handle(context.Context, Record) error
    WithAttrs(attrs []Attr) Handler
    WithGroup(name string) Handler
}
 
// zap 通过 zapslog 提供 slog.Handler 实现,两边互通
logger := slog.New(zapslog.NewHandler(zapLogger.L().Core()))

关键差异是:Go 的标准库接口在”生态长出来之前”就定义了 Handler 这个接缝。 而 Java 的 JUL 出来时,Log4j 已经统治了三年,没人会为了迁就标准库而重写。

更深一层:一个”够用”的标准库能避免整个生态的分裂。

这是我要你从这个问题里真正带走的东西。

Java 的日志混乱,本质上不是”Java 社区喜欢造轮子”,而是标准库缺位了 6 年(1996→2002),然后补了一个不够好的。生态在这 6 年里自己长出了最佳实践,等标准库姗姗来迟时,它已经无法成为统一标准了 —— 只能退化成”众多选项中的一个”。

同样的故事在 Java 里还发生过一次:日期时间。JDK 1.0 的 java.util.Date 设计糟糕(可变、月份从 0 开始、时区混乱),于是社区做出了 Joda-Time,直到 Java 8(2014)才用 JSR-310 / java.time 把它收编进标准库 —— 而这花了 18 年。

Go 的教训是双向的:

  • 好的一面:Go 标准库覆盖面广(net/http、encoding/json、database/sql、context、sync),所以生态分裂少。你换一个 Web 框架,底下还是 net/http 的接口
  • 代价的一面:标准库一旦收编,演进就极慢(Go 兼容性承诺)。database/sql 十几年没大改,社区想改进只能在外面包一层(sqlx、sqlc、GORM);net/http 的路由能力直到 Go 1.22 才增强,之前大家只能用 gin/chi

所以真正的问题不是”要不要标准库”,而是”标准库应该在什么时候出现、以及它应该薄到什么程度”。

太早出现(Java 的 JUL):锁死了一个坏设计,生态绕着走。 太晚出现:生态已经分裂,标准库无法统一。 刚刚好(Go 的 slog/Handler):定义一个足够薄的接缝,让生态在接缝之上竞争,而不是各自造接口。

给你的实践启示:当你在自己的系统里设计一个”通用能力”时,优先定义接口(薄接缝),而不是直接提供实现。Java 的 SLF4J 就是这么赢的 —— 它自己不打日志,它只定义 Logger 接口,让 Logback 和 Log4j 2 在上面竞争。

日志 jar 地狱:日志不输出 / 重复输出

理论上桥接器很美好,实际上你会遇到两个经典症状:

症状一:日志完全不输出。

最常见的原因:classpath 上有多个 SLF4J 绑定(binding)。SLF4J 在启动时会打印一条警告,去日志最开头找:

SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:/app/lib/logback-classic-1.4.14.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:/app/lib/slf4j-log4j12-1.7.36.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation.

谁被选中是不确定的(取决于 classpath 顺序),所以你配的 logback.xml 可能根本没生效。

症状二:每条日志打印两遍。

原因:桥接器成环。比如你同时引入了 log4j-to-slf4j(Log4j API → SLF4J)和 slf4j-log4j12(SLF4J → Log4j 1.x),日志就在两边无限循环(现代版本会检测并截断,但会打两遍)。

排查方法(一条命令):

mvn dependency:tree | grep -iE "slf4j|log4j|logback|commons-logging|jul"

黄金法则(背下来):classpath 上应该有且只有一个绑定实现,且不能同时存在 X-to-slf4j 和 slf4j-to-X 这一对反向桥接器。

Spring Boot 项目里,用 spring-boot-starter-logging(默认引 Logback)就不要再引 slf4j-log4j12;要用 Log4j 2,就先 exclusion 掉 starter-logging 再引 spring-boot-starter-log4j2。

生产日志配置的核心原则

这节是重点,因为大部分 Java 生产事故的排查困难,根子在日志配置上,而不在代码上。

原则一:用异步日志,但要知道代价

同步日志意味着业务线程要等 write() 系统调用返回。在高并发下这会成为真正的瓶颈 —— 我见过火焰图里 Log4j2 吃掉 40% CPU 的案例。

Log4j 2 的两种异步:

<!-- 方式一:AsyncAppender —— 包装另一个 Appender,用 ArrayBlockingQueue,不需要额外依赖 -->
<AsyncAppender name="ASYNC" bufferSize="1024" blocking="true">
    <AppenderRef ref="RollingFile"/>
</AsyncAppender>
 
<!-- 方式二:AsyncLogger —— 用 LMAX Disruptor 的 RingBuffer,性能更高,需要 disruptor 依赖 -->
<!-- 全局开启:-Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector -->
<dependency>
    <groupId>com.lmax</groupId>
    <artifactId>disruptor</artifactId>
    <version>3.4.4</version>
</dependency>

两者的关键差别:AsyncAppender 用阻塞队列,吞吐量不随线程数增长;AsyncLogger 用 Disruptor(无锁 + 解决伪共享),吞吐量能随线程数扩展,且 2.6 之后支持无 GC 模式。高并发场景选 AsyncLogger。

Logback 的 AsyncAppender:

<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="FILE"/>
    <queueSize>2048</queueSize>              <!-- 默认 256,太小了 -->
    <discardingThreshold>0</discardingThreshold>  <!-- 0 = 不丢弃 INFO,默认会丢 -->
    <includeCallerData>false</includeCallerData>  <!-- 必须为 false,见下 -->
    <neverBlock>false</neverBlock>            <!-- 队列满时阻塞业务线程(保日志,牺牲 RT) -->
</appender>

【思考】异步日志的代价是什么?

提示:想想”异步”这个词在分布式系统里通常意味着什么取舍。以及:为什么 discardingThreshold 的默认值会让你在最需要日志的时候丢掉日志?

参考答案

直接答案:三个代价 —— 可能丢日志、顺序可能错乱、调用者信息(行号/方法名)默认拿不到。

代价一:宕机时丢最后几条日志。

日志事件在队列里,由后台线程慢慢写盘。如果 JVM 被 kill -9、被 OOM killer 干掉、或者容器被强制回收,队列里还没落盘的事件就永远没了。

这件事的恶毒之处在于:你最需要日志的时刻(崩溃前一秒)恰好是日志最容易丢的时刻。

<!-- Logback:设置关闭时最多等 5 秒刷完队列 -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <maxFlushTime>5000</maxFlushTime>
    <appender-ref ref="FILE"/>
</appender>

但注意:maxFlushTime 只在 LoggerContext 正常关闭时生效(kill -15 且应用有 shutdown hook)。kill -9 谁也救不了。所以优雅停机(graceful shutdown)不是锦上添花,是数据完整性的一部分 —— Spring Boot 默认 server.shutdown=immediate,要改成 graceful。

代价二:日志顺序可能错乱。

多线程并发入队,出队顺序不保证与”实际发生顺序”严格一致。通常无所谓(日志时间戳精度到毫秒,几十条错一点点能接受),但如果你靠日志顺序推断事件因果,就要小心。

代价三(最反直觉):discardingThreshold 会在最忙的时候丢掉 INFO。

Logback 的 AsyncAppender 默认行为:当队列剩余容量低于 20% 时,丢弃 TRACE / DEBUG / INFO 级别的日志,只保留 WARN 和 ERROR。

// Logback 源码逻辑(简化)
if (blockingQueue.remainingCapacity() < discardingThreshold && isDiscardable(event)) {
    return;   // 直接丢弃,不进队列
}
// isDiscardable: level <= INFO 的都算"可丢弃"

想清楚这意味着什么:队列快满 = 系统正处于高负载 / 出问题的时刻 = 你最需要 INFO 日志(比如”订单 123 进入支付流程”)的时刻。而恰恰在这个时刻,Logback 把这些日志全扔了,只留下 ERROR。

于是你拿到一份”只有 ERROR、没有任何上下文”的日志,完全无法回答”这个错误之前发生了什么”。这是”为了保命而牺牲证据”,在排查场景下基本是最坏的选择。

<!-- 生产建议:宁可阻塞业务线程,也不要丢 INFO -->
<discardingThreshold>0</discardingThreshold>   <!-- 0 = 关闭丢弃行为 -->
<queueSize>2048</queueSize>                    <!-- 同时把队列调大,降低真阻塞的概率 -->
<neverBlock>false</neverBlock>                 <!-- 万一真满了,宁可慢也不要丢 -->

Log4j 2 的对应配置是 -Dlog4j2.asyncQueueFullPolicy=discard + -Dlog4j2.discardThreshold=INFO,语义相同,同样建议生产环境不要用 discard 策略。

代价四(隐藏款):includeCallerData 是性能杀手。

异步日志的入队动作发生在业务线程上。如果你在 pattern 里写了 %L(行号)或 %M(方法名),Logback 为了拿到这些信息,需要构造一个 Throwable 并调用 getStackTrace() —— 这玩意儿要遍历整个栈,单次成本是日志写入本身的几十倍。

在异步模式下这个问题被放大:入队变慢 → 队列迅速积压 → 触发 discardingThreshold → 开始丢 INFO。你加了个行号,结果把上下文日志全弄丢了。

<!-- 正确:异步 Appender 里永远不要开 includeCallerData -->
<includeCallerData>false</includeCallerData>
 
<!-- 同步 Appender 里也别用 %L/%M -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>
<!--                                                                ^ 注意:没有 %L,没有 %M -->

更深一层:异步日志是一个”可靠性 vs 性能”的取舍,但大多数人只看到了性能那一侧。

本质问题是:日志的价值是不对称的。 那条日志在 99.99% 的情况下毫无价值(没人看),在 0.01% 的情况下价值连城(出事时的唯一线索)。所以你不能按”平均价值”来优化它 —— 你必须按尾部分布来设计。

按这个思路重新审视上面的配置决策:

  • discardingThreshold=0:宁可慢也要保住那 0.01% 的证据 → 正确
  • neverBlock=false:宁可阻塞也不要丢 → 正确
  • 不开 %L:为了 99.99% 的性能牺牲 0.01% 的便利 → 可接受(因为行号可以从类名 + 异常栈反推)

Go 的对照:zap 的设计哲学很不一样。它的 SugaredLogger(慢,方便)和 Logger(快,结构化)是显式分开的两个 API,你选哪个就承担哪个的代价,没有”自动降级”这种魔法。zap 没有异步队列,因为它的同步写入已经足够快(预分配 buffer + 零反射编码)。

这又是一个”反馈周期/成本结构决定设计”的例子:Java 的日志实现背负着”要兼容二十年前的 Appender 接口”的包袱,只能在外面套异步队列;Go 的 zap 从零设计,直接把”快”做进编码路径里。

原则二:用参数化日志,不要字符串拼接

// ❌ 错误:不管 DEBUG 开不开,字符串拼接都会执行,而且会调用 amount.toString()
log.debug("order " + orderId + " paid, amount=" + amount + ", channel=" + channel);
 
// ✅ 正确:只有 DEBUG 开启时才会做格式化和 toString()
log.debug("order {} paid, amount={}, channel={}", orderId, amount, channel);

为什么这么重要? 三个理由,第二个才是关键:

  1. 性能:级别关闭时几乎零成本(只是一次变长数组分配)
  2. toString() 不会被执行:这是很多线上事故的源头。如果 amount 是个懒加载的 ORM 代理对象,.toString() 可能触发一次数据库查询 —— 在一条 DEBUG 日志里触发 SQL,你敢信?
  3. 可读性:模板和参数分离,日志采样/聚合时能更好地识别”同一类日志”

异常一定要放最后一个参数,且不要占位符:

// ✅ 正确:SLF4J 识别最后一个 Throwable,自动打印完整栈
log.error("订单支付失败, orderId={}", orderId, e);
 
// ❌ 错误:e 被当成普通参数,只打印 toString(),没有栈
log.error("订单支付失败, orderId={}, error={}", orderId, e);

原则三:MDC —— 让每个请求带上 traceId

MDC(Mapped Diagnostic Context)本质是一个 ThreadLocal<Map<String, String>>。你往里放东西,日志 pattern 里用 %X{key} 取出来。

// 在入口(Filter / Interceptor / AOP)里塞进去
public class TraceIdFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) {
        String traceId = Optional.ofNullable(
                ((HttpServletRequest) req).getHeader("X-Trace-Id"))
                .orElse(UUID.randomUUID().toString().substring(0, 16));
        MDC.put("traceId", traceId);
        try {
            chain.doFilter(req, resp);
        } finally {
            MDC.remove("traceId");   // 必须 finally,否则线程池复用时串号
        }
    }
}
<pattern>%d{...} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>
<!--                                  ^^^^^^^^^^^^ 取 MDC 的值 -->

这一步做完,你的日志就从”一堆无关联的文本行”变成了”可以按请求聚合的事件流”。 grep 'abc123' app.log 就能拿到这个请求在这一台机器上的全部日志。这是分布式排查的地基。

但 MDC 有个致命缺陷 —— 它是 ThreadLocal 的,所以在线程池和异步调用里会丢。这个坑放到 08.6 专门讲。

原则四:日志级别要有明确定义

级别含义是否需要人处理生产是否开启
ERROR功能失败,需要人介入是,通常应该进告警开
WARN可疑但能自愈(重试成功、降级生效)否,但要定期看趋势开
INFO关键业务节点(下单、支付、状态变更)否开
DEBUG排查用的详细参数否默认关,出事时临时开
TRACE更细(框架内部)否关

最容易犯的错是把 WARN 当 ERROR 用。 一个可重试的操作第一次失败,应该打 WARN;重试三次都失败,才打 ERROR。如果每次重试都打 ERROR,你的告警会被噪声淹没 —— 然后某天真正的 ERROR 来了,没人看。

好日志 vs 坏日志

【思考】日志里该记什么?给你看到的这段代码打个分。

public Order pay(Long orderId) {
    log.info("进入 pay 方法");
    Order order = orderMapper.selectById(orderId);
    log.info("查询完成");
    try {
        PaymentResult r = paymentClient.pay(order.getAmount());
        log.info("支付结果:" + r);
        return orderMapper.updateStatus(orderId, r.isSuccess() ? 1 : 2) > 0 ? order : null;
    } catch (Exception e) {
        log.error("出错", e);
        e.printStackTrace();
        return null;
    }
}

这段代码有 7 个问题。先自己找出来,再看答案。

参考答案

这段代码基本是一份”错误示范大全”。逐个拆解:

问题 1:log.info("进入 pay 方法") —— 零信息量。

这条日志能回答什么问题?“这个函数被调用过”。但这个信息你从上一层的日志、从监控的 QPS 曲线都能得到,而且它没有参数,你不知道是哪个订单进来了。生产上纯属浪费磁盘。

同样的,log.info("查询完成") 也是 —— 除非你想用两条日志的时间差来测耗时(那也应该直接打耗时,见问题 6)。

问题 2:log.info("支付结果:" + r) —— 字符串拼接 + 没有业务主键。

应该用参数化,并且带上 orderId。否则你看到 支付结果:PaymentResult(success=false, ...) 时,根本不知道是哪个订单失败了。

问题 3:catch (Exception e) 捕获了太宽的范围。

paymentClient.pay() 可能抛的是网络超时(可重试),也可能是参数校验失败(不可重试)。你把它们一视同仁地处理成”返回 null”,把一个可恢复错误和一个业务错误混成了一件事。

问题 4:log.error("出错", e) 没有任何上下文。

这是最严重的一条。日志里会出现:

2026-09-02 14:23:11 ERROR c.e.o.OrderService - 出错
java.net.SocketTimeoutException: Read timed out
    ...

然后呢?哪个订单?金额多少?调用的是哪个支付渠道? 全都不知道。这条日志在排查时的价值约等于零 —— 你只知道”某个订单在某个时刻超时了”,然后只能去翻数据库。

问题 5:e.printStackTrace() —— 生产代码的红线。

printStackTrace() 输出到 System.err,不走日志框架。这意味着:不进文件(除非你重定向了 stdout)、没有时间戳、没有级别、没有 traceId、不受滚动策略管理、无法被日志采集 agent 抓取。而且它不是线程安全的,多线程下输出会交错成一团乱码。

永远不要用 e.printStackTrace()。

问题 6:吞掉异常后返回 null。

调用方拿到 null,然后下一个 NPE 在完全不相关的另一行抛出来。你把犯罪现场从”支付超时”搬到了”某个地方用了这个 null”,栈里再也没有任何关于支付的痕迹。这就是第 00 章讲的”静默失败的风险”。

问题 7:log.info 打在循环/高频路径上没有采样。

如果这个方法 QPS 是 5000,两条 INFO 日志 × 5000 = 每秒 1 万行,一天 8.6 亿行。磁盘和日志采集都会先崩。

改造后应该长这样:

public Order pay(Long orderId) {
    // 只在关键节点打,带业务主键,用参数化
    log.info("payment.start orderId={}", orderId);
    Order order = orderMapper.selectById(orderId);
    if (order == null) {
        // 参数错误是 WARN,不是 ERROR:这不是系统故障,是调用方用错了
        log.warn("payment.order_not_found orderId={}", orderId);
        throw new OrderNotFoundException(orderId);
    }
 
    long start = System.currentTimeMillis();
    try {
        PaymentResult r = paymentClient.pay(order.getAmount());
        long cost = System.currentTimeMillis() - start;
        // 带上耗时、结果、关键参数 —— 这一行能回答"这个请求发生了什么"
        log.info("payment.result orderId={} amount={} success={} code={} cost={}ms",
                 orderId, order.getAmount(), r.isSuccess(), r.getCode(), cost);
 
        int status = r.isSuccess() ? PAID : PAY_FAILED;
        orderMapper.updateStatus(orderId, status);
        return order;
    } catch (TimeoutException e) {
        // 区分可重试错误:WARN + 完整上下文 + 重新抛出(不要返回 null)
        log.warn("payment.timeout orderId={} amount={} cost={}ms",
                 orderId, order.getAmount(), System.currentTimeMillis() - start, e);
        throw new PaymentRetryableException("支付超时", e);
    } catch (Exception e) {
        // 真错误:ERROR + 上下文 + 保留 cause
        log.error("payment.failed orderId={} amount={}", orderId, order.getAmount(), e);
        throw new PaymentException("支付失败, orderId=" + orderId, e);
    }
}

一份”好日志”的检查清单:

检查项好的坏的
有 traceId[%X{traceId}] 在 pattern 里没有,跨服务串联不了
有业务主键orderId=12345订单处理完成
有关键参数amount=99.9 channel=ALIPAY什么都不带
有耗时cost=1283ms只有”完成”
能回答”发生了什么”payment.result orderId=1 success=false code=TIMEOUT支付结果:xxx
异常有上下文log.error("payment.failed orderId={}", id, e)log.error("出错", e)
级别准确重试成功打 WARN,真失败打 ERROR什么都打 ERROR
走了日志框架log.error(...)e.printStackTrace()

一条可以立刻用的经验法则:打日志时想象凌晨三点的自己正在看这行日志。他手里只有这一行文本,没有 IDE,没有源码,没有数据库权限(得申请)。这一行日志能告诉他该去查什么吗?

更深一层:日志的本质是”给未来的自己留一份现场报告”,而报告的质量取决于你当时是否想清楚了”未来会问什么问题”。

Go 程序员在这件事上通常比 Java 程序员做得好,因为 Go 的 error 不带栈,逼着你在每个 wrap 点手写上下文:

return fmt.Errorf("支付失败 orderID=%d amount=%.2f: %w", orderID, amount, err)

Java 的异常自带栈,于是产生了一种懒惰:“反正有栈,够了”。但栈只告诉你代码路径,不告诉你数据状态。而线上问题 90% 是”特定数据 + 特定代码路径”的组合 —— 栈只覆盖了后半句。

所以这条纪律值得写进你们团队的代码规范:异常栈负责回答”在哪出错”,日志参数负责回答”是什么数据触发的”,两者缺一不可。