当前位置: 首页 > 科技观察

7行代码让B站崩溃了3小时,却因为“一个心机0”

时间:2023-03-13 15:33:08 科技观察

和一个小字“0”导致B站全面崩溃,不知道大家还记得吗还记得那个晚上,B站“大楼停电”、“服务器爆炸”、“程序员删库跑路”的通宵狂欢。(手动狗头)时隔一年,背后的“真凶”是终于被阿B爆料了——没想到,就这么简单的几行代码,直接到B站就用了两三个小时,搞得B站的程序员们彻夜难眠头发都掉光了。你可能会问,这不就是一个常用的求最大公约数的函数吗?怎么会这么厉害?幕后一堆一堆,归根结底其实就是一句话:0,真的没法淘汰。详情一起来看看“事故报告”吧。字符串“0”引起的“血案”先说悲剧的根源,就是开头贴出的gcd函数。学过一点编程知识的朋友应该知道,这是一个递归函数,采用滚动除法的方法计算最大公约数。不同于我们手算最大公约数的方法,姜阿姨的这个算法是:举个简单的例子,a=24,b=18,求a和b的最大公约数;a除以b,余数为6,则令a=18,b=6,然后继续计算;18除以6,这次余数为0,那么6就是24和18的最大公约数。也就是说,a和b重复除以取余,直到b=0。函数中:ifb==0thenreturnaend,判断语句生效,计算结果。基于这样的数学原理,我们再看这段代码,好像没有问题:但是如果输入的b是字符串“0”呢?B站的技术分析文章中提到,这次事故的代码是用Lua写的。Lua具有以下特点:它是一种动态类型的语言。一般习惯中,变量不需要定义类型,直接给变量赋值即可。当Lua对一串数字进行算术运算时,它会尝试将这串数字转换为一个数字。在Lua语言中,数学运算n%0的结果是nan(NotANumber)。我们来模拟一下这个过程:当b为字符串“0”时,由于gcd函数没有对其进行类型校验,当遇到判断语句时,“0”不等于0,代码“return_gcd(b,a%b)"并返回_gcd("0",nan)。_gcd("0",nan)再次执行,所以返回值变为_gcd(nan,nan)。小牛到此结束,判断语句中b=0的条件永远达不到,于是出现了死循环。也就是说,这个程序开始疯狂的原地打转,为了一个永远得不到的结果,占用了100%的CPU,自然无法处理其他的用户请求。那么问题来了,这个“0”是怎么进来的呢?官方的说法是:在某种发布模式下,应用的实例权重会暂时调整为0,此时注册中心返回给SLB(负载均衡)的权重为字符串类型的“0”。该发布环境仅在生产环境中使用,使用频率极低。这个问题在SLB的早期灰度过程中并没有被触发。在balance_by_lua阶段,SLB会将共享内存中保存的服务IP、Port、Weight作为参数传递给lua-resty-balancer模块,选择上游服务器。当节点weight="0"时,balancer模块中的_gcd函数接收到的输入参数b可能为"0"。BUG是如何定位的从“事后诸葛亮”的角度来看,B站整体崩盘的根本原因或多或少有点“就是这样”。但是从涉及的程序员的角度来看,事情真的没有那么简单。当晚22点52分——大部分程序员刚下班或还没下班(doge),B站运维接到服务不可用的告警,立马怀疑机房,网络、四层LB、七层SLB等基础设施存在问题。然后立即与相关技术人员建立紧急语音会议,开始处理。五分钟后,运维发现承载所有在线业务的主机房7楼SLB的CPU使用率已经达到100%,无法处理用户请求。排除其他设施后,故障锁定在这一层。(七层SLB是指基于URL等应用层信息的负载均衡,负载均衡通过算法将客户端请求分发到服务器集群,从而减轻服务器压力。)当万事紧急时,有一个小插曲:远程程序员在家后登录VPN,上不了内网,只好又打电话给内网负责人,过了一个绿色通道就全部上线了(因为其中一个域名被故障SLB代理了).此时,已经过去了25分钟,紧急矫正仪式开始了。一是运维热重启SLB,但没有恢复;然后尝试拒绝用户流量冷重启SLB,但是CPU还是100%,但还是没有恢复。然后,运维发现多活机房大量SLB请求超时,但CPU并没有过载。当多活机房的SLB即将重启时,内部群反应主站服务已经恢复,视频播放、推荐、评论、新闻等功能基本正常。事故发生前31分钟,时间是23点23分。值得一提的是,这些功能的恢复,其实是事发时被网友吐槽的“高可用容灾架构”的效果。至于当初为什么这道防线没起作用,也许你我还有关系。简单来说,就是大佬打不开哔哩哔哩就开始疯狂刷新。CDN流量回源重试+用户重试,将直接让哔哩哔哩的流量提升4倍以上,连接数提升100倍至千万级。多活SLB完全过载。但是并不是所有的服务都实现了多活架构,事情到现在也没有彻底解决。接下来的半个小时,大家做了很多操作,回滚了最近两周左右上线的Lua代码,但是并没有恢复剩下的服务。时间到了12点,没办法,“先不管bug是怎么出来的,还是把服务全部恢复吧”。简单+粗暴:运维直接花了一个小时重建了一套新的SLB集群。凌晨1点,新集群终于搭建完成:一方面有人负责将直播、电商、漫画、支付等核心业务流量陆续切换到新集群,并恢复了所有服务(凌晨1点50分全部搞定,崩溃暂时结束事故接近3小时);另一方面,继续分析bug产生的原因。在他们使用分析工具生成了详细的火焰图数据后,麻烦的“0”终于露出了端倪:CPU热点明显集中在对lua-resty-balancer模块的一次调用中。该模块的_gcd函数在某次执行后返回了一个意外的值:NaN。同时,他们还找到了触发条件:某容器IP的weight=0。他们怀疑是这个函数触发了JIT编译器的bug,在死循环中出现错误,导致SLBCPU占用100%。所以全局关闭了jit编译,暂时规避了风险。一切安顿好后,时间已经快四点了,大家总算是暂时睡了个好觉。第二天,大家也没闲着。不停地在离线环境复现这个bug,发现不是JIT编译器的问题,而是在服务的特殊发布模式下容器实例权重为0的情况,而这个0是字符串形式。前面说过,在动态语言Lua中,字符串“0”在算术运算中被转换为数字,走到了一个不该走的分支,造成了死循环,引发了史无前例的大崩溃事件。递归锅还是弱类型语言锅?很多网友对这起事故记忆犹新。一些人回忆说,他们只是认为他们不可能改变他们的手机和电脑。有人还记得,这件事5分钟后就上了热搜。所有人都很惊讶,这么简单的死循环竟然能导致这么大的网站崩溃。不过,也有人指出,无限循环并不少见。很少见的是SLB层和分发过程中出现问题。不像后台出现问题可以快速重启解决。为了避免这种情况,有人认为应该慎用递归。如果硬要用,应该设置一个计数器,达到业务不太可能达到的值后直接返回。有人认为这不能归咎于递归,而主要是因为弱类型语言。这也引出了“心机‘0’”的戏谑表达。另外,因为出事拖得太久,事情太多,B站给所有用户一天的大会员。这里有人算了一笔账,说就是这7行代码,让b站老板损失了大约15.75亿元。(手动狗头)这个bug你想吐槽什么?