写在前面:
距离双 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 写入磁盘
}
}
}
为什么平时没事,压测就炸了?
- IO 瓶颈: 压测时日志量激增,磁盘 IO 写入变慢(尤其是云盘 IOPS 被打满时)。
- 串行化: 由于
synchronized(this)的存在,所有试图打印日志的业务线程,必须排队。 - 多米诺骨牌:
- 线程 A 抢到锁,开始写日志,但磁盘慢,写了 10ms。
- 这 10ms 内,线程 B、C、D... 到线程 Z 全部堵在
synchronized外面。 - 线程 A 释放锁,线程 B 抢到,又卡了 10ms。
- 随着请求源源不断进来,线程池瞬间被占满,所有线程都在等这把"日志锁"。
结果: 整个系统因为一句 log.info,退化成了单线程运行,吞吐量自然跌至 0。
![Image: 线程阻塞示意图。左边是宽阔的"业务高速公路",右边是一个标着 "Log4j Lock" 的单人独木桥。几百辆车(线程)挤在桥头动弹不得]
(图2:所谓的"高性能并发",最后都死在了"串行写日志"上)
04. 极速止血与彻底根治
黄金 5 分钟止血
在压测现场,改代码重发版来不及了。我们利用配置中心(Apollo/Nacos)动态调整了日志级别:
rootLogger.level 从 INFO 调整为 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 当天,损失的就是真金白银。
关于日志,我有三条铁律:
-
生产环境禁止使用 ConsoleAppender
输出到控制台(System.out)比输出到文件慢得多,而且更容易导致锁竞争。这是新手最容易犯的错。
-
日志必须异步(Async)
别让昂贵的业务线程去干"写磁盘"这种苦力活。
-
别在日志里"序列化"大对象
log.info("Order info: {}", JSON.toJSONString(order))这行代码在执行
toJSONString时会消耗大量 CPU,甚至导致 OOM。请务必判断if (log.isInfoEnabled()),或者使用占位符。
06. 结语
高并发系统的崩溃,往往不是因为逻辑太复杂,而是因为基础太脆弱。
查一下你们的项目依赖,如果还存在 log4j-1.2.17.jar,请立刻、马上、现在就把它换掉。
别等到双 11 凌晨,让报警电话教你做人。