深入解析:P2P 直播

P2P 直播:支撑一对多实时分发的中继技术

直播中,一位主播的画面被众多观众同时观看,而所有人想要的都是同样的最新几秒。若朴素地向每个人直接发送,最先崩溃的是主播的上行带宽。本文详解突破这一约束的中继技术:分发树与级联、作为控制平面的领导者选举、PLI 与关键帧控制,以及送达 WebRTC 之外的协议桥接,并附上来自真实实现的实践经验。

直播难在哪里

直播是一种一对多的实时传输:只有一个发送者,而所有观众都想要同样的最新几秒画面,而不是「迟早凑齐一份正确数据」这种较宽松的目标。这一点决定了适用于其他 P2P 场景的技术在这里大多失效。

BitTorrent 式的分享群组(swarm)对 VOD(已经完成、内容不再变化的文件)极为高效:各节点可以自由交换彼此欠缺的任意分片。直播打破了这个前提:所有人在同一时刻想要的都是同样的最新分片,swarm 赖以运转的分片交换多样性根本无从产生。若朴素地用 WebRTC 直接向每个观众发送,主播自己的设备就要上传 n 份拷贝,主播的上行带宽会随观众数线性饱和。这正是 WebRTC 全网状 O(n²) 问题的一对多版本:不是每个节点的上行都随群体增长,而是一个节点的上行独自承担了全部观众的重量。

分发树与级联:把上行始终压在一路

基本解法是由中继节点组成的一棵树(级联)。树中只有一台中继(根)直接从主播接收媒体流,并不重新编码地原样分发给其他中继与观众;树中其余中继都从根、或从更上层的中继处取得自己的拷贝,从不直接触碰主播。无论观众规模如何增长,主播自己的上行始终只固定占用一条连接。

大型会议基础设施用的是完全相同的思路,称作 SFU 级联,即把多台 SFU 串联起来,使单台 SFU 的转发能力不再是房间规模的上限。

一个附带的好处出现在 P2P 覆盖网只是选择性互连、而非全连接的场景中:经根再分发的路径,仍能到达那些与主播毫无直接连接的中继,它们只需要一条通往某个上游中继的路径,而无需通往源头本身。

维度直接扇出(1对n)全网状级联/树
主播上行随观众数线性增长随观众数线性增长(且每个观众还要与其他所有观众互连)固定为一条连接,与观众规模无关
观众侧负载极低:各自只需下载一份高:每个观众既要上传也要向所有其他观众下载中等:每台中继只向自己的子节点转发
根/中继故障的影响不适用(没有共享的中继)没有单点故障,但完全无法扩展该中继的子树失去信号,直到重新接入上游
适用规模至多几名观众对直播场景基本不可行大规模观众,受限于树的深度与扇出

只对「谁是根」达成共识:作为控制平面的共识算法

总要有人决定哪台中继来当根。自然的工具是领导者选举,最常见的实现是 Raft(参见分布式共识),在中继节点这个池子里跑一遍。实践中一种刻意的简化非常有效:只用领导者选举,不用日志复制,这里没有需要持续保持一致的应用状态日志,「当选领导者是谁」这一件事本身,就是系统需要达成一致的全部内容。

这个简化背后是一条更普遍的原则:共识每提交一次都要付出一个往返的代价,因此绝不能让低延迟的媒体数据经过它。共识只属于控制平面,决定由谁来再分发;而数据平面,也就是真正的音视频,必须完全留在共识之外运行。

有一个值得提前知道的实务陷阱:Raft 教科书式的默认时序(选举超时约 150〜300ms,心跳约每 50ms 一次)假设的是行为良好的数据中心局域网。把同样的数字原样搬到抖动剧烈的 P2P/WebRTC 路径上,算法会把一次普通的心跳迟到读成「领导者已经死亡」,从而触发毫无必要的重新选举。选举超时需要放宽到秒级。共识的超时参数要按网络实际的延迟特性来调,而不是照抄默认值。

一旦领导者消失,其余中继会自动选出新的领导者,并各自把自己的上游重新锁定到新领导者的再分发轨道上,全程无需人工干预。

关键帧与反馈:PLI 与 NACK

关键帧(IDR)
能够独立解码的视频帧。编码器发出的绝大多数帧都是相对前一帧的增量编码,因此中途加入的解码器,或是刚刚丢过一帧的解码器,会一直卡住直到下一个关键帧到来。关键帧体积大,编码器基本上是「按请求」而非按固定周期发送。
PLI(Picture Loss Indication)
接收端向上游发送的一种 RTCP 反馈消息,请求编码器尽快生成一个新的关键帧。
NACK
一种 RTCP 反馈消息,按序列号指名请求重传某个具体丢失的数据包。

没有 NACK(逐包重传请求)时,劣化路径相当直接:丢失一个数据包会在 RTP 序列号中留下缺口,这个缺口让所在的帧无法重建,而为了避免把损坏的帧送入解码链,唯一的办法就是丢弃此后一切数据,直到下一个关键帧到来。说得直白一点:没有重传机制,一次简单的丢包就不再是包级别的小事,而会恶化成帧级别、长达数秒的画面冻结。

