一个递归差点酿成的悲剧

起因

事情是这样的: 博主接到一个任务,需要在某个核心服务消费者的消费代码里,新增一段处理逻辑。

这个任务原有的逻辑是:我们团队通过定时任务触发某个现有接口,调用其他团队的RPC接口,对方服务处理完数据后,利用MQ发送一条消息,我们的服务通过订阅Topic,进行相应的消费处理。消息反序列化后可以抽象成一个Item,而我的任务,就是在原有的消费代码里,找到与Item关联的其它Items,进行与原Item类似的操作。

  • 之前的代码逻辑抽象
type Item struct {
    ItemID int64 `json:"item_id"`
    ......
}

func (item *Item) ConsumeAndExecuteTask() error {
    item.IsValid()
    item.process1()
    item.process2()
    
    return nil
}

拆解好需求之后,博主就开干了。利用原来的函数功能,博主新增了找到关联ID的函数findRelatedItems。既然是相似的操作,博主为每个关联的ItemID重新Copy了一份Item,通过新增字段StopRecursion控制递归层数。测试验收完成后也没问题,代码就上线了。

type Item struct {
    ItemID int64 `json:"item_id"`
    /*  一些旧有字段 */
    StopRecursion bool `json:"stop_recursion"` // 新增字段
}

func (item *Item) ConsumeAndExecuteTask() error {
    // 现有的消费处理逻辑
    item.IsValid()
    item.process1()
    item.process2()

    // 以下是新增的消费处理逻辑
    // 递归出口: 如果有停止递归标志,跳出当前函数
    if item.StopRecursion {
       return nil
    }
    
    // 获取关联的Item,递归调用当前函数 
    for _, itemID := range item.findRelatedItems() {
       b, _ := json.Marshal(item)
       relItem := &Item{}
       _ = json.Unmarshal(b, relItem)
       relItem.ItemID = itemID
       relItem.StopRecursion = true
       go func() {
          err := relItem.ConsumeAndExecuteTask()
          if err != nil {
             return
          }
       }()
    }
    return nil
}

上线之后,测试在生产环境验证,发现博主的代码有个bug,影响到了产线现有的消费,于是博主便紧急回滚代码。重新切分支进行修复,但是由于修复改动比较大,博主一不留神,把代码改成类似于如下代码,便重新提测了。测试过程中,bug顺利解决,测试验证也很快,发现功能没问题,代码便重新合并到主分支中,准备重新上生产环境。

func (item *Item) ConsumeAndExecuteTask() error {
    // 现有的消费处理逻辑
    item.IsValid()
    item.process1()
    item.process2()

    
    for _, itemID := range item.findRelatedItems() {
       b, _ := json.Marshal(item)
       relItem := &Item{}
       _ = json.Unmarshal(b, relItem)
       relItem.ItemID = itemID
       relItem.StopRecursion = true
       go func() {
          err := relItem.ConsumeAndExecuteTask()
          if err != nil {
             return
          }
       }()
    }
    return nil
}

在上线之前,博主留了个心眼,想在测试环境再验证一下,这时候灵异的事情发生了。测试环境刚才还打得开的页面,此刻总是偶发超时或报错,许多旧功能调用之后也不生效,直觉告诉我,肯定是博主刚才的代码哪里出问题,导致测试环境濒临崩溃的边缘。博主重新打开代码,发现原有的递归出口,因为博主改bug时开发思路的多次变更,已经被拿掉了。

func (item *Item) ConsumeAndExecuteTask() error {
    ......
    // 递归出口: 如果有停止递归标志,跳出当前函数 
    if item.StopRecursion { return nil } ==> 这个限制被拿掉了
    ......
}

实际上最后要上线的代码仍然需要字段StopRecursion判断是否需要跳出递归,否则由于Item之间的关联性,ConsumeAndExecuteTask函数永远可以找到关联的Items,也就陷入了 无限递归 的深渊。定位到问题后,博主迅速在团队大群里通知所有人不要上线这个服务,并迅速修复了问题,部署到测试环境上,濒临崩溃测试环境立马恢复了正常,重新触发消费也都正常。

