凌晨零点,一个 TODO,差点把我们整个部门抬走

那晚杭州的闷热,至今记忆犹新。
2021 年,我刚来到杭州这座“卷城”,入职了一家梦想中的互联网大厂。作为一名电商新人,我一头扎进了促销和会场的研发中。
那晚,我们正为一个 S 级的“会员闪促”活动做最后的护航,它将在零点准时生效。作战室里灯火通明,所有人都盯着大盘,期待着活动上线后,GMV 曲线能像火箭一样发射。
然而,我们等来的不是火箭,而是雪崩。
刚过 0 点,登登登登… 告警群里的消息开始疯狂刷屏,声音急促得像是防空警报:
我心里咯噔一下,立马打开内部代号“天眼”的监控系统——整个promotion-marketing集群,上百台机器,像被病毒感染了一样,CPU 和 Load 曲线集体垂直拉升,整整齐齐。
这意味着,作为促销中枢的服务已经事实性瘫痪。所有促销页面上,为大会员准备的活动入口,都因服务超时而被降级——活动,上线即“失踪”。
一场精心筹备的 S 级大促,在上线的第一秒,就“出师未捷身先死”了。
第一幕:无效的挣扎
故障排查,有时候像是在黑暗的房间里找一个黑色的开关,但这一次,我们连房间的门都找不到了。
第一步,看日志。 一个 NPE(空指针异常)的数量有点多,但仔细一看,来自一个非常边缘的富客户端 jar 包,跟核心链路无关。排除。
第二步,怀疑死锁。 HSF 线程池全部耗尽,是线程“罢工”的典型症状。我立刻拉取线程快照,用
jstack分析,却没有发现任何死锁迹象。再次排除。第三步,重启大法。 我们挑了几台负载最高的机器进行重启。起初两分钟确实有效,但只要新流量一进来,CPU 和 Load 就像脱缰的野马,再次冲顶。
第四步,扩容。 既然单机扛不住,那就用“人海战术”。我们紧急扩容了 20 台机器。但新机器就像冲入火场的士兵,没坚持几分钟,就同样陷入了高负载、疯狂 GC 的泥潭。
此时,距离故障爆发已经过去了 18 分钟。作战室里的气氛已经从紧张变成了压抑。我能感觉到身后 Leader 的目光,像两把手术刀,在我背上反复切割。
一个刚入职不久的小兄弟,看着满屏的红色曲线,悄声自语道:“感觉都要被抬走了…”
他这句话,成了我当晚听到的最实在的一句“B 面”真话。
第二幕:深入“肌体”
常规手段全部失效,唯一的办法,就是深入到 JVM 的“肌体”内部,看看它的“细胞”到底出了什么问题。
我保留了一台故障机作为“案发现场”,然后 dump 了它的堆内存和线程栈。
分析堆内存,我发现老年代(Old Gen)的使用率居高不下,CMS 回收的效果非常差,导致了频繁且耗时的 Full GC,这完美解释了为什么 CPU 会飙升。
同时,我注意到,内存里驻留了大量char[]数组,内容都指向一个和“万豪活动配置”相关的字符串常量。这说明,有一个巨大的活动配置对象,像一个幽灵,赖在内存里不走。
接着,我开始分析线程栈快照。我用grep简单统计了一下:
三百多个线程在等待,两百多个在运行。问题大概率就出在这两百多个RUNNABLE的线程上。我过滤出这些线程的堆栈信息,一个熟悉的身影,反复出现在我的屏幕上:
大量的线程,都卡在了 FastJSON 的序列化操作上!
结合堆内存里那个巨大的“万豪配置”字符串,一个大胆的猜测浮现在我脑海里:有一个巨大的对象,正在被疯狂地、反复地序列化,这个 CPU 密集型操作,耗尽了线程资源,拖垮了整个集群!
第三幕:“一行好代码”
顺着线程栈的指引,我很快定位到了代码里的“犯罪现场”: XxxxxCacheManager.java
在这段代码上方,还留着一行几个月前同事留下的、刺眼的注释:// TODO: 此处有性能风险,大促前需优化。
正是这个被所有人遗忘的 TODO,在今晚,变成了捅向我们所有人的那把尖刀。
这是一个从缓存(Tair)里获取活动玩法数据的工具类。而另一个写入缓存的方法,则让我大开眼界:
看着这段代码,我愣了足足十秒钟。
零点活动生效,缓存里没有数据,发生了缓存击穿,这很正常。为了防止单 Key 读压力过大,作者设计了 20 个散列 Key 来分散读流量,这思路也没问题。
但致命的是,在写入缓存时,将巨大对象(约 1-2MB)序列化的操作,竟然被放在了 for 循环内部!
这意味着,每一次缓存击穿后的回写,都会将一个 1MB 的巨大对象,连续不断地、在同一个线程里,序列化整整 20 次!
这已经不是代码了,这是一台 CPU 绞肉机。
而更要命的是,我们的缓存中间件 Tair LDB 本身性能脆弱,被这放大了 20 倍的写流量(20 x 1MB)瞬间打爆,触发了限流。
Tair 被限流后,写入耗时急剧增加,从几十毫秒飙升到几秒。这导致“CPU 绞肉机”的操作时间被进一步拉长。
最终,HSF 线程池被这些“又慢又能吃”的线程全部占满,服务雪崩。
第四幕:真相与反思
故障的根因已经水落出。我们紧急回滚了这段“循环序列化”的代码,集群在凌晨 0 点 30 分左右,终于恢复了平静。30 分钟,生死时速。
在事后的复盘会上,我分享了老 A 的“B 面三法则”:
法则一:任何脱离了容量评估的“优化”,都是在“耍流氓”。
这次故障的始作俑者,就是一段为了解决“读压力”而设计的“好代码”。但好的优化是锦上添花,坏的优化是“画蛇添足”。敬畏之心,比奇技淫巧更重要。
法则二:监控的终点,是“代码块耗时”。
我们有机器、接口、中间件等各种监控,但唯独缺少对“代码块耗 plataformas”的精细化监控。如果 APM 工具能第一时间告诉我们 90%的耗时都在XxxxxCacheManager的update方法里,排查效率至少能提高一倍。
法则三:技术债,总会在你最想不到的时候“爆炸”。
代码里使用的 Tair LDB 是一个早已无人维护的老旧中间件。技术债就像家里的蟑螂,你平时可能看不到它,但它总会在最关键、最要命的时候,从角落里爬出来,给你致命一击。
那天凌晨一点,我走在杭州空无一人的大街上,吹着冷风,脑子里却异常地清醒。
因为在那场惊心动魄的“雪崩”里,在那一串串冰冷的线程堆栈中,我再次确认了一个朴素的道理:
所有宏大的系统,最终都是由一行行具体的代码组成的。而魔鬼,恰恰就藏在其中。
老 A 说:很多时候,一个 P3 故障的根因,可能并不是什么高深的架构难题,而仅仅是一行被放错了位置的
for循环。敬畏代码,是每个工程师应有的基本素养。
版权声明: 本文为 InfoQ 作者【大厂码农老A】的原创文章。
原文链接:【http://xie.infoq.cn/article/926291be21051d6a4b0d4299a】。文章转载请联系作者。







评论