用户抱怨「卡」的时候,背后的原因可能截然不同:丢包导致画面马赛克,延迟累积导致对话延迟,抖动导致声音断续。这三种劣化的对抗手段完全不同,把「弱网优化」当成一个笼统的开关来对待,往往事倍功半。
本文把弱网问题拆成可量化的三类,逐项给出发送端与接收端的应对策略,并落到具体的 API 与参数上。读完你应该能针对具体的劣化指标选择正确的对抗手段,而不是盲目降低码率。
一、弱网的三类表现
弱网不是一个指标,而是三种独立劣化的组合,它们互相影响但根因不同:
| 表现 | 指标 | 典型根因 | 主要对策 |
|---|---|---|---|
| 丢包 | 丢包率 > 2% | 链路拥塞、无线干扰 | FEC、NACK、降码率 |
| 延迟 | RTT > 300 ms | 排队、跨地域 | 抖动缓冲、就近接入 |
| 抖动 | 到达间隔方差大 | 无线调度、突发 | 抖动缓冲、Pacing |
排障的第一步永远是把这三者分开测量。用 getStats 拿到丢包率与 RTT,再结合 jitter 字段,就能判断当前是哪种劣化占主导。
async function diagnose(pc) {
const stats = await pc.getStats();
let report = {};
stats.forEach((r) => {
if (r.type === "inbound-rtp") {
report[r.kind] = {
packetsLost: r.packetsLost,
packetsReceived: r.packetsReceived,
jitter: r.jitter,
lossRate: r.packetsLost / (r.packetsLost + r.packetsReceived),
};
}
if (r.type === "candidate-pair" && r.state === "succeeded") {
report.rtt = r.currentRoundTripTime;
report.availableOutgoingBitrate = r.availableOutgoingBitrate;
}
});
console.log(report);
return report;
}
二、发送端自适应
2.1 码率跟随带宽估计
发送端的核心任务是让编码码率跟随带宽估计。带宽估计由拥塞控制模块给出,编码器通过 setParameters 动态调整:
async function applyBitrate(sender, targetBitrate) {
const params = sender.getParameters();
if (!params.encodings || params.encodings.length === 0) {
params.encodings = [{}];
}
params.encodings[0].maxBitrate = targetBitrate;
await sender.setParameters(params);
}
// 简单策略:带宽的 80% 用于视频,留出音频与重传余量
async function tuneFromStats(pc, sender) {
const stats = await pc.getStats();
stats.forEach((r) => {
if (r.type === "candidate-pair" && r.state === "succeeded" && r.availableOutgoingBitrate) {
const target = Math.floor(r.availableOutgoingBitrate * 0.8);
applyBitrate(sender, Math.max(target, 100000)); // 不低于 100 kbps
}
});
}
留出 20% 余量是为了应对突发与重传。若把码率顶满带宽估计,一旦估计抖动或出现重传,就会立即丢包,形成恶性循环。
2.2 分辨率与帧率的降级阶梯
当码率不足以支撑当前分辨率时,单纯降码率会导致画面糊成一片。更合理的做法是同步降低分辨率或帧率,保持单位像素的编码质量。
| 档位 | 分辨率 | 帧率 | 目标码率 |
|---|---|---|---|
| 高档 | 1280×720 | 30 | 1.5 Mbps |
| 中档 | 640×360 | 24 | 500 kbps |
| 低档 | 320×180 | 15 | 150 kbps |
const LADDER = [
{ scale: 1, maxBitrate: 1500000, maxFramerate: 30 },
{ scale: 2, maxBitrate: 500000, maxFramerate: 24 },
{ scale: 4, maxBitrate: 150000, maxFramerate: 15 },
];
function pickLevel(bitrate) {
if (bitrate > 1000000) return 0;
if (bitrate > 350000) return 1;
return 2;
}
async function adapt(sender, bitrate) {
const level = LADDER[pickLevel(bitrate)];
const params = sender.getParameters();
params.encodings[0].scaleResolutionDownBy = level.scale;
params.encodings[0].maxBitrate = level.maxBitrate;
params.encodings[0].maxFramerate = level.maxFramerate;
await sender.setParameters(params);
}
注意 scaleResolutionDownBy 只是让编码器降采样,采集分辨率不变。真正的分辨率降级需要 applyConstraints,但那会改变轨道本身,代价更大,通常只在极端情况下使用。
三、抗丢包技术
3.1 前向纠错 FEC
FEC 的思路是发送冗余数据,接收端丢包后无需重传即可恢复。WebRTC 视频常用 ULPFEC 与 FlexFEC,音频则用 Opus 的带内 FEC。
| 类型 | 适用 | 代价 |
|---|---|---|
| Opus 带内 FEC | 音频 | 码率增加约 20% |
| ULPFEC | 视频 | 冗余包占用带宽 |
| FlexFEC | 视频 | 更灵活,支持较新浏览器 |
a=rtpmap:116 ulpfec/90000
a=rtpmap:117 flexfec-03/90000
a=fmtp:117 repair-window=10000000
FEC 适合低丢包率(2% 到 10%)场景。丢包率过高时,冗余包本身也会丢失,且冗余占用的带宽会进一步压缩有效码率,此时不如直接降码率。
3.2 NACK 重传
NACK 让接收端显式请求重传丢失的包。它的优势是精确、无冗余开销,劣势是需要一个 RTT 的往返,因此对低延迟场景不友好。WebRTC 中的 rtx 编码就是为 NACK 服务的:
a=rtpmap:96 VP8/90000
a=rtpmap:97 rtx/90000
a=fmtp:97 apt=96
apt=96 表示 rtx 是载荷类型 96 的重传通道。接收端收到丢包后发 NACK,发送端用 rtx 通道重传。NACK 适合丢包率低但延迟敏感的场景,与 FEC 互补。
3.3 关键帧请求
当丢包破坏了参考帧链,后续帧都无法正确解码时,接收端会发送 PLI 请求新的关键帧。这是最后的兜底手段,代价是码率瞬时飙升。
// 判断是否需要请求关键帧:连续丢包导致解码失败
function shouldRequestKeyframe(stats) {
const video = stats.video;
if (!video) return false;
const lossSinceLastKeyframe = video.packetsLost;
return lossSinceLastKeyframe > 50; // 经验阈值
}
频繁触发关键帧是弱网下最隐蔽的陷阱:关键帧体积大,会瞬间占满带宽,加剧拥塞,进而触发更多丢包与更多关键帧请求。
四、接收端策略
4.1 抖动缓冲
抖动缓冲是接收端平滑网络抖动的核心。它把包先缓存一段时间再按时间戳播放,用延迟换取连续性。缓冲深度需要动态调整:
| 网络状态 | 缓冲策略 | 效果 |
|---|---|---|
| 稳定 | 浅缓冲 | 延迟低 |
| 抖动 | 深缓冲 | 连续性好 |
| 恶化 | 加速播放追回 | 避免延迟累积 |
WebRTC 的抖动缓冲自动调整,但应用层可以通过丢弃过期的视频帧来避免延迟累积。音频通常不能丢,因为丢音频会直接造成断续。
4.2 延迟累积与追帧
弱网下最危险的不是瞬时卡顿,而是延迟不断累积,最终导致「对方说话我要等好几秒才听到」。解决办法是在缓冲过大时主动加速播放或跳帧:
// 监控播放延迟,超过阈值时跳帧追赶
function monitorPlaybackDelay(videoEl) {
setInterval(() => {
const buffered = videoEl.buffered;
if (buffered.length === 0) return;
const end = buffered.end(buffered.length - 1);
const delay = end - videoEl.currentTime;
if (delay > 1.5) {
videoEl.currentTime = end - 0.2; // 跳到接近直播边缘
console.warn("播放延迟过大,执行追帧");
}
}, 1000);
}
五、Simulcast 与 SVC 在弱网下的调度
Simulcast 让发送方同时上传多层,SFU 为每个订阅者选择合适层。弱网下切换层的策略应遵循「先降时间层,再降空间层」的原则,因为时间层切换代价小。
// 服务端根据订阅者带宽选择空间层
async function adaptSubscriber(consumer, availableBitrate) {
const layers = [
{ spatial: 2, min: 1200000 },
{ spatial: 1, min: 400000 },
{ spatial: 0, min: 0 },
];
const target = layers.find((l) => availableBitrate >= l.min);
if (consumer.currentSpatialLayer !== target.spatial) {
await consumer.setPreferredLayers({ spatialLayer: target.spatial, temporalLayer: 2 });
}
}
空间层切换需要等待新层的关键帧,会有短暂冻结。为减少冻结时间,SFU 通常会缓存各层最近的关键帧,切换时优先从关键帧开始转发。
六、移动端弱网的特殊处理
移动端有额外的约束:无线链路波动剧烈、电量与发热敏感、网络切换频繁。
- 网络切换(Wi-Fi 到蜂窝)会触发 ICE 重启,应监听
iceconnectionstatechange并自动重连。 - 弱信号下应主动降低采集帧率与分辨率,减少编码功耗。
- 后台运行时浏览器可能暂停媒体,需监听
visibilitychange并提示用户。 - 移动网络常有 NAT 超时,需保持信令心跳避免连接被回收。
七、实测与调参方法
弱网优化不能靠猜,必须有可复现的测试环境。常用手段是用网络模拟工具限制带宽、注入丢包与延迟,再观察指标变化:
# Linux 上模拟 200ms 延迟、5% 丢包、1Mbps 带宽
sudo tc qdisc add dev eth0 root netem delay 200ms loss 5%
sudo tc qdisc change dev eth0 root tbf rate 1mbit burst 32kbit latency 400ms
关键指标应持续采集并上报:丢包率、RTT、抖动、可用带宽估计、实际码率、帧率、关键帧请求次数。把这些指标做成时间序列,就能在用户投诉时回溯到底发生了什么。
八、音频优先与带宽分配
8.1 为什么音频必须优先
音频码率通常只有 20 到 40 kbps,视频动辄 500 kbps 以上。丢 5% 的音频包,用户立刻感知为断续与吞字;丢 5% 的视频包,可能只是画面轻微马赛克。因此带宽紧张时,策略必须是「先保音频,再谈视频」。
| 媒体 | 典型码率 | 感知敏感度 | 优先级 |
|---|---|---|---|
| 音频 | 24~40 kbps | 极高 | 最高 |
| 视频主画面 | 300~1500 kbps | 中 | 次之 |
| 屏幕共享 | 200~800 kbps | 中 | 视场景 |
| 数据通道 | 可变 | 低 | 最低 |
8.2 分配算法
发送端应在总带宽估计中先扣除音频与重传开销,剩余部分才分配给视频:
const AUDIO_BITRATE = 40000; // Opus 平均码率
const RETRANSMIT_HEADROOM = 0.15; // 重传与突发余量
function allocateVideoBitrate(availableBitrate, hasScreenShare) {
const afterAudio = availableBitrate - AUDIO_BITRATE;
const usable = afterAudio * (1 - RETRANSMIT_HEADROOM);
if (usable <= 0) return 0; // 带宽不足,暂停视频
if (hasScreenShare) {
// 屏幕共享优先清晰度,分 70% 给共享流
return { screen: usable * 0.7, camera: usable * 0.3 };
}
return { camera: usable };
}
// 带宽极低时主动暂停视频,只保留音频
async function degradeToAudioOnly(sender, bitrate) {
if (bitrate < 60000) {
const params = sender.getParameters();
params.encodings[0].active = false; // 停止发送视频
await sender.setParameters(params);
console.warn("带宽不足,暂停视频发送");
}
}
encodings[0].active = false 会停止该编码的发送而不改变轨道,恢复时置回 true 即可,无需重新协商。这是弱网下最优雅的降级手段。
8.3 音频的抗丢包强化
音频在弱网下应叠加多重保护:开启 Opus 带内 FEC、降低码率换取更强冗余、必要时切到更保守的编码模式。
// 通过 SDP 参数影响 Opus(需在协商阶段设置)
const pc = new RTCPeerConnection();
const transceiver = pc.addTransceiver("audio", { direction: "sendrecv" });
const codecs = RTCRtpSender.getCapabilities("audio").codecs;
transceiver.setCodecPreferences(
codecs.filter((c) => c.mimeType === "audio/opus")
);
音频还有一个特殊之处:它不能被丢弃。视频丢帧只影响观感,音频丢包会造成语义缺失。因此接收端的抖动缓冲对音频应更宽容,宁可增加一点延迟也要保证连续。
九、常见坑清单
- 只降码率不降分辨率,画面糊成一片反而更差。
- 把码率顶满带宽估计,没有余量应对突发与重传。
- 关键帧请求过于频繁,码率飙升形成恶性循环。
- 抖动缓冲无限增大,延迟累积到不可接受。
- FEC 冗余比例固定,不随丢包率动态调整。
- 网络切换后不触发 ICE 重启,连接永久卡死。
- 只在前端做自适应,忽略 SFU 侧的分层调度能力。
- 用平均值掩盖瞬时劣化,测试环境与真实网络脱节。
小结
弱网对抗的本质是「在有限的带宽下做出正确的取舍」:丢包用 FEC 与 NACK,延迟用抖动缓冲与就近接入,抖动用缓冲与追帧。发送端的码率、分辨率、帧率应当协同调整,而不是单一维度地压缩。Simulcast 与 SVC 把选择权交给 SFU,是多人场景下最有效的弱网手段。这些策略能否生效,前提是 SDP 里协商出了 transport-cc 与 rtx 通道,这一点与 音视频编解码与拥塞控制
直接相关。最终效果如何,要靠 实时音视频的质量监控
来量化。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。