代码修复完成后,博主把新增了递归出口的代码重新合到了主分支里。从产生问题到修复问题这段时间内,其实是比较危险的,如果有人在博主不知情的情况下,把有问题的服务代码上线到生产环境并触发了消费场景,那么由于 无限递归 ,生产环境的CPU使用率内存使用量将会迅速飙升,不久之后就会导致所有服务实例挂掉。由于这个服务又是一个比较关键的服务,一旦服务挂掉,整个系统上下游都会受到影响,无法正常对外提供服务,造成无可挽回的损失。

监控

重新上线之前,博主其实也不是百分百确定现在的代码不会产生问题。在构建流水线部署时,博主选择了手动切换流量的方式,先正常部署代码生成Pod服务实例,此时流量还没有切换,但是新生成的实例是可以正常消费Topic的。如果代码仍然存在无限递归的问题,那么新的Pod实例CPU使用率应该会显著激增,日志也可以观察到一直在不断执行同一段代码函数。此时即使新的Pod实例挂掉,由于还没有切换流量,整个服务暴露给外部的RPC接口和HTTP接口依然只存在于旧实例上,在外部看来,整个服务依旧在正常对外提供服务。

利用这个特性,博主重新触发了我们团队的定时任务,并通过Grafana监控面板,观察新服务实例的CPU使用率。好在随着Topic的顺利消费,新实例的CPU使用率并没有太大波动,日志也如预期一样及时停止了递归函数的执行。确定没问题之后,博主才把流量切换到新生成的实例上。

如何避免

虽然这次没有造成重大产线事故,但也给了博主当头一棒,开始思考自己在这次事件中的表现与不足。首先,如果是走正常测试流程,这个问题肯定可以很快就可以暴露出来,测试人员发现测试环境崩溃,肯定可以迅速做出反应并定位到问题。不幸的是,这是代码上线后因为要紧急修复而产生的问题,当时只验证功能,无限递归造成的问题在短时间内还没有充分暴露出来,测试代码就通过且合并到主分支了。不幸中的万幸是,博主留了个心眼,虽然代码已经合并,但出于职业习惯还是想在上线前验证一下,这才及时发现了问题。

实际上,代码里这种不会在短时间内暴露的“”,往往无法通过测试及时发现问题,特别是上线时间紧张的条件下更是如此。这就要求作为开发人员的我们,绝对不要过度依赖测试,要对自己写的代码流程有一个精准的把握,既要胆大,更要心细。测试不是万金油,不可能覆盖到所有异常场景,总有一些坑只能开发自己去避免踩到,这个过程非常锻炼软件开发从业者的细心与耐心。成长,也就在这么一瞬之间!

事故止损

总结完如何避免,不妨假设一下,如果真发生了这种情况,又该如何应对?

首先,当有问题的代码上线之后,如果你的团队维护的服务流量较大,或者定时任务的触发频率足够高,应该很快就可以从监控或者告警群发现问题。此时如果只发版了这一个服务,那么应该立即回滚或切换流量。如果一次上线涉及到了多个微服务的部署,则要逆向按照上线顺序,将有问题的服务连同起其上游服务依次回滚。及时止损永远比定位问题更重要。并且在这个过程中,一定要及时把回滚的消息同步给所有相关人员,拦截当前时间点所有的上线计划。回滚完毕后,不必立马修复问题,而是要观察生产环境是否恢复正常的对外服务。当确认生产环境恢复之后,就可以开始排查问题进行修复,确保代码逻辑正常后,经过充分的Code Review、测试和验证后重新上线!

#牛客AI配图神器#

全部评论
手动切换流量妙
点赞 回复 分享
发布于 01-22 18:56 上海

相关推荐

