抗丢包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 + 少量 FEC | FEC 兜底 |
| 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 + ULPFEC | RFC 5109 | Chrome 默认(仅音频/旧视频) | 老链路兼容 | 不支持 Simulcast;保护粒度粗 |
| FlexFEC | RFC 8627 | Chrome 实验、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 的三层责任:
- 本地重传:订阅者发来 NACK,SFU 从 history buffer 查 RTX。
- 回源重传:history 没有 → 给发布者发 NACK(如果配置允许)。
- 降级:连续 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.fecPacketsDiscarded | FEC 包但用不上的数(说明已经恢复了) |
outbound-rtp.retransmittedPacketsSent | RTX 重传包数 |
outbound-rtp.retransmittedBytesSent | RTX 占用带宽 |
remote-inbound-rtp.fractionLost | 对端报告的丢包率(256 分母) |
8.2 chrome://webrtc-internals 看 NACK
打开后选对应 PC,看 RTCInboundRtpStreamStats 的 nackCount 曲线。陡升说明丢包变密集。
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 用于视频 + Simulcast | Chrome 实际无效 | 视频用 FlexFEC 或纯 NACK |
| 在 NACK 失败后立刻断流重连 | 用户体验更差 | 触发 PLI 等关键帧即可 |
| 同时开 ULPFEC 和 FlexFEC | 行为冲突,浏览器忽略一个 | 只开一个 |
| 接收端 jitter buffer 设太小 | NACK 包到了已经过期 | 默认 200~500ms |
10. 决策矩阵
| 场景 | 推荐组合 |
|---|---|
| 1v1 桌面视频,链路稳定 | NACK + RTX,不开 FEC |
| 1v1 移动通话,4G/5G | NACK + RTX + 音频 RED |
| 多人会议,SFU 转发 | NACK + RTX + FlexFEC(视频) |
| 跨国 RTT > 300ms | NACK + RTX + 强 FEC(重传太慢) |
| 直播低延迟(< 500ms) | RTX + FlexFEC(不能等 NACK) |
| 卫星 / 弱网 | 主要靠 FEC + 强 Simulcast 降级 |
11. 权威资料
- RFC 4585 RTP/AVPF(含 Generic NACK):https://www.rfc-editor.org/rfc/rfc4585
- RFC 4588 RTP RTX:https://www.rfc-editor.org/rfc/rfc4588
- RFC 2198 RTP Redundant Audio (RED):https://www.rfc-editor.org/rfc/rfc2198
- RFC 5109 ULPFEC:https://www.rfc-editor.org/rfc/rfc5109
- RFC 8627 FlexFEC:https://www.rfc-editor.org/rfc/rfc8627
- W3C
RTCStats:https://www.w3.org/TR/webrtc-stats/ - mediasoup Producer/Consumer:https://mediasoup.org/documentation/v3/mediasoup/api/
- 核对日期:2026-06-22