我到现在还记得那次事故,一个日请求量几百万的订单查询服务,突然在一分钟内接口 P99 从 80ms 飙到 4000ms,错误率直接破了 8%。技术团队的第一反应是查数据库、查网络、查中间件,折腾了快一个小时才发现真凶居然是自己项目里的 Log4j 1.x 锁竞争。那次之后我把日志从 Log4j 1.x 迁移到了 Log4j 2.x,用上了异步写入,整个系统的吞吐和延迟表现完全不一样。这篇文章就记录一下完整的排查过程、锁竞争的根本原因,以及迁移到 Log4j 2.x 异步写入的实战细节,包括配置、压测数据和那些不跑一遍根本发现不了的坑。
1. 事故现场与定位过程:Thread Dump 里 40 个线程全部 Blocked
1.1 接口变慢后的第一波排查
先说时间线。下午 14:02,监控大屏弹出告警:订单查询接口平均响应时间从 80ms 涨到 4s,错误率从 0.1% 升到 8%。因为之前遇到过几次数据库抖动导致的慢查询,团队第一反应就是查 DB。慢查询日志拉出来看了一遍,没有新增的明显慢 SQL;数据库连接池监控也正常,活跃连接数没有打满;再看了网络层,TCP 重传率没有异常,负载均衡和后端节点的带宽也没到瓶颈。
这时候 CPU 倒是有点奇怪,整体使用率在 72% 左右,不算低,但也没有把机器跑满。GC 日志看了一眼,Full GC 次数略多但不至于引起这么大的响应时间抖动。内存使用正常,没有频繁的 Young GC 或者堆溢出征兆。
到这里基础资源层面基本排除了。接下来把目光放到应用本身的线程池上。订单查询服务用的是 Dubbo 和 Spring MVC 两层入口,我连上去看了线程池活跃数,发现 Dubbo 业务线程池 200 个线程几乎全部处于活跃状态,但 CPU 又不是 100%,说明这些线程大概率不是在拼命运算,而是在等什么。
等了什么?最直接的办法就是抓 Thread Dump。我连续抓了两份,间隔 10 秒,对比后锁定了问题。
1.2 Thread Dump 抓到锁竞争证据
两份 Thread Dump 里都有一个极其统一的特征:大量业务线程卡在了 org.apache.log4j.Category.callAppenders 上,线程状态是 BLOCKED (on object monitor)。典型栈如下:
java复制"dubbo-thread-12" #67 daemon prio=5 os_prio=0 tid=0x00007f1b0c015800 nid=0x1f runnable [0x00007f1a9ffd9000]
java.lang.Thread.State: BLOCKED (on object monitor)
at org.apache.log4j.Category.callAppenders(Category.java:98)
- waiting to lock <0x00000000f2a7d9f8> (a org.apache.log4j.spi.RootLogger)
at org.apache.log4j.Category.log(Category.java:856)
at org.apache.log4j.Category.info(Category.java:968)
at com.xxx.order.service.OrderQueryService.queryOrder(OrderQueryService.java:126)
注意那一行 waiting to lock <0x00000000f2a7d9f8> (a org.apache.log4j.spi.RootLogger),这就是锁竞争的直接证据。也就是说,几十上百个业务线程都在等同一把锁——RootLogger 上的对象监视器锁。谁持有这把锁呢?从栈里能看到另一些线程,它们已经拿到了锁,正在执行写文件相关的操作,同样是在 callAppenders 链路里,不过走到了 FileAppender.append 或 WriterAppender.write 这样的方法,正卡在文件 IO 上。
这就能解释为什么 CPU 不到 100% 但线程全部活跃了:大家不是在计算,而是在排队。
我再顺手做了一次采样统计,两份 Thread Dump 里 BLOCKED 状态的业务线程数量分别是 42 个和 38 个,占比超过 60%。这个比例已经足以造成业务线程池喂满。后端的排队请求越来越多,调用方等不到响应就开始超时重试,重试流量又继续进来,形成恶性循环。
1.3 还原"血案"完整的因果链条
锁竞争只是直接原因,真正引爆问题的其实是日志量激增。我翻了一下最近一次发布记录,上线前三天一个同事为了排查一个偶发空指针,在订单查询链路里临时加了一段 DEBUG 日志,打印了完整的订单对象序列化结果,一共几十个字段。这个日志在测试环境没暴露问题,但到了线上,订单查询服务本身 QPS 就有几千,每个请求进来自动补全一次订单详情,高峰期每分钟产生的日志行数从原本的几万行暴涨到几百万行。
日志量一上来,Log4j 1.x 的全局锁就成了瓶颈。所有线程写日志都要抢同一把锁,抢到锁之后还要执行格式化、写文件、刷盘等一系列操作,持锁时间被拉长,排队线程越积越多。最终线程池被日志堵死,业务逻辑根本没机会执行。
完整链条是:
text复制日志量激增 -> 全局锁竞争加剧 -> 每个日志写入都被串行化 -> 业务线程大规模阻塞 -> 线程池打满 -> 调用方超时重试 -> 流量继续涌入 -> 服务雪崩
后来我们临时把那段 DEBUG 日志注释掉,服务在几分钟内就恢复了。但这件事暴露出的问题没这么简单——底层的 Log4j 1.x 同步日志模型本身就扛不住高并发场景,这次是日志量触发的,下次可能换个入口还会炸。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Log4j 1.x 锁竞争的本质:一次日志写入里发生了什么
2.1 Category 和 Appender 的双重同步
如果把 Log4j 1.x 的日志写入链路拆开看,你会发现它几乎是处处加锁。首先是 Category.callAppenders() 这个方法,它本身就是一个 synchronized 方法:
java复制public v
