上个月有个周二晚上,我正准备下班,监控突然报警了。我们用WebAssembly做的视频处理模块在线上崩溃了,错误率飙升到了40%。
我赶紧打开电脑开始排查,这一查就是一整夜,直到第二天早上八点才找到根因。这篇文章记录一下这次排查的完整过程。
故障现象
先说说故障现象。我们的视频处理服务用Rust写了核心算法,编译成WebAssembly在Node.js环境里运行,用来做视频的滤镜和转码。
那天晚上突然有大量用户反馈视频处理失败,错误日志里全是"RuntimeError: memory access out of bounds"和"unreachable"的报错。
最诡异的是,这个错误不是所有视频都有,大概40%的视频会触发,而且集中在特定分辨率的视频上。1080p的视频正常,720p的视频有一半会崩溃,480p的视频全部崩溃。
初步排查
第一步先看最近的发布记录。我们当天下午发布了一个新版本,升级了WASM的编译工具链,从wasm-pack 0.10升级到了0.12。
第一反应就是新版本有问题,赶紧回滚。回滚之后错误率降下来了,但没有完全消失,还有10%左右的错误率。这说明不只是新版本的问题,老版本也有问题,只是新版本让问题更严重了。
回滚只能临时缓解,根因还没找到。我开始深入排查。
先在本地复现。拿一个会崩溃的480p视频在本地跑,确实能复现。错误是在WASM执行过程中发生的内存越界访问。
内存越界一般是指针问题,或者是内存分配的问题。我开始检查Rust代码里的指针操作和内存管理。
深入排查
Rust代码里没有unsafe块,理论上不应该有内存越界。那问题可能出在WASM和JS的交互上。
我们的WASM模块和JS之间通过共享内存传递数据。JS把视频帧数据写到WASM的线性内存里,WASM读取数据进行处理,处理完再写回内存,JS读取结果。
我开始检查内存分配的逻辑。WASM的线性内存默认是16MB,我们在初始化的时候会申请一块内存用来存放视频帧数据。视频帧的大小是宽乘高乘4(RGBA格式),1080p是1920x1080x4,大约8MB;720p是1280x720x4,大约3.5MB;480p是854x480x4,大约1.6MB。
看起来480p的视频帧最小,不应该内存越界啊。但事实就是480p全部崩溃,1080p反而正常。
我加了一些日志,打印内存分配的地址和大小。发现了一个奇怪的现象:480p视频分配的内存地址比1080p的还大,而且有时候会超过线性内存的边界。
找到线索
这个现象很反常,小视频反而分配了更大的内存地址。我开始怀疑是内存分配器的问题。
我们用的是Rust默认的wee_alloc分配器,这是一个专为WASM设计的轻量级分配器,代码体积小但功能也简单。
我查了weealloc的文档和issue,发现了一个已知的bug:在某些特定的分配和释放顺序下,weealloc会产生内存碎片,导致后续的大内存分配失败,返回空指针。而我们的代码没有检查分配结果,空指针解引用就导致了内存越界。
为什么小视频更容易触发?因为小视频处理快,分配和释放的频率更高,更容易产生内存碎片。大视频处理慢,分配释放频率低,反而不容易触发。
为什么新版本更严重?因为wasm-pack 0.12升级了默认的wee_alloc版本,新版本的分配策略有变化,更容易产生碎片。
验证根因
找到线索之后,我开始验证。
第一步,把weealloc换成系统默认的分配器。Rust在WASM目标下默认用的是dlmalloc,比weealloc功能完善,但代码体积大一些。
改完之后重新编译,在本地测试,480p的视频全部正常了,没有再崩溃。线上灰度了一小部分流量,错误率也降到了0。
第二步,复现内存碎片的问题。我写了个测试程序,模拟频繁分配释放小内存,然后分配大内存,用wee_alloc的时候确实会分配失败,用dlmalloc就正常。
根因确认了:wee_alloc在频繁分配释放的场景下会产生内存碎片,导致后续分配失败返回空指针,代码没有空指针检查就导致了内存越界崩溃。
修复上线
找到根因之后,修复方案就简单了。
第一步,把分配器从wee_alloc换成dlmalloc。代码体积增加了大约30KB,但稳定性大大提升。
第二步,在所有内存分配的地方加上空指针检查。如果分配失败,返回错误而不是继续执行,避免崩溃。
第三步,加了内存使用监控。定期检查WASM线性内存的使用情况,如果使用率超过80%就告警,提前发现问题。
修复完成后,当天晚上发布了新版本。观察了一夜,错误率为0,没有再出现崩溃。
这次故障从发现到完全修复花了大约20个小时,其中排查根因花了大部分时间。
经验教训
这次故障给了我很多教训。
第一个教训是不要盲目追求代码体积小。weealloc比dlmalloc小大约30KB,但稳定性差很多。对于服务端应用来说,30KB的体积差异根本不重要,稳定性才是第一位的。weealloc更适合浏览器端对体积敏感的场景,服务端用dlmalloc更稳妥。
第二个教训是所有外部调用都要做错误检查。WASM的内存分配可能失败,文件操作可能失败,网络请求可能失败,任何可能失败的地方都要检查返回值。不要假设调用一定成功,墨菲定律告诉我们,会出错的迟早会出错。
第三个教训是升级依赖要谨慎。wasm-pack从0.10升级到0.12,看起来是小版本升级,但底层的分配器变了,行为也变了。升级核心依赖之后要做充分的测试,特别是边界条件和压力测试。
第四个教训是监控要更细粒度。我们的监控只统计了错误率,没有按视频分辨率、视频时长、处理时间等维度细分。如果一开始就能看到错误集中在小分辨率视频上,排查速度会快很多。
第五个教训是WASM的调试工具还不够完善。在WASM里调试内存问题比在原生代码里困难很多,没有gdb那样强大的工具,只能靠加日志一点点猜。建议在开发阶段就做好日志和错误处理,不要等到线上出问题了才开始调试。
WASM开发的建议
经过这次故障,我总结了一些WASM开发的建议。
第一,选择合适的分配器。如果对体积不敏感,用默认的dlmalloc就好。如果对体积敏感,可以用wee_alloc,但要做好充分的压力测试。
第二,做好内存管理。WASM的线性内存是有限的,默认16MB,可以手动增长但有上限。要合理规划内存使用,避免内存泄漏和内存碎片。
第三,做好错误处理。WASM里的错误不会像JS那样抛异常,很多时候是返回错误码或者直接崩溃。要在关键路径上做好错误检查和日志记录。
第四,做好测试。WASM的测试比普通代码难写,但还是要写。特别是边界条件测试、压力测试、长时间运行测试,这些能帮你发现很多潜在问题。
第五,保持工具链稳定。WASM的生态还在快速发展,工具链更新频繁。不要盲目追新,稳定的工具链比最新的工具链更重要。升级之前先看changelog,了解有哪些破坏性变更。
写在最后
排查了一夜,虽然很累,但收获很大。WebAssembly是个很有前途的技术,但它还不够成熟,坑还很多。
每一次线上故障都是一次学习的机会。这次故障让我对WASM的内存管理有了更深的理解,也让我对线上服务的稳定性有了更多的敬畏。
做后端开发久了,会遇到各种各样奇怪的bug。有时候bug不在你的业务代码里,而在你依赖的底层库里。这种bug最难排查,但也最能锻炼能力。
希望这篇文章能给正在用WASM的朋友一些启发。线上故障不可怕,可怕的是故障之后没有总结,下次还犯同样的错误。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录