双 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 凌晨,让报警电话教你做人。


相关推荐
BestAns1 天前
一文带你吃透 Java 反射机制
java·后端
wasp5201 天前
AgentScope Java 核心架构深度解析
java·开发语言·人工智能·架构·agentscope
2501_916766541 天前
【Springboot】数据层开发-数据源自动管理
java·spring boot·后端
自在极意功。1 天前
MyBatis 动态 SQL 详解:从基础到进阶实战
java·数据库·mybatis·动态sql
软件管理系统1 天前
基于Spring Boot的便民维修管理系统
java·spring boot·后端
百***78751 天前
Step-Audio-2 轻量化接入全流程详解
android·java·gpt·php·llama
快乐肚皮1 天前
MySQL递归CTE
java·数据库·mysql·递归表达式
廋到被风吹走1 天前
【Spring】DispatcherServlet解析
java·后端·spring
廋到被风吹走1 天前
【Spring】PlatformTransactionManager详解
java·spring·wpf