稀土掘金技术社区 09月25日 18:02
大厂电商新人亲历S级活动上线‘雪崩’故障及反思
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

那晚杭州的闷热,至今记忆犹新。2021年,我刚来到杭州这座“卷城”,入职了一家梦想中的互联网大厂。作为一名电商新人,我一头扎进了促销和会场的研发中。那晚,我们正为一个S级的“会员闪促”活动做最后的护航,它将在零点准时生效。作战室里灯火通明,所有人都盯着大盘,期待着活动上线后,GMV曲线能像火箭一样发射。然而,我们等来的不是火箭,而是「雪崩」。刚过0点,登登登登… 告警群里的消息开始疯狂刷屏,声音急促得像是防空警报:[严重] promotion-marketing集群 - 应用可用度 < 10%[严重] promotion-marketing集群 - HSF线程池活跃线程数 > 95%[紧急] promotion-marketing集群 - CPU Load > 8.0我心里咯噔一下,立马打开内部代号“天眼”的监控系统——整个promotion-marketing集群,上百台机器,像被病毒感染了一样,CPU和Load曲线集体垂直拉升,整整齐齐。这意味着,作为促销中枢的服务已经事实性瘫痪。所有促销页面上,为大会员准备的活动入口,都因服务超时而被降级——「活动,上线即“失踪”」。一场精心筹备的S级大促,在上线的第一秒,就“出师未捷身先死”了。

🔍 故障初期,团队尝试了多种常规排查手段,包括查看日志、怀疑死锁、重启机器和扩容,但均未能有效解决问题,故障持续扩大。

🧬 深入分析发现,问题的根源在于JVM内部:老年代(Old Gen)使用率居高不下,CMS回收效果差,导致频繁且耗时的Full GC,CPU飙升;同时,大量char[]数组驻留内存,指向一个与“万豪活动配置”相关的字符串常量,说明存在一个巨大的活动配置对象。

🧵 进一步分析线程栈快照,发现大量线程卡在了FastJSON的序列化操作上,结合内存中的巨大对象,推断出有一个巨大的对象正在被疯狂地、反复地序列化,耗尽了线程资源,拖垮了整个集群。

🔧 定位到代码中的“犯罪现场”是XxxxxCacheManager.java中的updateActivityXxxCache方法,该方法为了防止单Key读压力过大,设计了20个散列Key来分散读流量,但在写入缓存时,将序列化操作放在了for循环内部,导致每次缓存击穿后的回写,都会将一个1MB的巨大对象,连续不断地、在同一个线程里,'序列化整整20次!'。

📉 更要命的是,我们的缓存中间件Tair LDB本身性能脆弱,被这放大了20倍的写流量(20 x 1MB)瞬间打爆,触发了限流。Tair被限流后,写入耗时急剧增加,从几十毫秒飙升到几秒,进一步拉长了'CPU绞肉机'的操作时间,最终导致HSF线程池被这些'又慢又能吃'的线程全部占满,服务雪崩。

🤔 事后复盘,团队总结出老A的“B面三法则”:任何脱离了容量评估的“优化”,都是在“耍流氓”;监控的终点,是“代码块耗时”;技术债,总会在你最想不到的时候“爆炸”。

原创 大厂码农老A 2025-09-11 08:30 重庆

点击关注公众号,技术干货及时达。

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

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

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

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

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

    [严重] promotion-marketing集群 - 应用可用度 < 10%
    [严重] promotion-marketing集群 - HSF线程池活跃线程数 > 95%
    [紧急] promotion-marketing集群 - CPU Load > 8.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简单统计了一下:

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

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

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

    at com.alibaba.fastjson.toJSONString(...)

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

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

    「第三幕:“一行好代码”」顺着线程栈的指引,我很快定位到了代码里的“犯罪现场”: XxxxxCacheManager.java

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

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

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

    // ... 省略部分代码
    // 从缓存(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循环。敬畏代码,是每个工程师应有的基本素养。

    AI编程资讯AI Coding专区指南:https://aicoding.juejin.cn/aicoding

    ""~

    阅读原文

    跳转微信打开

    Fish AI Reader

    Fish AI Reader

    AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

    FishAI

    FishAI

    鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

    联系邮箱 441953276@qq.com

    相关标签

    大厂 电商 S级活动 雪崩故障 JVM Full GC FastJSON 序列化 缓存 技术债
    相关文章