在面试和工作中,有效地表达自己的硬实力(即专业技能和知识)是至关重要的。这不仅能帮助你展示自己的能力,还能让面试官或同事更好地理解你的价值。下面是一些具体的方法和技巧,帮助你将硬实力更好地传达:https://www.nowcoder.com/issue/tutorial?zhuanlanId=j572L2&uuid=d3520e4b0ad640008bc5305fd6838a1c1. 理清自己的硬实力首先,你需要理清自己的硬实力,明确你掌握的技能和知识,包括:编程语言:如 JavaScript、HTML、CSS、Python 等。框架与库:如 React、Vue、Angular、Node.js、Bootstrap 等。工具与技术:如 Git、Webpack、Docker、Jest 等。项目经验:具体参与的项目及角色。相关证书:如相关的专业认证、课程证书等。2. 使用量化数据通过量化来表达你的成果会让你的能力显得更加具体和有说服力。具体数字:如“通过技术优化,将页面加载时间减少了30%”或“在项目中提高了代码复用率,减少了50%的开发时间”。项目规模:描述参与项目的规模、影响用户数量等,例如“参与了一个月活跃用户超过10万的电商平台开发”。3. 采用 STAR 方法在回答相关问题时,采用 STAR 方法(Situation, Task, Action, Result)能够有效组织你的表达:Situation(情境):描述面临的具体情境。Task(任务):你在这个情境中需要完成的任务。Action(行动):你采取的具体行动和使用的技术。Result(结果):最后的结果和影响,可以用量化的结果来描述。https://www.nowcoder.com/issue/tutorial?zhuanlanId=j572L2&uuid=d3520e4b0ad640008bc5305fd6838a1c
点赞 评论 收藏
分享
03-04 16:02
四川大学 Java
最近被大厂校招数据刷屏了吧?字节4000、腾讯7000、美团5000...光这几家头部企业就释放出近三万个岗位机会。但先别急着欢呼,作为经历过简历石沉大海、面试连环翻车的25届学长,这有份实战血泪经验包要送你们。简历不是自传,是产品说明书千万别把简历写成流水账!上周帮学弟改简历,发现他把课程作业都列了半页纸。记住:HR筛简历时正在和KPI赛跑!重点突出你在项目中如何解决具体问题,比如"通过优化数据清洗流程,将模型训练效率提升30%"。用STAR法则包装经历(情境-任务-行动-结果),数据化的成果比"参与项目"更有说服力。实在没亮眼经历?把课程设计包装成微项目,重点展示你的解题逻辑。面试不是考试,是真人秀收到鹅厂面试通知那天,我对着镜子练了3小时微表情管理。找同学模拟时发现,自以为完美的"项目难点"回答,在对方连环追问下漏洞百出。建议:组建3人互怼小组,轮流扮演压力面试官用手机录下回答过程,回放时注意眼神飘忽、摸鼻子等小动作准备3种不同时长的自我介绍(1/3/5分钟)遇到不会的技术问题别硬撑,诚恳说"这个领域我接触较少,但我的理解是...",既展现学习能力又避免冷场尴尬。心态不是玄学,是战略物资去年秋招连续被6家企业发"好人卡",差点怀疑自己选错专业。后来发现,面试失败可能是岗位匹配度问题。建议建立求职追踪表:记录每家企业的业务方向标注面试官关注的核心能力统计高频出现的八股题当某类问题反复出现卡壳(比如我的动态规划总翻车),立即针对性恶补。记住:面试就像相亲,被拒可能只是"性格不合",未必是你不够优秀。现在各厂HC看着诱人,但实际竞争可能比数据更残酷。上周和字节HR喝咖啡时听说,某个算法岗已经出现1:200的报录比。建议在冲大厂同时,关注细分领域独角兽企业,很多隐形冠军给出的薪资待遇和发展空间并不逊色。最后送大家三个锦囊:秋招季每天预留1小时行业资讯速读准备3套不同风格的证件照(科技感/亲和力/专业范)学会用Notion搭建个人求职知识库那些在朋友圈晒offer的大神,可能只是比你早摔了几十个跟头。与其焦虑HC真假,不如现在就打开电脑迭代简历。毕竟,泼天的富贵也要伸手才能接住。八股不知道咋记,不知道重点,可以看看我的神品八股专栏,全网40w粉大博主在看,鹅厂面试官也在看,介绍如下https://www.nowcoder.com/discuss/718273556131377152?sourceSSR=users
点赞 评论 收藏
分享
评论
2
2
分享

创作者周榜

更多
牛客网
牛客企业服务