做视频接入这一行的,早晚会遇到一个绕不过去的需求:手里有一路RTSP流,却要让十几家下游同时拿到它。
我最初处理这个需求的方式很原始,直接让所有下游各自去连源头设备。摄像头端先扛不住,接着交换机和带宽也开始报警,最要命的是,同一路流被拉取多次后,源头设备的响应会明显变慢。折腾了一段时间之后,我决定自己动手写一个基于Live555的高性能RTSP流媒体转发服务,项目代号叫HYMMService。它不做转码,只负责一件事:把上游的RTSP流接进来,再以RTSP协议分发给任意数量的下游。如果你也面临类似的多路分发、源端限拉、带宽控制问题,这篇文章应该能帮你少走不少弯路。
1. 先梳理清楚:市面上现成的东西到底缺在哪
1.1 最朴素的多路分发做法是怎么扛不住的
先说最常见的场景。一台网络摄像头输出一路RTSP流,监控平台要收一份,AI分析程序要收一份,手机预览还要一份。一开始图省事,直接把摄像头地址复制三份发给下游,大家各拉各的。表面上看架构简单,实际上坑全在后面。
源头设备的压力是最先暴露出来的。摄像头这类嵌入式设备,解码能力和网络栈资源非常有限,我实测过好几款主流型号,超过四五个并发拉流,画面就开始卡顿,有的型号甚至会主动断开所有会话。更麻烦的是,很多摄像头厂商的固件里,RTSP服务线程是共用的,一个会话卡住,其他所有会话跟着遭殃,正常的录像存储都可能被影响。
公网带宽的浪费是第二层隐藏成本。如果下游在异地接入,每一路都是独立占用源端上行带宽。三路下游就是三倍的上行消耗,而且这个消耗是纯粹重复的,因为三路下游看到的画面完全一样。等下游数量上去之后,摄像头所在局域网的出口带宽会被这类无谓的流量占满,真正重要的业务反而不够带宽用。
鉴权和稳定性也没法统一管理。直连模式下,每个下游都要自己保存摄像头密码,密码一旦轮换,所有下游的配置都要同步改。某一路下游如果出现异常断连、反复重连,甚至能把源头设备直接折腾到死机,其他正常业务也被拖下水。
这个时候就需要一个"中间商"。这个中间商从源头拉一路流,然后在本地维护一个流缓存和一套订阅名单,所有下游都来连中间商。源头永远只面对一个会话,压力恒定。下游想加多少个就加多少个,只要中间商机器的带宽和性能允许。这就是HYMMService立项时的核心逻辑:把多路分发这个复杂问题,收敛成"一路采集+多路派发"两个简单问题的组合。
1.2 转了一圈,为什么最后落到Live555上
立项时我对比了几条路线:自研协议栈、用重型流媒体服务器、在协议库基础上二次开发。
自研协议栈这条路线第一个被放弃。RTSP虽然信令部分不复杂,但RTP/RTCP的封包时序、NTP时间戳换算、SSRC冲突处理这些细节,真要自己从裸socket写起,开发周期至少按月算,而且测试成本极高。流媒体协议最恶心的地方在于边界条件特别多:丢包怎么反馈、时间戳跳变怎么处理、SSRC冲突怎么规避,这些没有一个现成的实现作为参照,自己写出来没法保证正确性。
重型流媒体服务器功能确实全,但正是因为功能全,引入了一整套复杂的架构。配置项多到能写一本书,改一行配置要翻半天文档。我当时的核心诉求非常单一:把上游RTSP流接进来,原样分发给下游,不需要转封装、不需要录制、不需要协议转换。为了这一个功能,去学它那套庞大的配置体系和模块管理,明显是杀鸡用牛刀。而且这类方案通常有重型的运行时依赖,部署环境要求高,对于很多只需要轻量接入的小项目来说不够友好。
最终选择了在Live555上二次开发,理由很实际:
- 它是C++写的事件驱动协议栈,RTSP信令、RTP打包、RTCP反馈、SDP会话描述这些基础能力都齐了,接口清晰。
- 依赖极少,编译产物就是一个静态库加几个头文件,跨平台也方便。
- 社区存在时间长,协议细节不懂的时候能查到不少讨论,踩坑资料相对多。
不过这里要提前说明:Live555官方自带的是播放器和单路服务器示例,行为偏"协议演示",直接拿来做高并发转发服务是扛不住的。HYMMService真正做的工作,是在Live555的基础协议能力之上,重新设计了会话管理、内存策略和分发模型。换句话说,Live555负责的是"一个RTSP会话内部怎么正确交互",HYMMService负责的是"一堆会话之间怎么高效协调"。这两件事,缺一不可。
2. HYMMService的整体设计:一个只管转发的中间商
2.1 转发模型:单路采集、多路分发
HYMMService的转发模型可以用一句话概括:一个上游源对应一个采集会话,整个服务内只维护一份该源的RTP数据流;下游所有客户端都作为这个源的订阅者,共享这一份数据。
具体拆开是三个角色:
- 上游源(Source):以RTSP客户端角色,主动连接源头设备的RTSP地址,完整走一遍DESCRIBE、SETUP、PLAY流程,把流数据拉回来。
- 源采集器(SourceSession):每个Source对应一个,负责解析上游SDP、建立RTP传输通道、接收RTP包,并维护源的健康状态。
- 下游订阅者(Subscriber):以RTSP服务器角色,等下游播放器等客户端来连接,每个客户端一个独立会话,完成各自的RTSP信令协商。
整个转发过程中有一个关键取舍:不做转码,不做解复用再复用,不做帧级处理。RTP包从源头到达之后,HYMMService只做头部时间戳校准,然后把原始payload原样转发给所有订阅者。这样做的最大好处是延迟极低。在我们压测里,转发引入的额外时延基本就是一次内存拷贝的时间,在几十微秒级别。缺点是下游拿到的流格式必须和上游完全一致,但这本来就是这个项目的定位——市面上做转码的成熟方案很多,轮不到我来重复造轮子。
我之所以坚持"纯净转发"这个定位,还有一个管理上的考虑:转码会引入转码队列、编码参数、码率控制、关键帧间隔调整等一大摊子事,会让服务的复杂度和故障率同时上升。而把转码拆成独立模块,通过标准协议和HYMMService对接,各自可以独立升级、独立伸缩,这才是更好的工程组织方式。
2.2 会话管理:用两张表管住所有连接
每个SourceSession有一个唯一编号,我用SourceID标识;每个订阅者连接有一个ClientID标识。服务里维护两张映射表。
第一张是源信息表,记录上游源的地址、鉴权信息、当前连接状态、已订阅的下游数量。第二张是订阅关系表,记录ClientID对应的SourceID,以及这个订阅者当前的RTSP信令状态机。
这里有一个容易被忽视的设计细节:订阅者集合不是简单放在一个标准链表或动态数组里。在多线程场景下,某些下游会频繁地连接、断开,如果直接用容器裸操作,会引发大量锁竞争和内存碎片。我们最终选择用数组加原子标记的方式管理订阅者槽位。断开时不删除对象,只是把槽位标记为空闲,新接入的客户端复用空闲槽位。这样既避免了频繁的内存申请释放,也把锁的范围缩小到了最小。
两张表之间的关系是通过引用计数维持的。当一个SourceID不再有任何订阅者时,源采集器并不会立即释放,而是进入一个短暂的"待机状态"。如果短时间内又来了新订阅者,可以直接复用这个仍然热着的采集会话,避免重复连接源端、重复拉流的开销。如果待机超时,才真正释放源会话。这个策略在摄像头这类连接成本较高的场景下特别有用:下游频繁进出的时候,源端不会跟着不断重建会话。
2.3 数据面与控制面分离的线程模型
RTSP服务最常见的性能瓶颈在于:信令处理(控制面)和RTP发送(数据面)混在同一个事件循环里,一路慢速客户端会把整个服务的发送节奏全部拖乱。
HYMMService的线程模型是明确分离的:
- 控制面:每个下游TCP连接一个独立任务,负责RTSP信令交互,包括DESCRIBE、SETUP、PLAY、TEARDOWN。这部分QPS不高,但延迟敏感,适合用事件驱动方式处理。
- 数据面:每路SourceSession单独一个RTP发送任务,它从共享的RTP接收队列里取包,再按订阅者列表逐个投递。这部分是吞吐量大头,需要尽量减少锁和上下文切换。
控制面和数据面之间的通信通过命令队列实现。比如一个新的下游完成PLAY协商后,控制面任务会向数据面任务提交一条"添加订阅者"的命令;客户端TEARDOWN或异常断开时,又会提交一条"移除订阅者"的命令。数据面任务是唯一有权操作发送缓冲区、订阅者槽位的角色,所以它内部的逻辑完全不需要考虑并发问题。这个设计让核心转发路径变成单线程处理,大幅减少了锁冲突。
有朋友问过我:为什么不让控制面直连数据面互相调用,还要多一层命令队列?原因是直连调用会让双方在锁上互相等待。控制面线程如果要拿数据面的发送锁,而数据面线程正好在等控制面线程的某个信令响应,两个线程就死锁了。命令队列本质上是一种单向消息传递,把双向的依赖关系切成两条独立的单向依赖,彻底绕开了死锁的可能。
3. 性能优化:从"能用"做到"能扛"
3.1 内存池与引用计数:告别一层层拷贝
转发服务最容易出现的CPU浪费就是内存拷贝。如果上游每收到一个RTP包就要copy一份给每个订阅者,订阅者数量一多,内存带宽就被吃干净了。
HYMMService的做法是:源采集器收到RTP包后,将包放入一个固定大小的内存池,每个包带一个引用计数。下游投递时,发送任务只增加引用计数,传递一个指针给各订阅者的发送缓冲队列。只有所有订阅者都发送完毕,引用计数归零后,这个包的内存才会回到内存池。
这里的收益在贴近真实场景时非常明显。经常是几十个客户端同时拉同一路流,RTP包只有一个原始内存块,但被多个发送队列共享,内存占用不会随订阅者数量线性增长。从压测数据看,同样的源流和并发数量,内存占用比每订阅者拷贝的方案下降了大约60%。
内存池的初始大小也不是拍脑袋定的。我们是按源码率和服务器的物理内存来估算的。以4Mbps码率的1080P流为例,RTP包平均大约1200字节,每秒约420个包。内存池如果预分配5万个包,就能缓冲约120秒的流数据,足够应付绝大多数慢客户端场景。这个参数在配置里开放出来,方便不同内存规格的机器自行调整。
3.2 发送缓冲区的动态水位:处理快慢客户端并存
第二类常见的坑是慢客户端拖垮服务。有的播放器网络抖动,接收窗口变小,如果发送端不管不顾地往它那个方向塞数据,发送缓冲会越积越高,最后内存耗尽或者把其他正常客户端一起拖死。
HYMMService的做法是给每个订阅者的发送队列定义一个高水位和低水位。发送任务投递前先检查目标队列的积压量:
- 队列深度低于低水位时,正常投递。
- 队列深度超过高水位时,投递逻辑改走丢帧策略:跳过部分非关键帧的RTP包,只保留关键帧继续投递。
- 如果持续超过一个阈值时间,直接把该订阅者标记为慢客户端,暂停投递并发送RTCP反馈。
有人可能会担心跳帧会影响画质,这里需要解释一下:跳帧不是随便跳,是根据RTP的payload类型和帧边界信息来跳的。H.264的RTP载荷里,关键帧对应的包带有明确的帧边界标记,非关键帧丢掉之后,下一帧关键帧到达就能完整恢复画面,播放器最多闪一下,不会长时间花屏。这个策略的本质是保证服务的整体吞吐优先于单一路的质量。实际部署中,慢客户端恢复后再通过I帧请求重新同步画面,效果是可接受的,而整个服务的稳定性得到了大幅提升。
水位参数的调节也做了动态化。最初版本是固定值,后来发现静态水位在码率波动大的场景下不太合适。码率突然从4Mbps跳到8Mbps时,原先的高水位设置会让丢帧过于激进。后来改成按照源当前码率的滑动平均值来动态计算高水位,效果好了很多。
3.3 锁竞争治理:能无锁就无锁
前面提过数据面任务内部是无锁的,但控制面和数据面之间的交互绕不开边界。最初版本我直接用互斥量保护订阅者列表,压测到200路并发时,锁竞争导致的CPU浪费已经能明显感受到,性能曲线不再线性增长。
后来做了一次关键调整:控制面任务的命令不再直接操作订阅者列表,而是写入一个SPSC环形队列,数据面任务每次循环集中消费这批命令后再统一更新列表。这样锁的颗粒度从"每次投递都要拿锁"变成了"每毫秒集中拿一两次锁"。单核上的转发吞吐从每秒几千包提升到了数万包级别,高并发场景下的CPU占用下降了约35%。
顺便说一句,这种命令队列加集中消化的思路也符合实时流媒体的实际节奏:RTP包到达是有固定节奏的,与其让控制面事件随时打断数据面,不如把可延迟的事都放到数据面下一轮循环里统一处理。H264视频一个GOP也就是1到2秒,控制面命令延迟个几毫秒完全不会影响用户体验。
这里还想强调一个工程习惯:性能优化一定要先定位瓶颈再动手,不要上来就套各种花哨技巧。我用perf抓了几次热点,发现最初版本的锁竞争只是表象,真正的底层原因是订阅者操作和数据拷贝耦合在了一起。先把锁的范围缩小,再考虑锁的粒度,最后才是无锁化。每一步都能看到明确的性能变化,心里才有底。
4. 实测数据:转发延迟、并发上限和调参记录
4.1 测试环境和压测方法
先交代压测环境,方便大家对号入座。我们用了两台通用服务器跑测试:一台跑源端模拟程序,输出1080P H.264、4Mbps码率的RTSP流;一台跑HYMMService。另外用若干台压测机开启多进程拉流,模拟不同数量的并发播放器。
压测工具是自写的,每进程创建若干个RTSP客户端,统计每个客户端从发送PLAY请求到首帧到达的间隔,作为首开延迟指标;再用RTP时间戳差计算端到端的转发延迟。为了避免偶然误差,每组数据都跑了5轮,取中位数。
说起来压测工具本身也是个容易翻车的地方。最初用现成的播放工具做并发拉流,它们自身有各种缓存和重连逻辑,测出来的延迟掺了不少杂质。后来自己写轻量客户端,只做最基本的RTSP握手和RTP接收统计,数据干净多了。
4.2 没有对比就没有伤害:几组关键数据
直接的对比数据最有说服力。我们分别测试了下游直连源端和下游连接HYMMService两种模式:
| 并发播放器数量 | 直连源端表现 | HYMMService转发表现 |
|---|---|---|
| 10路 | 源端CPU 20%,画面基本稳定 | 源端CPU 5%,转发服务CPU 8% |
| 30路 | 源端CPU飙到70%,偶发卡顿 | 源端CPU 5%,转发服务CPU 22% |
| 80路 | 源端CPU接近100%,大量掉线 | 源端CPU 5%,转发服务CPU 55% |
| 150路 | 源端直接拒绝新会话 | 转发服务CPU 75%,画面稳定 |
可以看到,直连模式下,瓶颈集中在源端;HYMMService模式下,源端永远只扛一个会话,真正的瓶颈变成转发服务所在机器的CPU和带宽。后续再加机器,按源ID做哈希分片,横向扩展就能继续往上堆并发。
转发延迟方面,我们连续采集了1000个RTP包的时间戳,转发端相对源端的额外延迟平均在0.3毫秒以内,抖动也低于0.1毫秒。这个结果符合预期,毕竟整个链路里没有编解码,只有内存拷贝和socket发送。少量客户端出现延迟尖峰的情况,几乎都对应着网络GC或CPU调度抖动,属于单机环境的噪音。
4.3 三个最容易被忽略的调参项
压测过程中,有几个参数折磨了我们很久,写出来供参考:
第一是内核socket缓冲大小。默认的接收缓冲在高速率、高并发下很容易丢包。我们把源端接收缓冲调到2MB,下游发送缓冲也调大了,丢包率明显下降。注意这里不能只顾一头,接收和发送缓冲要一起调。
第二是TCP长连接的心跳策略。RTSP信令是TCP,长时间不发送数据,某些中间设备会静默断开连接。应用层保活要自己做,用RTSP规范的GET_PARAMETER作为心跳,间隔设置得比中间设备的老化时间短一半以上才安全。
第三是Live555内置的RTP包发送间隔机制。它默认会按照时间戳间隔来控制发送节奏,直接做转发时如果不关闭这个机制,包会被重新规整,导致转发延迟虚高。HYMMService里通过自定义发送调度绕开了这个默认行为。这个点很有意思,Live555的默认行为对播放器来说是合理的,但对接转发服务反而是干扰。
5. 落地过程中踩过的坑和绕行方案
5.1 信令线程和RTP发送线程的纠缠
第一次把HYMMService并发化改造完,马上遇到一个诡异现象:某些播放器在发完PLAY请求后,画面隔很久才出,有的甚至永远不出来。
排查链路是这样的:先怀疑是SDP里的端口错误,抓包看了SETUP阶段的传输地址,没问题。又怀疑是RTP端口没通,用拉流工具直接连转发地址,能出流。最后进到Live555的事件循环里看,才发现问题出在线程模型:Live555的默认调度里,RTP包的发送是在事件循环中做的,而我的转发发送任务在另一个线程。两个线程同时对同一套socket状态做操作,信令线程的某些动作把发送状态机搞乱了,导致PLAY后一直等不到RTP数据。
最终的解决方案是将信令处理和RTP发送彻底解耦:把Live555的RTP发送能力封装成独立发送器,只保留信令处理走它的事件循环,两者通过内部消息协议交互。改动量不小,但解决得彻底。这个坑给我的教训是:不要因为Live555自带事件循环就理所当然地认为它能承载高并发转发,它的事件循环设计目标是单路流的正确性,而不是多路流的吞吐。
5.2 客户端断开后资源回收不及时
第二个坑是资源泄漏。最初的下线处理只依赖RTSP的TEARDOWN信令,但很多播放器是直接关socket走人,根本不发TEARDOWN。服务端的会话对象、定时器对象、发送缓冲全部变成僵尸,积少成多,跑了两三天后内存占用稳定爬升,最终需要重启。
后来在控制面socket的可读事件里加了一层断开检测:如果某个ClientID对应的TCP连接超过一定时间没有可读数据,就主动销毁对应的订阅者会话和资源。同时数据面任务发现慢客户端超时,也会触发强制下线。这样即使客户端不告而别,回收也能够在几十秒内完成。
这个坑在实际运营中非常致命。有一次部署到一个边缘机房,内存才2GB,流媒体转发服务跑了不到一天就OOM了。加了强制的僵尸回收之后,连续跑了两周,内存曲线基本是一条直线。无论如何,不能把资源回收的希望寄托在客户端的协议规范上,服务端必须有主动兜底机制。
5.3 RTSP会话超时和NAT保活的冲突
第三个坑更隐蔽。RTSP规范里,服务端和客户端之间有SESSION_TIMEOUT机制,默认是65秒。如果客户端在超时时间内没有发任何请求,服务端会主动断开会话。这个机制在局域网下没什么问题,但大量实际使用场景是隔着NAT访问。
NAT会话有它自己的老化时间,通常比65秒还短。RTSP信令长时间空闲,NAT映射先过期了,外面的播放器就会突然和转发服务失联。解决方案是启用RTSP的GET_PARAMETER作为保活请求,让客户端周期性发送,同时把SESSION_TIMEOUT调大。这个改动看着简单,但对公网场景的稳定性影响巨大。
还有一个细节:SESSION_TIMEOUT的默认值65秒,很多客户端还会在OPTIONS请求里带上自己期望的超时时间。如果服务端不理会客户端建议值,只按自己的来,某些严格实现的客户端会直接报错。我们在信令解析里专门处理了Timeout头字段,服务端最终值取双方建议值的较小者。
6. 部署、验证和后续可以做的事
6.1 编译部署要点
HYMMService的部署很简单,依赖就三样:C++编译器、Live555源码、CMake。没有数据库依赖,没有外部服务依赖,编译出一个二进制文件后,配合一个JSON配置文件就能启动。
编译时有个小提醒:建议在CMakeLists里显式打开Release优化,并开启针对当前CPU架构的指令集优化。转发服务是内存带宽密集型的,编译优化选项对最终性能影响很大。我试过不开优化直接跑,CPU使用率比开满优化高出接近30%。
配置里比较关键的是这几个字段:
{ "source": [ { "id": "cam001", "url": "rtsp://192.0.2.10:554/stream1", "username": "admin", "password": "******" } ], "listen_port": 8554, "auth": { "username": "viewer", "password": "******" }, "timeout": 180, "buffer_watermark": { "low": 30, "high": 80 } }跑起来之后,直接在一个内网主机上用播放器拉流验证。能出画面,首包延迟在预期范围内,说明信令和RTP链路都没有问题。具体到生产环境,我习惯先跑一路流测试30分钟,观察内存曲线和延迟曲线,确认稳定后再切换正式流量。
6.2 一个实用的验证工具链
我日常验证用的是一套组合:
- 用支持TCP模式播放的播放器,确认TCP传输下转发是否正常。
- 用播放器实测画面和声音同步情况。
- 用自写的统计脚本周期性拉起N个RTSP客户端,统计PLAY到首帧的延迟,观察转发延迟的抖动曲线。
- 用系统自带的网络监控工具观察连接数和收发包速率,确认服务承受的负载符合预期。
这套组合看起来不复杂,但能覆盖转发服务最关键的两个指标:延迟和时间稳定度。我特别推荐定期跑一次长时间稳定性测试,我这边是每周跑一次72小时连续拉流,内存和CPU曲线出现任何异常都能及早发现。
6.3 可以继续做深的方向
最后聊几个目前在我计划里、但还没排上期的扩展方向。
第一是集群化。目前HYMMService还是单节点部署,受制于单机网卡和CPU的极限。后续可以在源采集层加一层一致性哈希,让同一路流在集群内只有一个节点负责采集,其他节点通过内部协议拉取分发,这样就能横向扩展下游并发量。
第二是结合流录制和点播。既然已经拿到了完整RTP流,在转发的同时落盘写入文件,可以很容易做出一个带录像回放能力的接入层服务。这个扩展对安防类项目来说几乎是刚需,值得优先考虑。
第三是输出协议扩展。RTSP虽然成熟,但不少新场景需要更通用的输出方式。转发层的RTP包已经在内存里,其实可以同时喂给其他封装器和发送通道,做多协议输出是可行的。这块工作量和协议复杂度都不低,适合作为下一阶段的攻坚方向。
我个人在把HYMMService从"跑起来"做到"能上线"的过程中,最大的体会是:流媒体的性能问题,往往不是某个惊天动地的技巧能解决的,而是把内存分配、线程模型、缓冲水位这些细节一个个抠到位。如果这篇分享能给你省下几天排查时间,那就很有价值了。