把这一点控制住的实务模式有三层:周期性 PLI(每隔几秒发一次,确保迟到入场的观众也能在一个周期内拿到关键帧)、丢包触发的即时 PLI(若等到下一个固定周期才请求,意味着数秒的画面冻结)、以及去抖(一次丢包尖峰会在序列号上同时打开许多相邻缺口,若不去抖就会演变成一场冗余 PLI 组成的风暴)。

树状分发还藏着一个容易被忽略的陷阱:叶子节点观众发出的 PLI,指向的是它自己的直接上游,也就是转发根信号的那台中继,而不是最初的主播。除非这棵树把该反馈一路转发回源头,否则真正能到达编码器的只有根自身发出的 PLI,每个叶子节点实际能等到关键帧的上限,就变成了根的 PLI 周期,而非它自己的。上行反馈路径值得投入与下行数据路径同等的设计精力。

协议桥接:送达 WebRTC 之外的世界

并非所有观众都会说 WebRTC。要送达那些「只能播放一个 URL」的播放器,比如嵌入式设备,或是游戏与元宇宙平台内置的播放器,需要一台中继把 WebRTC 翻译成 RTSP、HLS 或目标所需的任意协议。

指导原则是解包、再重新打包,而不是重新编码。以 H264 视频为例,只需从 RTP 载荷重组回访问单元,再按目标协议的容器格式重新封装即可,压缩后的比特流本身原封不动。转码只保留给目标确实无法播放的部分,比如把 WebRTC 标准使用的 Opus 音频转成 AAC,而不是不分青红皂白地一律转码。把真正需要转换的部分降到最小,延迟和画质两头都能讨好。

一种值得单独提一句的部署形态:每个观众都在自己机器上运行这个桥接,播放的是一个本地回环 URL(例如 `rtsp://127.0.0.1/...`)。既然每个观众早已经由 P2P 群组收到了整条流,整条链路上就不再需要任何专用流媒体服务器、公网 IP 地址或端口转发:桥接程序与它喂给的播放器,全都活在观众自己的机器里。

从实际运行这类桥接中得到的一个低调但重要的经验:不少播放器会把「一段时间没有数据」直接解读为「连接已断开」,进而主动把整个会话挂断。因此,在真实媒体数据暂时不可用的窗口内(比如短暂卡顿期间),桥接程序需要持续发送解码器能够接受的哑数据(例如反复重发参数集)以维持会话存活,而不是干脆什么都不发。

唇音同步:对齐两只不同的钟

音频与视频是两条完全独立的 RTP 流,各自运行在自己的时间戳空间里:视频通常是 90kHz 时钟,音频通常是 48kHz。光凭这两个数字本身,接收端并不知道该如何把两者对齐。真正提供对齐依据的是 RTCP Sender Report(SR),它周期性地携带一条映射关系:「这条流的某个 RTP 时刻,对应着这个墙钟(NTP)时刻」。一旦忘记发送 SR,播放器就彻底失去了同步基准,音画会持续漂移,且无法被自行纠正。成熟的媒体库通常会隐式地代劳这一步,这也正是它在手写实现中最容易被遗漏的原因。

还有一个同样重要的实务细节:源头的 RTP 时间戳可能因切流、丢包等原因发生意外跳变。负责转发媒体的中继需要检查相邻时间戳之间的增量,把异常跳变替换为标称步长(例如典型音频帧的 20ms),从而保证输出的时间戳序列始终单调递增,即便输入本身并不满足这一点。这种时间戳的重新基准化(rebase),做对了不会被任何人注意到,一旦做错就会立刻表现为可闻的卡顿或漂移。

延迟从哪里来

中继层本身贡献的延迟意外地小:访问单元重组大约耗费一帧的时间,音频重新分帧大约 20ms。真正的大头落在链路两端:编码器一侧的前瞻缓冲,以及播放器一侧的抖动缓冲与解码缓冲。

任何关于延迟优化的严肃讨论,都应该先把「中继或传输方式真正能优化的部分」,与「结构上根本无能为力的部分」分清楚,再谈下一步。

作为背景参考:传统基于 RTMP 的链路端到端延迟通常在数秒到十几秒之间,而基于 WebRTC 的分发能把这一数字压到亚秒级。这一转变在推流(ingest)一端的标准化成果,正是 WebRTC 信令一节中提到的 WHIP/WHEP。

相关页面

P2P 直播与本站的多个其他主题相互交织。它所要绕开的可扩展性瓶颈,正是 WebRTC 中讨论的那个 O(n²) 全网状高墙,而级联式分发树,则是直播场景下对同一篇提到的 SFU/MCU 拓扑问题给出的专门答案。为分发树选出根节点,是分布式共识的一次直接应用,且被刻意收窄到只做领导者选举这一件事。让 BitTorrent 的分享群组在 VOD 上如此高效的那种分片多样性,恰恰是直播场景所欠缺的,这也是为什么树而非 swarm 才是这里的主流方案;相比之下,中继节点之间的成员与存活状态信息,则天然适合交给 Gossip 协议 去传播。而同样的树状分发与兴趣管理思路,会以不同的尺度、用位置数据取代媒体数据的方式,再次出现在 P2P 与元宇宙 之中。

返回首页