我们来对B站2021年7月13日的这次严重故障进行一次更详细、更具体的复盘分析。
事件名称: B站 “713” 大规模服务不可用事故
发生时间: 2021年7月13日 22:52 - 2021年7月14日 01:50 (核心业务恢复) / ~03:50 (风险规避完成)
核心影响: B站全站服务(主站、直播、APP、支付、电商、漫画等)大面积瘫痪,用户无法访问,APP首页无法加载。内部系统访问也受阻。
核心原因: SLB(七层负载均衡,基于OpenResty)的一个Lua模块(lua-resty-balancer
)在处理特定输入(权重为字符串"0")时,因Lua的动态类型转换特性和 % 0
数学运算产生 nan
(Not a Number),导致其中的 _gcd
函数陷入死循环,Nginx worker进程CPU 100%,无法处理用户请求。
详细复盘过程:
一、 告警触发与初步响应 (22:52 - 22:57)
22:52:
- 触发: SRE监控系统侦测到大量服务和域名的接入层(SLB)不可用告警。
- 用户侧: 客服收到海量用户反馈(APP/Web无法使用)。
- 内部: B站员工也发现无法访问自家服务。
- 初步判断: 基于告警,SRE首先怀疑是底层基础设施问题:物理机房故障?网络中断?四层LB故障?七层SLB故障?
- 行动: 紧急启动语音应急会议,召集网络、机房、四层LB、七层SLB等相关团队人员。
22:55:
- 遇阻: 远程在家响应的工程师尝试登录VPN后,发现无法通过内部统一鉴权系统认证。
- 原因: 内部鉴权系统的部分认证流程依赖的域名,恰好也由故障的主机房SLB集群代理。SLB故障 -> 鉴权依赖域名不可用 -> 无法获取登录态 -> 无法访问监控、日志等内部排障系统。
- 关键影响: 延误了远程人员接入内部系统进行问题定位的时间。
22:57:
- 突破: 在公司值班(Oncall)的SRE同学(无需VPN和二次鉴权)成功访问内部系统。
- 关键发现: 监控显示主机房的七层SLB集群所有节点CPU利用率飙升至100%。
- 初步结论: 确认故障点在接入层的七层SLB。暂时排除SLB之下的业务服务本身问题(因为请求根本到不了业务层)。基础设施(机房、网络、四层LB)反馈正常。
二、 故障定位与初步止损尝试 (22:57 - 23:55)
23:07:
- 打通路径: 远程人员联系上负责VPN和内网鉴权系统的同学,获知并开始使用“绿色通道”(一种备用或紧急访问机制)登录内网。
23:17:
- 人员到位: 负责七层SLB、四层LB、CDN的核心处理人员通过绿色通道陆续接入系统,应急响应团队核心力量集结完毕。
23:20:
- 主机房SLB操作:
- 分析: SLB运维观察到故障发生时流量有突增,初步怀疑是流量过载导致SLB雪崩。
- 尝试1 (Reload): 执行 Nginx Reload(平滑重启worker进程)。结果:失败,CPU依然100%。
- 尝试2 (冷重启): 拒绝入口流量,强制重启整个Nginx服务(冷重启)。结果:失败,重启后CPU迅速再次飙升至100%。
- 判断: 问题并非简单的过载或配置加载问题,可能存在更深层次的Bug。
- 主机房SLB操作:
23:22:
- 多活机房状况: 用户反馈和监控显示,多活机房承载的服务也不可用。
- 分析: SLB运维检查多活机房的SLB集群,发现请求大量超时,但CPU并未100%(负载不高)。
- 推测: 多活机房SLB可能因主机房故障导致的大量CDN回源重试和用户客户端重试流量冲击,连接数暴增(事后分析增长百倍达千万级),虽然CPU未满,但处理能力达到瓶颈,导致超时。其日常CPU负载30%,冗余不足两倍。
- 尝试 (多活SLB重启): 准备重启多活机房SLB,尝试恢复部分服务。
23:23:
- 意外恢复: 在准备重启多活SLB时,内部群有同学反馈主站服务(部分)已恢复。
- 观察: 监控显示多活机房SLB的请求超时数急剧下降,业务成功率回升至50%以上。
- 结果: 部署在多活机房且正确配置了多活调度的核心功能(如APP推荐、播放、评论弹幕、动态、追番影视等)基本恢复。
- 遗留问题: 未做多活的服务,以及多活服务中的非读接口(如写操作)仍然不可用。
23:25 - 23:55:
- 主机房SLB排查 (回滚变更): 核心问题仍在主机房SLB。团队开始怀疑近期变更引入Bug。
- 工具: 使用
perf
等工具分析CPU热点,发现集中在 Lua 函数调用上。 - 怀疑1 (WAF): 近期配合安全团队上线了自研Lua WAF。尝试: 移除WAF配置后重启SLB。结果:失败,未恢复。
- 怀疑2 (重试逻辑): 两周前优化了
balance_by_lua
阶段的重试逻辑(避免重试到坏节点,含最多10次循环)。尝试: 回滚此Lua代码后重启SLB。结果:失败,未恢复。 - 怀疑3 (HTTP/2): 一周前灰度上线了HTTP/2支持。尝试: 移除H2相关配置后重启SLB。结果:失败,未恢复。
- 工具: 使用
- 结论: 近期主要变更回滚均无效,问题可能隐藏更深或存在时间更长。
- 主机房SLB排查 (回滚变更): 核心问题仍在主机房SLB。团队开始怀疑近期变更引入Bug。
三、 最终止损:新建集群与流量切换 (00:00 - 01:50)
00:00:
- 决策: 既然无法快速修复现有主机房SLB集群,且回滚无效,决定采取最终预案:新建一套全新的SLB集群,并将故障业务流量从旧集群切到新集群,实现故障隔离。
00:20 - 01:00:
- 执行:
- SLB团队开始在新机器上初始化SLB环境和基础配置。
- 协调四层LB团队配置新的公网IP和对应的四层负载均衡规则。
- 新集群基础功能测试。
- 挑战: 该流程涉及SLB、四层LB、CDN三个团队协作,且之前缺乏全链路演练,元信息(如四层LB的Real Server列表、公网线路选择、CDN回源IP更新等)需手动协调传递,效率低下。
- 执行:
01:00:
- 新集群就绪: 新SLB集群部署和测试完成。
- 开始切量: CDN团队开始配合,将业务流量逐步切向新的SLB集群公网IP。切量工作由业务SRE协助执行(SLB核心运维继续排查根因)。
01:18:
- 首个恢复: 直播业务流量切换到新集群,直播业务恢复正常。
01:40:
- 核心业务恢复: 主站、电商、漫画、支付等核心业务流量陆续切换到新集群,业务恢复正常。
01:50:
- 基本恢复: 在线核心业务基本全部切换到新集群并恢复服务。故障主体时段结束。
四、 根因定位与临时修复 (01:00 - 02:07,与切量并行)
01:00 - 01:27:
- 深入分析: SLB运维在新集群搭建的同时,继续在故障集群上分析CPU 100%问题。
- 工具: 使用更专业的Lua程序分析工具(如
stap++
或 OpenResty 的ngx.timer
+ debug profiling)生成详细的火焰图。 - 聚焦: 火焰图清晰显示CPU热点极其集中在
lua-resty-balancer
模块的相关调用上。
01:28 - 01:38:
- 代码追踪: 从SLB流量入口逻辑开始,逐步分析到底层
lua-resty-balancer
模块调用链。 - 锁定嫌疑: 定位到该模块内几个可能存在热点的函数。
- 加日志: 选择一台故障SLB节点,在这些嫌疑函数(尤其是
_gcd
)入口和出口添加详细的ngx.log(ngx.ERR, ...)
调试日志,记录入参和返回值。重启该节点Nginx进程观察日志。
- 代码追踪: 从SLB流量入口逻辑开始,逐步分析到底层
01:39 - 01:58:
- 关键日志发现: 分析新产生的Debug日志,发现:
lua-resty-balancer
模块中的_gcd
(最大公约数) 函数在某次执行后,返回了一个非预期的值:nan
(Not A Number)。- 触发条件: 同时发现,这种情况发生在处理某个权重(weight)为0的容器IP时。具体来说,注册中心同步过来的权重值是字符串类型的 “0”。
- 关键日志发现: 分析新产生的Debug日志,发现:
01:59 - 02:06:
- 根因推测:
- Lua是动态类型语言,
_gcd
函数未校验入参b
的类型,允许传入字符串"0"
。 - Lua在对数字字符串进行算术运算时会尝试自动转换成数字。
- 关键在于
_gcd
函数内部逻辑(可能涉及取模%
运算)。当b
是字符串"0"
时,在某个运算(如a % b
)中,Lua将"0"
转为数字0
。执行a % 0
会产生nan
。 _gcd
函数如果收到nan
作为参数,并且其递归/循环逻辑未正确处理nan
(因为nan
不等于任何值,包括它自己),可能导致无限递归或死循环。原代码中if b == 0
的判断对字符串"0"
不成立("0" != 0
),但对nan
也可能无法正常终止循环。
- 最初误判: 当时团队怀疑是
nan
值触发了 LuaJIT (Just-In-Time compiler) 的某个 Bug,导致JIT编译后的代码陷入死循环。
- Lua是动态类型语言,
- 临时解决方案: 基于JIT Bug的假设,决定全局关闭 LuaJIT 编译,让Lua代码在解释模式下运行。
- 根因推测:
02:07:
- 执行修复: SLB运维修改所有故障SLB节点的Nginx配置,添加
jit off;
指令,然后分批重启Nginx进程。 - 结果: 所有旧集群SLB节点的CPU使用率恢复正常,可以处理请求了。
- 保留现场: 同时
gcore
了一个异常进程的内存镜像,供后续离线分析。
- 执行修复: SLB运维修改所有故障SLB节点的Nginx配置,添加
五、 事后深入分析与长久解决 (02:07 - 次日 20:00)
02:31 - 03:50:
- 风险规避: 为保险起见,SLB运维对其他所有正常运行的SLB集群(包括新建的集群和多活集群)也修改配置,临时关闭了JIT编译。
次日 11:40:
- 线下复现: 在线下测试环境中,成功构造出
weight="0"
的场景,复现了CPU 100%的Bug。 - 惊人发现: 进一步测试发现,即使关闭了JIT编译,该Bug依然存在! 这证明了之前的判断(JIT Bug)是错误的。根因在于
_gcd
函数本身的逻辑缺陷,在收到特定输入(字符串"0" ->nan
)时会死循环,与JIT无关。 - 诱因确认: 再次确认问题诱因:某种特殊的应用发布模式下,会短暂将实例权重设置为0,并通过注册中心同步给SLB(值为字符串"0")。该模式生产环境使用频率极低,之前的灰度测试未覆盖到。
- 线下复现: 在线下测试环境中,成功构造出
次日 12:30:
- 内部讨论与决策: 认识到问题并未彻底解决(关闭JIT只是碰巧在诱因消失后操作,显得有效),SLB仍有巨大风险。最终决定:
- 平台策略: 禁止使用会导致
weight=0
的那种特殊发布模式。 - SLB代码加固: SLB暂时修改Lua代码,强制忽略注册中心返回的权重值,或者对权重值做严格的类型和范围校验(例如,将非正数权重视为无效或赋予默认值1)。
- 平台策略: 禁止使用会导致
- 内部讨论与决策: 认识到问题并未彻底解决(关闭JIT只是碰巧在诱因消失后操作,显得有效),SLB仍有巨大风险。最终决定:
次日 13:24:
- 执行1: 发布平台完成修改,禁用该特殊发布模式。
次日 14:06:
- 执行2: SLB Lua代码完成修改(如忽略注册中心权重,或增加健壮性处理)。
次日 14:30:
- 验证: 新的SLB Lua代码在UAT(用户验收测试)环境发布升级,并反复验证节点权重处理符合预期,问题不再复现。
次日 15:00 - 20:00:
- 生产发布: 经过验证的修复代码,在生产环境的所有SLB集群(包括旧集群、新集群、多活集群)进行灰度发布并最终全量升级完成。同时可能重新开启了JIT。
六、 关键问题反思与解答
为何故障初期无法登陆内网后台?
- 内网统一鉴权系统为了在多个内部域下种Cookie,其认证流程中有一个跳转依赖的域名是由故障的主机房SLB代理的。SLB瘫痪导致该域名无法访问,鉴权流程中断,用户无法获取完整的登录态,进而无法访问需要登录态的其他内网系统。这是典型的运维基础设施依赖业务基础设施的问题。
为何多活SLB初期也不可用?
- 主机房SLB故障后,CDN的大量回源请求(因无法从主站获取内容)和用户App/浏览器的大量重试请求,全部涌向了多活机房。
- 多活机房SLB集群的容量冗余不足(日常CPU 30%,Buffer < 2倍),无法承受瞬间增长4倍以上的流量和100倍的连接数(峰值达1000W)。导致连接数被打满,请求处理严重超时,表现为服务不可用。
- 重启操作可能释放了部分连接或资源,加上部分CDN可能因持续失败降低了回源频率,使得多活SLB在23:23左右逐渐恢复处理能力。
为何回滚无效后才选择新建集群,而非并行操作?
- SLB团队规模小(当时1位平台开发+1位组件运维)。故障时虽然有其他SRE支援,但SLB核心组件的变更(如代码回滚、配置修改、重启)高度依赖这位核心运维同学的操作或Review。单点人力瓶颈导致难以并行执行复杂操作(一边排查回滚,一边搭建新集群)。
为何新建集群+切流耗时这么久(近2小时)?
- 缺乏自动化和编排: 涉及SLB(机器、配置初始化)、四层LB(公网IP、规则配置)、CDN(修改回源IP、刷新缓存、切量)三大块,跨团队协作。
- 缺乏演练: 虽然SLB内部演练过机器和配置初始化,但从未进行过跨团队、全链路的新建集群并切换流量的演练。
- 信息孤岛: 各平台间元信息(如新SLB的IP列表、所需公网IP属性、CDN回源配置等)没有打通和联动,需要大量人工沟通和手动配置。
关闭JIT是如何“恢复”旧集群的?
- 这是一个巧合。真正的诱因(某服务特殊发布模式导致
weight="0"
)在 01:45 左右已经随着该服务发布完成而自然消失了。 - SLB运维在 02:07 左右执行关闭JIT并重启进程。此时诱因已不在,SLB重启后自然恢复正常。关闭JIT的操作本身并未修复由
_gcd
函数逻辑缺陷引发的死循环问题。 - 但即使当时诱因未消失,团队也已通过日志定位到
weight=0
这个关键信息,很可能也能顺藤摸瓜找到对应的服务及其发布模式,从而定位根因。
- 这是一个巧合。真正的诱因(某服务特殊发布模式导致
七、 核心教训与改进方向提炼
多活建设是生命线,但需做深做透:
- 能力: 基础组件(接入层、存储同步、消息队列)需全面支持多活;需明确机房定位(Czone/Gzone/Rzone);CDN需支持更精细调度(用户分片);要支持写请求的多活。
- 管理: 需要平台化管理多活元信息(哪些业务、类型、URL规则、调度策略),而非文档维护。
- 容灾: 切量需要平台化、自动化、可视化、可编排,降低对单一团队(CDN)和人工操作的依赖,实现一键式、可预检、可观测的全链路切换。直播首页接口多活但未配置调度是典型案例。
SLB治理刻不容缓:
- 架构: 按业务/重要性拆分SLB集群和公网IP,实现故障隔离。明确SLB职责边界,非核心能力(如动态权重灰度)下沉到API网关。
- 运维: 平台化管理Lua代码版本(易回滚);自动化SLB资源申请、初始化、上线流程(联动四层LB/IPAM),目标5分钟内完成;提升容量冗余(CPU降至15%),压测连接数极限;优化CDN回源策略(超时时间)。
- 自研/代码质量: 引入专业测试团队对核心组件做异常输入测试(如类型、边界值、
nil
、nan
);Review开源库源码;提升Lua代码健壮性(类型检查、强制转换、错误处理);招聘专业LB开发人才(Nginx C模块/C++/系统底层)。
故障演练必须常态化、实战化:
- 模拟机房级故障(CDN回源故障、单机房网络隔离),端到端(业务APP/Web -> CDN -> 接入层 -> 服务 -> 存储)检验多活效果。
- 使用真实用户流量(灰度)或模拟流量,在演练环境中触发故障场景,检验CDN、源站的实际容灾表现。
- 通过多活管控平台,定期演练业务的多活切换预案。
应急响应机制需要优化:
- 设立故障指挥官角色,与故障处理人分离,负责信息通报、资源协调,减轻处理人压力。明确角色职责并强制执行。
- 建设故障通告平台,标准化、便捷化故障信息录入与同步。
- 强化事件分析平台能力:不仅面向应用,还要能关联平台(发布系统、注册中心)、组件(SLB)、用户等多维度事件,快速溯源变更,定位诱因。推动核心平台(控制面)和底层组件(数据面,如SLB IP/权重变更)上报事件。
总结:
这次"713"事故是B站技术体系一次深刻的教训。它暴露了在高速发展过程中,核心基础设施的健壮性、多活架构的完善度、运维自动化水平、应急响应流程以及质量保障体系等方面存在的短板。事故的根因是一个看似微小的Lua代码处理疏忽,在特定条件下被罕见场景触发,最终通过核心接入层SLB放大了影响,导致了全站级别的服务中断。
尽管过程痛苦,但事故也验证了多活架构在部分场景下的有效性,并强力推动了后续一系列深入的技术和管理优化。复盘的价值在于直面问题,吸取教训,并将改进措施真正落地,持续提升系统的稳定性和韧性。