双 11 预演:系统吞吐量跌至 0!一次由 Log4j 锁竞争引发的线程“集体猝死”

写在前面:

距离双 11 还有不到两周,全链路压测正在紧张进行。

就在流量攀升到平时峰值 5 倍的那个瞬间,没有任何报错,没有任何异常抛出,我们的核心交易系统 TPS 直接归零

就像是有人拔掉了服务器的电源,但进程却还活着。

排查结果让人大跌眼镜:搞垮整个集群的,不是数据库慢 SQL,也不是 Redis 击穿,而是为了排查问题而留下的那几行 log.info()


01. 诡异的"心电图拉直"

晚上 22:00,第三轮全链路压测启动。

流量通过网关涌入,监控大盘上的曲线漂亮地上扬。所有人都盯着数据库连接池和 CPU 负载。

22:15 ,流量达到预设顶峰。

突然,业务监控大屏上的"订单创建量"瞬间跌至 0。

不是下降,是垂直归零

我立刻切到服务器监控:

  • CPU: 从 60% 骤降到 1%(意味着线程不干活了)。
  • Load Average: 飙升到 100+(意味着大量线程在排队等待)。
  • Memory: 平稳。
  • GC: 无 Full GC。

"服务僵死了。" 这是我的第一反应。应用进程还在,端口也是通的,但就是不处理任何请求。

![Image: 监控大盘截图。上半部分是 QPS 曲线,在最高点突然垂直掉到底部变成直线;下半部分是 System Load 曲线,与 QPS 相反,瞬间飙升并爆表]
(图1:这哪里是压测,简直是断电)


02. 现场取证:jstack 里的"停尸房"

既然进程还活着,别瞎猜,直接 dump 线程栈。

jstack -l <pid> > thread_dump.log

打开文件的一刹那,我倒吸一口凉气。

整个文件里有 200 个 Dubbo 业务线程,状态全都是 BLOCKED

它们整整齐齐地排在同一个对象监视器(Monitor)后面,像极了早高峰堵死的立交桥。

凶手显形:

java 复制代码
"DubboServerHandler-192.168.1.100-20880-thread-150" #150 ... state: BLOCKED
    - waiting to lock <0x0000000780a000b0> (a org.apache.log4j.spi.RootLogger)
    at org.apache.log4j.Category.callAppenders(Category.java:204)
    at org.apache.log4j.Category.forcedLog(Category.java:391)
    at org.apache.log4j.Category.info(Category.java:666)
    at com.company.order.service.OrderServiceImpl.createOrder(OrderServiceImpl.java:88)

看到 org.apache.log4j.Category.callAppenders,我瞬间明白了。


03. 根因剖析:该死的 synchronized

这是一个老系统,用的是古老的 Log4j 1.x(注意,不是 Log4j2)。

在 Log4j 1.x 的源码设计中,为了保证日志写入的线程安全,RootLogger 在分发日志事件(Event)时,用了一把重量级的同步锁

java 复制代码
// Log4j 1.x 源码片段
public void callAppenders(LoggingEvent event) {
    int writes = 0;
    // 灾难之源:同步锁
    synchronized(this) { 
        for(Category c = this; c != null; c = c.parent) {
            // ... 循环调用 Appender 写入磁盘
        }
    }
}

为什么平时没事,压测就炸了?

  1. IO 瓶颈: 压测时日志量激增,磁盘 IO 写入变慢(尤其是云盘 IOPS 被打满时)。
  2. 串行化: 由于 synchronized(this) 的存在,所有试图打印日志的业务线程,必须排队
  3. 多米诺骨牌:
    • 线程 A 抢到锁,开始写日志,但磁盘慢,写了 10ms。
    • 这 10ms 内,线程 B、C、D... 到线程 Z 全部堵在 synchronized 外面。
    • 线程 A 释放锁,线程 B 抢到,又卡了 10ms。
    • 随着请求源源不断进来,线程池瞬间被占满,所有线程都在等这把"日志锁"。

结果: 整个系统因为一句 log.info,退化成了单线程运行,吞吐量自然跌至 0。

![Image: 线程阻塞示意图。左边是宽阔的"业务高速公路",右边是一个标着 "Log4j Lock" 的单人独木桥。几百辆车(线程)挤在桥头动弹不得]
(图2:所谓的"高性能并发",最后都死在了"串行写日志"上)


04. 极速止血与彻底根治

黄金 5 分钟止血

在压测现场,改代码重发版来不及了。我们利用配置中心(Apollo/Nacos)动态调整了日志级别:

rootLogger.levelINFO 调整为 ERROR

配置下发的瞬间,锁竞争消失,QPS 像火箭一样直接窜回了 5 万。

彻底根治方案

复盘会上,我们确定了两个改造方向:

方案一:使用 AsyncAppender(异步日志)

不要让业务线程傻傻地等磁盘 IO。用一个队列(BlockingQueue)做缓冲,业务线程把日志扔进队列就走人,由独立的 Worker 线程慢慢写磁盘。

  • 风险: 队列满了怎么处理?是丢弃还是阻塞?(推荐丢弃,保业务)

方案二:升级 Log4j2(推荐)

Log4j 1.x 已经是时代的眼泪了。Log4j2 引入了 LMAX Disruptor(无锁环形队列)技术,在多线程竞争下的性能是 1.x 的几百倍。

![Image: 性能对比柱状图。Log4j 1.x 的柱子很短,Log4j2 (Async) 的柱子高出天际,标注着 "Throughput x18"]
(图3:Log4j2 的无锁设计简直是降维打击)


05. 资深专家的"避坑指南"

这次事故损失的是 200 万(虚拟)压测数据,但如果发生在双 11 当天,损失的就是真金白银。

关于日志,我有三条铁律:

  1. 生产环境禁止使用 ConsoleAppender

    输出到控制台(System.out)比输出到文件慢得多,而且更容易导致锁竞争。这是新手最容易犯的错。

  2. 日志必须异步(Async)

    别让昂贵的业务线程去干"写磁盘"这种苦力活。

  3. 别在日志里"序列化"大对象
    log.info("Order info: {}", JSON.toJSONString(order))

    这行代码在执行 toJSONString 时会消耗大量 CPU,甚至导致 OOM。请务必判断 if (log.isInfoEnabled()),或者使用占位符。


06. 结语

高并发系统的崩溃,往往不是因为逻辑太复杂,而是因为基础太脆弱。

查一下你们的项目依赖,如果还存在 log4j-1.2.17.jar,请立刻、马上、现在就把它换掉。

别等到双 11 凌晨,让报警电话教你做人。


相关推荐
薛纪克1 小时前
Lambda Query:让微软Dataverse查询像“说话”一样简单
java·spring·microsoft·lambda·dataverse
程序员-周李斌1 小时前
CopyOnWriteArrayList 源码分析
java·开发语言·哈希算法·散列表
廋到被风吹走1 小时前
【Spring】两大核心基石 IoC和 AOP
java·spring
明有所思1 小时前
springsecurity更换加密方式
java·spring
却话巴山夜雨时i1 小时前
295. 数据流的中位数【困难】
java·服务器·前端
java干货1 小时前
优雅停机!Spring Boot 应用如何使用 Hook 线程完成“身后事”?
java·spring boot·后端
tealcwu1 小时前
【Unity技巧】实现在Play时自动保存当前场景
java·unity·游戏引擎
uup1 小时前
Java 多线程下的可见性问题
java