作者用 Go 将 RTSP 流转码为 HLS,在单 CPU 核心上实现 8 Gbps 吞吐,并详细描述了雷群(Thundering Herd)问题的成因与应对策略,有具体性能数据和解决方案。
我一直有一个副项目——把原始 RTSP 流转成 HLS。说白了,目标很简单:让普通用户直接观看摄像头的画面,而不必使用摄像头厂商的私有云软件。原因如下:首先,这很费钱,因为很多客户想长期保存录像。其次,几乎所有摄像头厂商的软件都不一样,同时装 10 个不同的 App 或门户去看摄像头,真的很麻烦。第三,很少有厂商提供 AI 集成能力(这对我的客户来说至关重要)。就算有集成,要么高度专业化、再次私有化,要么价格高昂——有时候三者兼而有之。解决方案是使用原始 RTSP 流,市场上 90% 的摄像头都支持并提供 RTSP 流。
所以架构很简单:几个摄像头 + 一个简洁的后端,通过 HLS 向客户端提供视频流,用 hls.js 做播放器。大家都挺满意——快、简单、方便、便宜。测试也跑得很好,客户也满意了。每个客户都有单独的对接群,观看链接就在里面,还有一个汇总群,用来讨论连接问题、财务事宜等。然后真相时刻到了:有人不小心把链接丢进了汇总群……然后就炸了。显然,整个群的人都在点那个链接。3 分钟后,机器人的告警开始狂飞:alarm、achtung、panic。检查硬件:服务器宕了,OOM,所有流都断了。20 分钟后,群里炸满了愤怒的消息,说谁都看不了。落幕。
欢迎来到"惊群"(Thundering Herd)问题。
如果你做过直播视频服务,一定知道这个问题。当成百上千的观众同时观看同一路流时,标准服务器的逻辑很简单——为每个观众打开新连接或派生独立进程。其结果总是一样:OOM,CPU 挂掉。最简单的解决方案是租用更强大的云实例或购买新硬件。有句老话说"能用钱解决的问题就不是问题",但问题是真的没钱。何况我们是工程师,就当是抛给你专业能力的挑战吧。于是想到用 GO 从头写一个引擎。便宜,而且能压出最高性能。说实话,用 GO 写自己的东西——很酷、很潮、很 hip。剧透一下:最终我们在单 CPU 核心上压出了 8.8 Gbps,而垃圾回收器全程基本上在休假。
这就是 Ruseon Core 的构建过程。
实话实说,FFmpeg 在视频处理领域就是"公理",确实很厉害,某种程度上是行业标准。但我的天,它太能"吃"了。光是为转播启动一百个 FFmpeg 进程就等于自杀。我们恰恰就是想摆脱 FFmpeg 的这些问题。即便把它用作接收代理,开销也会把所有好处抵消掉。寻找方案时发现了 MediaMTX,是个很棒的项目,基本上解决了我们的"核心"问题。但是(我觉得"但是"快成我最喜欢的词了,嘿嘿),我们需要与 AI 流水线的无缝集成,以及存档录制。开箱即用没有这些,写插件或包装器又很费时间。再说,既然已经决定从概念上解决问题了,那用现成的有什么意义?而且,跟客户吹嘘"我们只用自研专有技术"也是个好买卖。在其他工程师和公司眼里也更有分量。所以我们从零写了一个引擎,同时尽量保持"模块化"。意味着可以嵌入使用,或作为 SDK 使用。开发的核心原则——绝对不做转码(只搬运字节),最小开销,最大性能。
当新的一帧从摄像头到来时(通常是 H.264,不过我们也实现了 H.265 编码器),它只是一块字节数据。设想一个"绝妙"的主意:把这块数据拷贝到独立响应缓冲区,分发给成千上万的观众。想明白了吗?在 GO 的世界里——这是一条通往成功的路(讽刺拉满)。垃圾回收器会极度兴奋,拼命处理这座垃圾山。CPU 不去推流,而是全部忙于清理工作。我们决定走另一条路。实现了一个零拷贝环形缓冲区,并给它绑了一个 sync.Pool。
理论上的工作流程:一帧从摄像头到来。从预分配的池子里取一个空字节缓冲区。写入这一帧(只写一次!)。把这块缓冲区的指针分发给一千个观众。当所有人读完后——把缓冲区归还池子。
无内存分配。无 GC 停顿。250 MB 内存,100 路流只占 1-2% CPU。
基准测试结果如下:
BenchmarkWriteFrame-12 13.9 ns/op 0 B/op 0 allocs/op
在这里你能体验到真正的工程"高潮"。视频推流时,每次操作零数据量。当处理器在处理数万帧时,堆依然保持在最低占用。
我给你们讲了一切是如何实现的,但我们都爱数字(尤其是银行账户里的数字)。写高性能代码很酷,但你需要知道它在实际中表现如何,以及极限在哪里。压测我们选用了 Grafana 的 k6;它能精确模拟引发整个项目的那个问题。测试场景:1000 个用户狂请求我们的 muxer,不断下载播放列表(index.m3u8),以服务器允许的最快速度抓取兆字节级的 .ts 分片。原本以为瓶颈会在 300 用户开始出现……但好在我想错了。
在台式机 Ryzen 5600x 的单核上压测 70 秒:
data_received..................: 81 GB 1.1 GB/s
http_req_failed................: 0.00% ✓ 0 ✗ 60822
http_req_duration..............: avg=3.13ms p(95)=6.13ms
也就是 8.8 Gbps 吞吐量(Carl!)。6 万个成功 HTTP 响应。没有一个断开的连接。数字很漂亮,但问题在哪?很简单,HLS 分片就放在内存里。当大量请求某个分片时,服务器直接从缓存里吐出相同的字节。就这么简单。磁盘在休息,没有重新打包,处理器在点威士忌加可乐。
关于无限缓冲积累的问题会冒出来,但不会出现无限缓冲积累(以及后续的 OOM),因为实现了一些保护机制:基础 Go 网络栈(net/http),它本质上运行在投递层。服务器直接把一块静态内存交给 socket。仅此而已。客户端下载快还是慢——无所谓。
但在核心层(Ring.go)保护实现更有意思——在核心内部,订阅者(HLS Muxer 或 AI worker)通过 Go 的 channel 读取帧。写入 channel 实现的非阻塞发送(通过 select + default)。channel 有严格定义的深度。如果某个订阅者落后了,它的 channel 就会堵塞,核心不会等待它,也不会分配新内存。此时 default 分支发挥作用——针对那个特定订阅者丢弃这一帧。很遗憾,在这里你必须"牺牲"一个差劲的客户端,来保全比如 1000 个好的客户端。还有一个常见问题——重同步和滞后,解决方式如下。如果订阅者因为落后在核心内部丢弃了一帧,就不能把下一个 P-frame 交给它,否则画面就会崩溃(顺便说一句,gortsplib——项目中使用的 RTSP 库——也提到了这一点)。在这种情况下,核心为该订阅者设置 NeedsIFrame 标志。订阅者静默等待下一个 I-frame(关键帧)到来;之后从那里干净利落地恢复读取,最小化损失。在实际运行中,这个过程只需几毫秒,最终用户基本感知不到。何况 muxer 在内存中维护了一个"滑动窗口"——最近 5 个分片。如果最终用户的连接真的很差,尝试下载一个过时的分片时会得到 404。客户端的播放器(99% 的情况下用户用的是 hls.js 或其衍生实现)捕获 404,意识到自己落后于直播流,然后跳到当前分片,与其他人同步。
还有一个重要点:HLS 协议是 PULL 实现。这意味着不会发生无限滞后累积。那种情况发生在服务器试图往 socket 里塞数据时,socket 因为客户端网络差而阻塞,包在队列里堆积,等网络"清嗓子"时,客户端开始看 10 分钟前的视频。这里的机制不同:Muxer 在内存中维护一个滑动窗口(Live Playlist)——比如只有最近 5 个分片(假设 10 秒视频)。更老的分片被永久删除。一个连接极差的客户端拉取第 100 个分片超过 3 分钟。下好了,播放器请求下一个分片 #101。但服务器上最新的是 200。第 101 个分片物理上已经不在内存中了。服务器老老实实返回 404。播放器捕获 404,下载一份新的 index.m3u8,发现当前分片已经是 200 了,然后跳到直播边缘。
网络差的客户端只会看到不断"快进"和缓冲(网络死了这很合理),但不会迫使我们的服务器为他们个人存储 10 分钟的缓存。
在这个实现里,你的 CPU 基本上在摸鱼,负载 1-2%。但同时,网络负载是巨大的。根据测试,9 Gbps 基本上是万兆网卡的极限。代码没问题,但网卡开始丢包了。物理世界有它的极限。另外不要忘了,当我们说 100 流 0 B/op 和 250 MB 内存占用时,这只指的是用户态内存(堆),也就是垃圾回收器负责的那部分。Socket 缓冲区去哪了?成百上千个连接会吃掉它们应有的 tcp_mem 兆字节内存。这更多描述的是问题不会在软件层面倍增——也就是说我们不是在 GO 内部分配数 GB 的结构。你得明白,操作系统内存是网络场景下不可避免的税。最棘手的部分是不能搞砸 sync.Pool 的实现。否则你的画面就会崩溃,某些帧会直接变绿。就一句话:如果你不需要转码,或者需要最小化硬件负载——就不应该使用 FFmpeg 之流的重型武器。控制你的内存。搬运字节但不拷贝源码和压测脚本位于 Ruseon Core 仓库的 benchmarks/ 目录下。去试试把你的电脑搞崩吧(我弄崩过不止一次)。