14、丢包与抗丢包:NACK与RTX、FEC前向纠错、PLI关键帧请求
做WebRTC开发,最头疼的问题是什么?
我个人觉得,丢包绝对排前三。
你想想看,视频通话正聊着,突然画面卡住、马赛克、甚至直接黑屏。用户第一反应就是「这什么破软件」。其实很多时候,问题出在网络丢包上。
今天我们就来聊聊WebRTC里对抗丢包的几把刷子:NACK、RTX、FEC、PLI。这些技术我几乎每个项目都跟它们打过交道,踩过坑,也填过坑。
丢包是怎么发生的?
先说个基础概念。网络丢包说白了就是数据包在传输过程中「走丢了」。原因很多:路由器缓存满了、网络拥塞、无线信号不稳定……
在实时通信里,丢包的影响特别明显。我遇到过最夸张的一次,某客户在偏远矿区做视频巡检,丢包率飙到30%。画面基本就是幻灯片,声音断断续续。
丢包带来的问题主要有三个:
- 视频花屏/马赛克:丢失了关键帧数据,解码器拿不到完整信息
- 画面卡顿:接收端等不到数据包,只能重复显示上一帧
- 音画不同步:音频包和视频包丢失比例不同,时间戳对不上
那WebRTC是怎么解决这些问题的?核心就是下面这几种抗丢包机制。
NACK:最朴实的重传方案
NACK,全称Negative ACKnowledgment。名字挺绕,意思很简单——「我没收到,再发一次」。
接收端发现某个包丢了,就给发送端发个NACK消息。发送端收到后,把对应的包重新发一遍。
NACK的工作流程:
- 接收端检测到序列号不连续(比如收到了1、2、4,缺了3)
- 接收端构造NACK消息,包含丢失包的序列号
- 发送端收到NACK,从历史缓存中找到对应包
- 发送端重新发送该包
这里有个关键点:发送端必须保留一段时间内的历史包。我习惯把这个缓存叫做「重传缓冲区」,大小一般设500ms到1000ms。太短了来不及重传,太长了内存吃不消。
// WebRTC内部NACK处理的简化逻辑
// 接收端检测到丢包
if (expected_seq_no != received_seq_no) {
uint16_t lost_seq = expected_seq_no;
while (lost_seq != received_seq_no) {
nack_list.push_back(lost_seq);
lost_seq++;
}
SendNackToSender(nack_list);
}
// 发送端收到NACK后重传
void OnReceivedNack(const std::vector<uint16_t>& nack_list) {
for (auto seq : nack_list) {
auto packet = retransmission_buffer_.FindPacket(seq);
if (packet) {
SendPacket(packet);
}
}
}
我的经验:NACK不是万能的。如果RTT(往返时延)太大,重传包到了也来不及解码。我曾经在跨国链路上测试,RTT 300ms,NACK重传基本没用。这时候就得靠FEC了。
RTX:专门为重传设计的通道
RTX是Retransmission的缩写。它和NACK是搭档关系——NACK负责「通知」,RTX负责「执行」。
WebRTC里,RTX是一个独立的SSRC(同步源)流。什么意思呢?就是原始视频流走一个SSRC,重传的包走另一个SSRC。这样接收端可以区分哪些是原始包,哪些是重传包。
| 特性 | 原始流 | RTX流 |
|---|---|---|
| SSRC | 主SSRC | RTX SSRC(关联主SSRC) |
| 序列号 | 原始序列号 | RTX专用序列号 |
| 负载 | 原始RTP包 | 原始RTP包(完整复制) |
| 用途 | 正常传输 | 重传丢失包 |
RTX的好处是:接收端可以独立处理重传包,不会干扰原始流的排序和抖动缓冲。而且RTX流可以单独配置FEC,进一步保护重传数据。
注意:RTX会增加带宽消耗。每个重传包都是原始包的完整拷贝。如果丢包率很高,重传流量可能占满整个带宽。我曾经见过一个极端案例:丢包率20%,重传流量占了总带宽的40%,导致正常视频质量严重下降。
FEC前向纠错:未雨绸缪
NACK是「事后补救」,FEC是「事前预防」。
FEC的原理很简单:发送端在发送原始数据包的同时,额外发送一些冗余包。接收端即使丢了一部分包,也能通过冗余包恢复出原始数据。
WebRTC里最常用的是ULP FEC(Uneven Level Protection)。它基于异或(XOR)运算:
// FEC编码示例:保护一组K个媒体包
// 生成R个冗余包
// 每个冗余包 = 部分媒体包的异或结果
// 假设有4个媒体包:P1, P2, P3, P4
// 生成2个FEC包:
// F1 = P1 XOR P2
// F2 = P3 XOR P4
// 如果P2丢失,接收端可以用P1和F1恢复:
// P2 = P1 XOR F1
FEC的冗余度可以调节。冗余越多,抗丢包能力越强,但带宽消耗也越大。我一般建议这样配置:
- 低丢包率(<5%):FEC冗余度10%-20%,主要保护关键帧
- 中丢包率(5%-15%):FEC冗余度30%-50%,保护所有帧
- 高丢包率(>15%):FEC+NACK联合使用,冗余度50%以上
FEC vs NACK:怎么选?
FEC适合低延迟场景(比如视频会议),因为它不需要等待重传。但FEC会持续消耗带宽,即使没有丢包。
NACK适合带宽敏感场景,只在丢包时才消耗额外带宽。但NACK需要等待一个RTT,延迟更高。
我个人的做法是:两者都用。FEC处理小规模丢包,NACK兜底处理大规模丢包。
PLI关键帧请求:最后的杀手锏
PLI,Picture Loss Indication。当接收端发现画面彻底坏了(比如关键帧丢了),就会发送PLI请求。
发送端收到PLI后,会立即编码一个关键帧(I帧)发送出去。接收端收到关键帧后,解码器重新开始工作,画面恢复正常。
PLI的使用场景:
- 关键帧(IDR)丢失,后续所有P帧都无法解码
- 连续丢包导致解码器状态混乱
- 接收端刚加入通话,需要快速获取完整画面
我曾经踩过的坑:PLI不能频繁发送。如果网络持续丢包,接收端反复请求关键帧,发送端不断编码大尺寸关键帧,带宽瞬间爆炸。这叫「PLI风暴」。我见过一个案例,PLI请求频率达到每秒5次,视频码率从1Mbps飙升到8Mbps,整个通话直接崩溃。
解决方案:限制PLI发送频率,比如每秒最多1次。同时配合NACK和FEC,尽量用它们恢复画面,PLI作为最后手段。
四种技术的协同工作
在实际的WebRTC引擎里,这四种技术是协同工作的。我画了一张图来说明它们的关系:
从图中可以看到:
- 正常情况:发送端发送RTP包,同时携带FEC冗余。接收端正常解码。
- 小规模丢包:接收端通过FEC恢复丢失包,无需额外请求。
- FEC恢复失败:接收端发送NACK,发送端通过RTX通道重传。
- 画面彻底损坏:接收端发送PLI,发送端编码关键帧。
我的建议:在实际项目中,不要把所有抗丢包机制都开到最大。要根据网络状况动态调整。比如WiFi环境下丢包率低,可以降低FEC冗余;4G网络波动大,可以增加NACK的灵敏度。
WebRTC内置了拥塞控制和码率自适应,但抗丢包参数的调优还是需要根据具体场景来。我一般会在客户端暴露几个配置项,让运维人员可以根据实际网络情况微调。
总结一下
四种抗丢包技术各有千秋:
- NACK:按需重传,节省带宽,但有延迟
- RTX:独立通道重传,不干扰原始流
- FEC:主动冗余,零延迟恢复,但持续消耗带宽
- PLI:终极手段,恢复画面但代价大
实际工程中,没有银弹。我见过只用FEC的项目,也见过只用NACK的项目。但做得好的WebRTC产品,一定是把这四种技术组合起来,根据网络状况动态切换。
嗯,关于抗丢包就先聊这么多。下一节我们聊聊更进阶的话题——如何在弱网环境下保持流畅的通话体验。
公众号:蓝海资料掘金营,微信deep3321