SRT在B站的落地
SRT协议成功的解决了流媒体在公网传输上稳定性不足的问题,但由于广播电视行业ToB的特性,在互联网行业落地时常会遇到高负载等一些挑战。哔哩哔哩资深开发工程师 郑龙在LiveVideoStackCon2019深圳的演讲中详细介绍了哔哩哔哩在遇到这些挑战时的实践与经验 。
文 / 郑龙
整理 / LiveVideoStack
大家好,我是来自哔哩哔哩的郑龙,2012年至2017年我在广播电视行业从事工作,2017年我转型至互联网行业并加入了哔哩哔哩的视频云团队。在视频云团队的三年里,主要参与了哔哩哔哩的亿秒级日吞吐视频转码系统的开发与自营视频窄带高清技术的探索,以上两项服务都已上线并长期运行。
本次分享将从以下四个方面为大家分享SRT协议在互联网领域的实践。
1. SRT协议介绍
1.1 SRT联盟
SRT联盟是由Haivision和Wowza两大公司牵头组成的一个开源组织,通过汇集行业资源以求更好地实现提高音视频的传输质量的共同目标。联盟里的公司多为广播电视企业与国内外流媒体企业,包括国内较为知名的企业腾讯云、声网等,国际知名公司微软等。
1.2 SRT协议
SRT本身诞生于广电行业而非互联网行业,其主要运用UDP进行可靠传输或低延时传输,适用于复杂网络。这里的复杂网络主要相对于专线网络而言,也就是我们所谓的互联网。SRT包含ARQ重传与用来实现对丢包数据恢复的FEC前向纠错。其支持AES加密,同时也支持TLS链路加密。
1.3 SRT优点
1.3.1 抗丢包能力强
SRT协议的最显著优势便是抗丢包能力强。对比使用SRT协议推流与RTMP协议推流播放同一段视频:
初期SRT的丢包率增长较为迅速而RTMP的丢包率增长则较为缓慢,直到二者都出现卡顿。其中RTMP在丢包率为20%时画面已经完全卡住不动,而SRT哪怕在丢包率高达80%时,一些画面也能在经历短暂卡顿后恢复播放。
1.3.2 类SOCKET抽象API
对于开发人员来说,SRT协议的另一项优势是其类SOCKET编程的抽象API。
观察API的头文件我们不难看出,在SOCKET编程里能够遇到的几乎所有connect、SOCKETlsn、sendmsg、recvmsg等等函数,都可以在SRT的API中找到可以无缝替换的函数;除此之外SRT的【input】包含了Linux本身自带的【input】,也就是说SRT除了能用Linux本身的【input】对SOCKET进行处理之外还能对SRT自身的事件进行处理,这样便大大降低了协议开发与移植的时间成本。
1.3.3 负载无关与用户态协议栈
很多人将SRT与RTMP拿来做比较,在我看来该两个协议并非同一层级。SRT是一个用户态上的传输层协议,而RTMP则更像是一个流媒体协议,也就是说RTMP比SRT位于更高的层级。我们可以基于SRT使用RTMP进行开发,这里的底层不再是TCP,而是将SRT底层之上的所有流媒体相关逻辑全部使用RTMP替换。
所谓的“负载无关”也就是让我们将SRT看作是一个传输协议,SRT本身在协议开发阶段,其所使用的流通常为一个标准的TS流。
1.4 SRT缺点
SRT协议的缺点主要有:协议额外带宽较高,且SRT协议的传输策略激进,会对同网的其他用户带来影响;除此之外,SRT的协议栈在用户态并且占用了3~4个线程,我们知道线程的切换存在开销,线程之间的一些数据需要使用锁进行保护,加锁解锁的过程也会产生很大的开销;还有一个不可忽视的缺点就是很多防火墙对UDP并不友好,这就导致UDP的优先级更低或是含有UDP的包可能会被过滤掉。
2. SRT协议与互联网结合
接下来会详细介绍SRT协议与互联网的结合,上图展示了一个典型的包含CDN的直播与观看的场景。
来自主播的视频流推至上行CDN,再由源站推至下行CDN,继而分发给众多用户。大家可以看到视频流在该路径中的不同位置,其需要复制的数量也不尽相同。主播到上行CDN采用1:1复制,上行CDN至源站也是1:1,但是源站到下行CDN则采用1:N,而下行分发至用户则需要1:N*M,这里集中了几乎所有的带宽成本。
激进的传输策略会导致SRT的带宽增加,如果我们将SRT运用于上述所有流传输路径,势必会造成带宽成本的激增。由此可以得出SRT并不适用于下行CDN至用户这一传输路径,而更加适合主播至上行CDN以及上行CDN至源站这两部分传输路径;源站到下行CDN也可以使用SRT,相对于下行CDN至用户的1:N*M路径,其带宽占用也更低。
接下来我们就针对SRT适用的三条路径做进一步的探索。
在进行SRT有关实践之前,首先需要了解RTMP直播常见的技术栈。直播上行部分,用户多会使用OBS、移动智能终端上的App或者FFmpeg与其衍生品采集制作并生成视频流,继而通过RTMP推流至NGINX服务器,NGINX服务器会进行数据交换处理,这便是一条典型的工具链。
该工具链底层主要涉及LibRTMP与Nginx-RTMP模块,如果我们想入手实践RTMP就必须对该两部分进行改造。
3. 落地与实践
工欲善其事,必先利其器——SRT协议虽然基于UDP,但是其拥有很多自己的语义,为了更好分析复杂网络情况下SRT传输协议的情况,我们使用SRT协议解析插件实现了对于一层层嵌套的SRT语义的展示,由此我们可以看到很多SRT本身的内容例如一些包的大小或序号等等。
除此之外我们还实现了telent over SRT,也完善了一些纯SRT的推流工具,这些都便于我们能够更好地开发SRT协议。
3.1 LibRTMP支持SRT
落地与实践中第一点便是LibRTMP支持SRT,我们知道所有的TCP Socket都可以无缝替换为SRT Socket API。很快我们发现一个问题是:在正常建连时,如果手动产生一些丢包且丢包发生在RTMP握手的时刻,建连就会失败。
经过研究发现:正常情况下A向B发送一个消息1,A发出1之后会等待B回传消息2,收到B回传的2之后继续发送消息3,以此类推;我们知道SRT诞生于广电系统,其所使用的TS流传输不需要握手而是依次传输1、2、3、4、5、6、7、8……如果发送8之前未收到B传回的7即会要求B重传;但实际上SRT却是A发送1之后等待接收B发送的2,一旦握手信息出现丢包B无法接收来自A的1,也就不会发送2,此时便出现了死循环的情况,导致建连失败。
为解决SRT(1.34版本)不丢包模式下只启用被动重传的情况,我们修改了SRT的源码使得其能够在无被动重传的情况下进行主动重传。该问题已在新版本的SRT源码中得以修复。
第二个问题便是RTMP层丢包后带宽恢复过慢。SRT基于广电网络,其长时间用TS流的固定码率传输,SRT需要对码率进行评估;但是RTMP层也会出现丢包,一旦RTMP层产生丢包,就会导致SRT对负载带宽估计过小,重传率变小 。
解决该问题的方案是找到SRT源码并关闭SRT自动负载带宽估计,并根据业务手动设置负载带宽估计。例如业务需要8M的码率,我们可将其设置为8M并允许上浮50%,然后再根据RTMP或上层业务进行一定的联动。
3.2 nginx-rtmp-module支持SRT
以上介绍的内容与RTMP推流相关,接下来需要解决服务器端的一些问题。现在所有的RTMP服务器都是由Nginx事件驱动,所有的Socket都由其自己接管,而SRT的API内核也接管了所有Socket操作,这里便存在Nginx事件驱动和SRT API冲突。所以我们需要构建两个开发方案:第一个是SRT<-proxy->TCP,开发代理程序;第二个是在Nginx上开发SRT协议栈模块。
SRT代理方案。
SRT的协议栈本身含有三个核心线程:Recv Thread、Send Thread与GC Thread。我们所做的就是加入MainThread并把SRT所收到的流代理发出。
SRT协议栈的工作过程如上图所示:Recv Thread线程收到数据之后会直接向后发送数据,同时收到一系列关键指令包;在此之后,这些指令会通过sendmsg()发给对方,同时GC Thread与Send Thread也会发送很多指令至sendmsg(),每一个线程除了数据交换之外还有比较多的发送至对端的指令。
在尝试SRT代理之后我们发现了以下局限性:首先协议栈固定的线程数量导致其无法有效利用多核性能,并且协议栈线程间锁竞争的开销与代理过程本身CPU的额外开销不容忽视。
Nginx协议栈方案:基于Stream模块的SRT四层代理。
开始时我们希望将Nginx协议栈方案实现在RTMP模块当中,但是后来发现不仅仅是RTMP这一个业务需要SRT。因此后续我们尝试实现SRT在Nginx中的4层代理,简单的说就是将“收UDP,转TCP“,这里便需要经过SRT协议的转换。
最原始基于Stream的代理模块ngx_stream_proxy_module.c
如上图所示,收到的请求会在ngx_stream_proxy_handler 被处理,而数据则会在ngx_stream_topfilter被处理;我们所做的就是在ngx_stream_proxy_module.c 之前加入ngx_stream_srt_module.c 代理模块并拦截向ngx_stream_proxy_module.c传输的请求与数据,并将其转化为SRT,如下图所示:
请求会先调用ngx_stream_srt_module.c 中的ngx_stream_srt_handler 再调用ngx_stream_proxy_module.c中的ngx_stream_proxy_handler;而数据首先会调用ngx_stream_srt_module.c 中的ngx_stream_srt_filter并被交给ngx_stream_srt_stack处理,待协议栈处理完毕之后再交给ngx_stream_proxy_module.c 中的ngx_stream_top_filter 。这样我们便将之前占用4个线程的协议优化到仅占用一个线程,并加入了事件响应与Nginx中的时间响应。
请大家注意这里的数据与指令流向:UDP处于无连接状态而TCP与SRT则存在连接过程,所以Nginx会将每一个新四元组的到来认为是一个新连接的到来;可实际上我们收到一个新的四元组并不意味着这就是个真实的连接,这里需要作出进一步处理:如果代理的Nginx协议栈发现是SRT的连接则与其握手,并将该连接发送给ngx_stream_proxy_handler ,反之则在代理的Nginx协议栈内处理该连接;这一过程对随后的ngx_stream_proxy_module.c 并无影响。
3.3 Nginx协议栈方案的局限
Nginx协议栈方案的局限主要有以上几点:首先便是协议栈依旧无法发挥多核性能;而在Linux3.9之前,reuseport只有热备功能,Linux3.9~4.4 reuseport分配算法复杂度O(n),也就是监控的人越多,所需要遍历的表就越长,消耗的时间也就越大;Linux 4.5后,Nginx协议栈使用了四元组取模哈希算法,看似解决了无法发挥多核性能的局限,实际这会导致程序崩溃。
3.4 破解Nginx协议栈方案的局限
如上图所示,我们简单地将其视为一个四元组,这里有三个进程0、1、2,index取0、1、2;使用一个简单的int取模计算作为算法进行解释,0——>0、1——>1、2——>2。
一旦进程1崩溃,内核会回收进程1并把进程2的index从原本的2变成1,取模算法的模值也从3变成了2。这就导致进程1崩溃后,进程0与其index不变。但进程2的index变为1,同时index 2的Socket依旧存在且会被转至进程0,也就是说其中一个进程的崩溃会造成其他连接的重置会造成其它连接的重置。
也许有人会想到使用一致性哈希算法,通过在内核中打补丁使得index0一定对应进程0,index1一定对应进程1,index2一定对应进程2。这里还存在下图所示问题:
假设进程1崩溃,index0与index2均按照既定正常对应进程0与进程2,但所有崩溃的进程都是由守护进程重新拉起。
一旦拉起就意味着进程1崩溃后对应进程0的index1随后又会恢复对应进程1,相当于要中断两次运算。
接下来就要怎么优化?
这里的一个先决条件是每个SRT连接成功后有一个唯一的SOCKET ID ,这个Socket是SRT所给而非底层Socket。我们使用eBPF做 连接-进程绑定 。内核socket下存在一个reuseport的内核HOOK 点:SOATTACHREUSEPORT_EBPF,我们可以在其中注入一个选择逻辑函数,并且可以和用户态进行数据交换,将我们所需要的连接-进程 绑定数据注入进内核。
我们建立上图所示映射。
一个socket发送给一个进程,每个PID与其index的映射也进行绑定。如果其中某一个进程崩溃,也仅仅是一路出现崩溃,并且在进程崩溃之后Map会发生相应变化,这就使得一个进程崩溃不会干扰其他进程,这就使得处理器的多核性能可以被充分而有效的利用。
上图展示了SRT直播推流灰度数据,在Android端的新版灰度占比为25%。我们统计了RTMP的丢帧率为0.116‰ ,而使用SRT做RTMP传输层,其达到的丢帧率为0.043‰ ,降到了原来的1/3。
下图展示了一个典型的弱网用户数据,可以看到SRT下丢帧率得到了明显改善。
3.5 其他值得注意的问题
在使用SRT的过程中我们也遇见了很多坎坷,其中一个便是服务器1需要支持IP层分包。也就是说,我们之前发现有一台云主机上的服务器关闭了IP分包的内核选项。大家知道TCP有握手且会携上【MTU?】而UDP没有握手,这就导致如果UDP发送的数据被分包而此时服务端不支持IP层分包,会默认该包完全丢失。所以我们需要重点关注的是,使用UDP进行传输时服务器需要支持IP层分包。
除此之外的第二点便是关于UDP的防火墙策略。有很多公司或服务器运维会对UDP和TCP采取不一样的防火墙策略。这里需要关注针对UDP的防火墙策略是否会对传输造成影响。
第三点便是:同样的丢包率情况下,乱序越多SRT的带宽开销越大。我们有几台机器向广州发送数据,尽管几台机器的丢包率几乎一样,但我们发现其中一台机器的带宽很大。查下来之后我们发现是有一台机器乱序很多,我们不知道其中间经历了什么样的链路或者路由器,但这些乱序会造成SRT的带宽消耗很大,我觉得这可能是SRT协议本身未来需要进行一定优化的地方。
4. 未来展望
展望未来SRT协议本身可以进行链路捆绑,也就是说让SRT本地的两个IP发到对端的两个IP之上,IP1传输1、3,IP2传输2、4,这样的话就可以有效解决解决低带宽问题。这对可实现两路并发的4G或5G双卡双待手机来说具有显著价值。
链路备份也至关重要,因为SRT对于数据包的区分很清晰,某一个包丢了需要去找另外一条链路上的包,这对保证传输可靠意义重大。
我们还在研究使用SRT实现信源信道联合编码。SRT协议本身会采集很多链路信息,我们可以将这些链路信息反馈给编码器,使得编码器进行码率的调整。