跳到主要内容

抗丢包NACK-RTX-FEC

UDP 不保证送达,链路丢包是常态。WebRTC 的抗丢包体系由三层组成:NACK 重传(精准、省带宽)、RTX 重传通道(独立 SSRC、不污染主流)、FEC 前向纠错(无 RTT 代价、有带宽代价)。本文按"机制 → 实现 → 决策矩阵 → 反模式"展开。

1. 为什么需要分层抗丢包

单纯 NACK 的局限:

  • 重传一个包至少要 1×RTT,长 RTT(>200ms)链路下视频会卡。
  • 突发丢包时大量 NACK 会反向拥塞链路。

单纯 FEC 的局限:

  • 永久占用 20%~50% 带宽,丢包率低时是浪费。
  • 突发整批丢包时 FEC 也救不回来。

正确做法:按丢包率分级,混合使用。

丢包率推荐策略理由
< 1%NACK + RTX丢包随机,重传足够
1%~5%NACK + RTX + 少量 FECFEC 兜底
5%~10%NACK + RTX + FlexFEC + 降帧率FEC 抗连续丢包
> 10%上述全开 + 切到最低 Simulcast 层保连接

失败时的样子:花屏、绿屏、长时间冻结、突然黑屏。

2. NACK:基于反馈的重传

2.1 RTCP NACK 消息

NACK 走 RTCP(PT=205, FMT=1, Generic NACK),格式:

0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|V=2|P| FMT=1 | PT=205 | length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| SSRC of packet sender |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| SSRC of media source |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| PID (lost seq) | BLP (bitmap) |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
  • PID:丢失的首个 RTP 序号。
  • BLP:紧随 PID 的 16 个序号是否丢失的位图。

一个 NACK 包能请求最多 17 个连续丢包。

2.2 NACK 触发时机

接收端通常按以下规则触发 NACK:

检测到 seq 跳跃 → 启动等待计时器
- 立即触发(首次)
- 重发间隔 = max(RTT, 30ms)
- 最多重试 N 次(Chrome 默认 ~10)
- 超过 jitter buffer 容量 → 放弃,等关键帧

2.3 SDP 协商

a=rtcp-fb:96 nack

没有这行,发送端不会响应 NACK。

2.4 流程

3. RTX:独立的重传通道

直接重发原 RTP 包会让接收端误以为是"包序倒乱",破坏抖动缓冲的统计。RTX(RFC 4588) 用独立 SSRC、独立 payload type 发送重传包。

3.1 RTX 包格式

原 RTP header
+原 RTP seq 作为 OSN(Original Sequence Number)放进 payload 头
+RTX 自己的 seq 递增

3.2 SDP 表现

m=video 9 UDP/TLS/RTP/SAVPF 96 97
a=rtpmap:96 VP8/90000
a=rtcp-fb:96 nack
a=rtpmap:97 rtx/90000 # RTX 的 PT
a=fmtp:97 apt=96 # apt = associated payload type
a=ssrc-group:FID 1234 5678 # FID = Flow ID,1234 是主流 SSRC,5678 是 RTX SSRC
a=ssrc:1234 cname:abc
a=ssrc:5678 cname:abc

3.3 SFU 处理 RTX 的关键点

// SFU 接收 RTX 包时的伪代码
function handleIncomingRtp(packet) {
if (packet.payloadType === RTX_PT) {
const osn = packet.payload.readUInt16BE(0); // 取 OSN
const originalPacket = restoreFromRtx(packet, osn);
// 用 originalPacket 喂下游 jitter buffer
forwardToSubscribers(originalPacket);
} else {
// 主流:缓存到 history buffer 供 NACK 重传
historyBuffer.add(packet);
forwardToSubscribers(packet);
}
}

SFU 必须对每路发布流维护一个 history buffer(默认 500ms~1s),否则 NACK 来了找不到包。

3.4 RTX 占用的码率

通常占主流 1%~3%。突发丢包时可短暂飙到 10%。GCC 在估带宽时把 RTX 算进去。

4. FEC:前向纠错

FEC 在发送端附带"冗余包",接收端用冗余包恢复丢失的原始包,无需等待重传

4.1 三种 FEC 方案对比

方案RFC浏览器支持适用局限
RED + ULPFECRFC 5109Chrome 默认(仅音频/旧视频)老链路兼容不支持 Simulcast;保护粒度粗
FlexFECRFC 8627Chrome 实验、Edge视频 + Simulcast移动端实现少
RFC 6363 generic协议层定义实际不用

