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

那晚杭州的闷热,至今记忆犹新。

2021年,我刚来到杭州这座"卷城",入职了一家梦想中的互联网大厂。作为一名电商新人,我一头扎进了促销和会场的研发中。

那晚,我们正为一个S级的"会员闪促"活动做最后的护航,它将在零点准时生效。作战室里灯火通明,所有人都盯着大盘,期待着活动上线后,GMV曲线能像火箭一样发射。

然而,我们等来的不是火箭,而是雪崩

刚过0点,登登登登... 告警群里的消息开始疯狂刷屏,声音急促得像是防空警报:

ini 复制代码
[严重] promotion-marketing集群 - 应用可用度 < 10%
[严重] promotion-marketing集群 - HSF线程池活跃线程数 > 95%
[紧急] promotion-marketing集群 - CPU Load > 8.0

我心里咯噔一下,立马打开内部代号"天眼"的监控系统------整个promotion-marketing集群,上百台机器,像被病毒感染了一样,CPU和Load曲线集体垂直拉升,整整齐齐。

这意味着,作为促销中枢的服务已经事实性瘫痪。所有促销页面上,为大会员准备的活动入口,都因服务超时而被降级------活动,上线即"失踪"

一场精心筹备的S级大促,在上线的第一秒,就"出师未捷身先死"了。

第一幕:无效的挣扎

故障排查,有时候像是在黑暗的房间里找一个黑色的开关,但这一次,我们连房间的门都找不到了。

  1. 第一步,看日志。 一个NPE(空指针异常)的数量有点多,但仔细一看,来自一个非常边缘的富客户端jar包,跟核心链路无关。排除。

  2. 第二步,怀疑死锁。 HSF线程池全部耗尽,是线程"罢工"的典型症状。我立刻拉取线程快照,用jstack分析,却没有发现任何死锁迹象。再次排除。

  3. 第三步,重启大法。 我们挑了几台负载最高的机器进行重启。起初两分钟确实有效,但只要新流量一进来,CPU和Load就像脱缰的野马,再次冲顶。

  4. 第四步,扩容。 既然单机扛不住,那就用"人海战术"。我们紧急扩容了20台机器。但新机器就像冲入火场的士兵,没坚持几分钟,就同样陷入了高负载、疯狂GC的泥潭。

此时,距离故障爆发已经过去了18分钟。作战室里的气氛已经从紧张变成了压抑。我能感觉到身后Leader的目光,像两把手术刀,在我背上反复切割。

一个刚入职不久的小兄弟,看着满屏的红色曲线,悄声自语道:"感觉都要被抬走了..."

他这句话,成了我当晚听到的最实在的一句"B面"真话。

第二幕:深入"肌体"

常规手段全部失效,唯一的办法,就是深入到JVM的"肌体"内部,看看它的"细胞"到底出了什么问题。

我保留了一台故障机作为"案发现场",然后dump了它的堆内存和线程栈。

分析堆内存,我发现老年代(Old Gen)的使用率居高不下,CMS回收的效果非常差,导致了频繁且耗时的Full GC,这完美解释了为什么CPU会飙升。

同时,我注意到,内存里驻留了大量char[]数组,内容都指向一个和"万豪活动配置"相关的字符串常量。这说明,有一个巨大的活动配置对象,像一个幽灵,赖在内存里不走。

接着,我开始分析线程栈快照。我用grep简单统计了一下:

bash 复制代码
# 查看等待的线程
$ sgrep 'TIMED_WAITING' HSF_JStack.log | wc -l
336

# 查看正在运行的线程
$ sgrep 'RUNNABLE' HSF_JStack.log | wc -l
246

三百多个线程在等待,两百多个在运行。问题大概率就出在这两百多个RUNNABLE的线程上。我过滤出这些线程的堆栈信息,一个熟悉的身影,反复出现在我的屏幕上:

java 复制代码
at com.alibaba.fastjson.toJSONString(...)

大量的线程,都卡在了FastJSON的序列化操作上!

结合堆内存里那个巨大的"万豪配置"字符串,一个大胆的猜测浮现在我脑海里:有一个巨大的对象,正在被疯狂地、反复地序列化,这个CPU密集型操作,耗尽了线程资源,拖垮了整个集群!

第三幕:"一行好代码"

顺着线程栈的指引,我很快定位到了代码里的"犯罪现场": XxxxxCacheManager.java

在这段代码上方,还留着一行几个月前同事留下的、刺眼的注释: // TODO: 此处有性能风险,大促前需优化。

正是这个被所有人遗忘的TODO,在今晚,变成了捅向我们所有人的那把尖刀。

这是一个从缓存(Tair)里获取活动玩法数据的工具类。而另一个写入缓存的方法,则让我大开眼界:

java 复制代码
// ... 省略部分代码
// 从缓存(Tair)里获取活动玩法数据的工具类
public void updateActivityXxxCache(Long sellerId, List<XxxDO> xxxDOList) {
    try {
        if (CollectionUtils.isEmpty(xxxDOList)) {
            xxxDOList = new ArrayList<>();
        }
        // 为了防止单Key读压力过大,设计了20个散列Key
        for (int index = 0; index < XXX_CACHE_PARTITION_NUMBER; index++) {
            // 致命问题:将序列化操作放在了循环体内!
            tairCache.put(String.format(ACTIVITY_PLAY_KEY, xxxId, index), 
                          JSON.toJSONString(xxxDOList), // 就是这行代码,序列化了20次!
                          EXPIRE_TIME);
        }
    } catch (Exception e) {
        log.warn("update cache exception occur", e);
    }
}

看着这段代码,我愣了足足十秒钟。

零点活动生效,缓存里没有数据,发生了缓存击穿,这很正常。 为了防止单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%的耗时都在XxxxxCacheManagerupdate方法里,排查效率至少能提高一倍。

法则三:技术债,总会在你最想不到的时候"爆炸"。

代码里使用的Tair LDB是一个早已无人维护的老旧中间件。技术债就像家里的蟑螂,你平时可能看不到它,但它总会在最关键、最要命的时候,从角落里爬出来,给你致命一击。

那天凌晨一点,我走在杭州空无一人的大街上,吹着冷风,脑子里却异常地清醒。

因为在那场惊心动魄的"雪崩"里,在那一串串冰冷的线程堆栈中,我再次确认了一个朴素的道理:

所有宏大的系统,最终都是由一行行具体的代码组成的。而魔鬼,恰恰就藏在其中。


老A说: 很多时候,一个P3故障的根因,可能并不是什么高深的架构难题,而仅仅是一行被放错了位置的for循环。敬畏代码,是每个工程师应有的基本素养。

相关推荐
写代码的大聪明5 小时前
Java Socket 短链接 自定义报文
java·tcp/ip
张三xy5 小时前
Java网络编程基础 Socket通信入门指南
java·开发语言·网络协议
执子手 吹散苍茫茫烟波6 小时前
leetcode46.全排列
java·leetcode·链表·深度优先·回溯法
爱学习的小道长6 小时前
使用 Dify 和 LangBot 搭建飞书通信机器人
android·java·飞书
洛卡卡了6 小时前
适配私有化部署,我手写了套支持离线验证的 License 授权系统
java·后端·架构
SimonKing6 小时前
亲测有效!分享一个稳定访问GitHub,快速下载资源的实用技巧
java·后端·程序员
过期动态6 小时前
MySQL内置的各种单行函数
java·数据库·spring boot·mysql·spring cloud·tomcat
MiniCode6 小时前
EllipsizeEndTextview末尾省略自定义View
android·java·前端
悦悦子a啊6 小时前
[Java]PTA:jmu-java-01入门-基本输入
java·开发语言·算法