起因
事情是这样的: 博主接到一个任务,需要在某个核心服务消费者的消费代码里,新增一段处理逻辑。
这个任务原有的逻辑是:我们团队通过定时任务触发某个现有接口,调用其他团队的RPC接口,对方服务处理完数据后,利用MQ发送一条消息,我们的服务通过订阅Topic
,进行相应的消费处理。消息反序列化后可以抽象成一个Item
,而我的任务,就是在原有的消费代码里,找到与Item
关联的其它Items
,进行与原Item
类似的操作。
- 之前的代码逻辑抽象
go
type Item struct {
ItemID int64 `json:"item_id"`
......
}
go
func (item *Item) ConsumeAndExecuteTask() error {
item.IsValid()
item.process1()
item.process2()
return nil
}
拆解好需求之后,博主就开干了。利用原来的函数功能,博主新增了找到关联ID的函数findRelatedItems
。既然是相似的操作,博主为每个关联的ItemID
重新Copy了一份Item
,通过新增字段StopRecursion
控制递归层数。测试验收完成后也没问题,代码就上线了。
go
type Item struct {
ItemID int64 `json:"item_id"`
/* 一些旧有字段 */
StopRecursion bool `json:"stop_recursion"` // 新增字段
}
go
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顺利解决,测试验证也很快,发现功能没问题,代码便重新合并到主分支中,准备重新上生产环境。
go
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时开发思路的多次变更,已经被拿掉了。
go
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、测试和验证后重新上线!