4.2 RED 与 ULPFEC

RED(Redundant Encoding, RFC 2198) 是包封装格式:一个 RED 包里可以塞多个 "block",每个 block 是原始 payload 或 FEC payload。

ULPFEC(Uneven Level Protection) 是 FEC 算法,输出 FEC payload 给 RED 装载。

a=rtpmap:111 opus/48000/2
a=rtpmap:63 red/48000/2 # RED 装载
a=fmtp:63 111/111 # RED 包里可以嵌两个 opus block

视频侧(Chrome 当前默认关闭 ULPFEC 视频保护,因为有 Simulcast):

a=rtpmap:96 VP8/90000
a=rtpmap:120 red/90000
a=rtpmap:121 ulpfec/90000

4.3 FlexFEC

FlexFEC(RFC 8627) 把 FEC 分到独立 SSRC,支持 1D 和 2D 矩阵保护:

原始包矩阵(4x4):
P1 P2 P3 P4
P5 P6 P7 P8
P9 P10 P11 P12
P13 P14 P15 P16

行 FEC(4 个):F_R1=P1⊕P2⊕P3⊕P4, F_R2=...
列 FEC(4 个):F_C1=P1⊕P5⊕P9⊕P13, F_C2=...

任意一行 + 一列丢一个,都能恢复。

SDP:

a=rtpmap:118 flexfec-03/90000
a=fmtp:118 repair-window=10000000
a=ssrc-group:FEC-FR 1234 9999 # 1234 是主流,9999 是 FlexFEC SSRC

4.4 FEC 的"无用"区间

  • 丢包率 < 0.5%:FEC 几乎没机会"用上",纯浪费。
  • 突发整批丢包 > FEC 保护范围:FEC 也救不回。
  • 高 RTT 链路:FEC 比 NACK 强(NACK 一个 RTT 太久)。

5. WebRTC API 怎么开

5.1 启用 RED(音频)

Chrome 起新建 PC 默认音频 RED 是开启的。手动控制:

const transceiver = pc.addTransceiver('audio');
const caps = RTCRtpReceiver.getCapabilities('audio');
const codecs = caps.codecs.filter(c =>
c.mimeType === 'audio/red' || c.mimeType === 'audio/opus'
);
// 把 RED 放最前,优先选用
codecs.sort((a, b) => a.mimeType === 'audio/red' ? -1 : 1);
transceiver.setCodecPreferences(codecs);

5.2 启用 FlexFEC(Chrome 实验)

// 启动时:chrome://flags 启 "WebRtc FlexFEC"
// 或代码里
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
const params = sender.getParameters();
params.encodings[0].priority = 'high';
// FlexFEC 是 SDP 协商出来的,不能从 setParameters 单独开
await sender.setParameters(params);

实际生产中,FlexFEC 多由 SFU 端控制开关(mediasoup enableFlexFEC)。

5.3 查询是否在用 FEC

async function inspectFec(pc) {
const stats = await pc.getStats();
for (const r of stats.values()) {
if (r.type === 'inbound-rtp') {
console.log('FEC 包接收数', r.fecPacketsReceived);
console.log('FEC 解码成功', r.fecPacketsReceived - r.fecPacketsDiscarded);
}
if (r.type === 'outbound-rtp') {
console.log('RTX 重传字节', r.retransmittedBytesSent);
console.log('NACK 收到数', r.nackCount);
}
}
}

6. 分级策略实现:自适应抗丢包

class AdaptiveLossProtection {
constructor(pc) {
this.pc = pc;
this.lastFractionLost = 0;
this.timer = null;
}

start() {
this.timer = setInterval(() => this.adjust(), 2000);
}

stop() {
clearInterval(this.timer);
}

async adjust() {
const stats = await this.pc.getStats();
let fractionLost = 0;
let rtt = 0;

for (const r of stats.values()) {
if (r.type === 'remote-inbound-rtp' && r.kind === 'video') {
fractionLost = r.fractionLost ?? 0;
rtt = r.roundTripTime ?? 0;
}
}

const level = this.classify(fractionLost, rtt);
if (level !== this.currentLevel) {
console.log(`[Loss] 切换抗丢包等级 ${this.currentLevel}${level}`);
await this.apply(level);
this.currentLevel = level;
}
}

classify(loss, rtt) {
if (loss < 0.01) return 'low';
if (loss < 0.05) return 'mid';
if (loss < 0.10) return 'high';
return 'critical';
}

async apply(level) {
const sender = this.pc.getSenders().find(s => s.track?.kind === 'video');
if (!sender) return;

const params = sender.getParameters();
switch (level) {
case 'low':
params.encodings.forEach(e => { e.maxBitrate = 2_500_000; e.maxFramerate = 30; });
break;
case 'mid':
params.encodings.forEach(e => { e.maxBitrate = 1_500_000; e.maxFramerate = 30; });
break;
case 'high':
params.encodings.forEach(e => { e.maxBitrate = 800_000; e.maxFramerate = 20; });
// 关闭高层 Simulcast
if (params.encodings.length >= 3) params.encodings[2].active = false;
break;
case 'critical':
params.encodings.forEach(e => { e.maxBitrate = 300_000; e.maxFramerate = 15; });
params.encodings.forEach((e, i) => { e.active = i === 0; });
break;
}
await sender.setParameters(params);
}
}

const protector = new AdaptiveLossProtection(pc);
protector.start();

7. SFU 端的抗丢包责任

SFU 的三层责任:

  1. 本地重传:订阅者发来 NACK,SFU 从 history buffer 查 RTX。
  2. 回源重传:history 没有 → 给发布者发 NACK(如果配置允许)。
  3. 降级:连续 N 次失败 → 发起 PLI 强制关键帧(见 关键帧请求PLI与FIR.md)。

mediasoup 配置示例

const producer = await transport.produce({
kind: 'video',
rtpParameters,
// history buffer 默认 1s,可调
});

const consumer = await transport.consume({
producerId: producer.id,
rtpCapabilities,
enableRtx: true,
});

consumer.on('trace', (trace) => {
if (trace.type === 'nack') {
console.log('NACK from subscriber', trace.info);
}
});

8. 调试技巧

8.1 关键 stats 字段

字段看什么
inbound-rtp.packetsLost累计丢包数
inbound-rtp.nackCount接收端发出的 NACK 次数
inbound-rtp.fecPacketsReceived收到的 FEC 包数
inbound-rtp.fecPacketsDiscardedFEC 包但用不上的数(说明已经恢复了)
outbound-rtp.retransmittedPacketsSentRTX 重传包数
outbound-rtp.retransmittedBytesSentRTX 占用带宽
remote-inbound-rtp.fractionLost对端报告的丢包率(256 分母)

8.2 chrome://webrtc-internals 看 NACK

打开后选对应 PC,看 RTCInboundRtpStreamStatsnackCount 曲线。陡升说明丢包变密集。

8.3 用 tc 模拟丢包

# Linux:在网卡上加 5% 随机丢包
sudo tc qdisc add dev eth0 root netem loss 5%

# 加 RTT
sudo tc qdisc change dev eth0 root netem delay 200ms 50ms

# 清理
sudo tc qdisc del dev eth0 root

9. 反模式

反模式后果正确做法
关掉 NACK 想"省带宽"1% 丢包就严重花屏永远开 NACK
在 SDP 里删 RTX重传走主流 SSRC,污染抖动统计保留 RTX
全程开 FlexFEC 双倍冗余占 50% 带宽却不解决根本问题按丢包率自适应开关
SFU 不维护 history buffer订阅者 NACK 永远拿不到包默认 1s buffer
把 RED 用于视频 + SimulcastChrome 实际无效视频用 FlexFEC 或纯 NACK
在 NACK 失败后立刻断流重连用户体验更差触发 PLI 等关键帧即可
同时开 ULPFEC 和 FlexFEC行为冲突,浏览器忽略一个只开一个
接收端 jitter buffer 设太小NACK 包到了已经过期默认 200~500ms

10. 决策矩阵

场景推荐组合
1v1 桌面视频,链路稳定NACK + RTX,不开 FEC
1v1 移动通话,4G/5GNACK + RTX + 音频 RED
多人会议,SFU 转发NACK + RTX + FlexFEC(视频)
跨国 RTT > 300msNACK + RTX + 强 FEC(重传太慢)
直播低延迟(< 500ms)RTX + FlexFEC(不能等 NACK)
卫星 / 弱网主要靠 FEC + 强 Simulcast 降级

11. 权威资料