WebRTC 全景实战 (10):带宽估计与拥塞控制(GCC)
"拥塞控制不是可选项——它是实时通信能否在公网上存活的核心。" — webrtcH4cKS
Ch8 RTCP 介绍了 TWCC、REMB 等反馈机制。本章深入 Google Congestion Control(GCC)——WebRTC 发送端如何根据网络状况动态调整码率。GCC 由 Google 在 2010 年代为 Hangouts / Meet 打磨,Serge Lachapelle 在 Curious 历史访谈 中回忆:Marratech 时代网络质量波动极大,自适应码率是从「能通」到「能用」的分水岭。
GCC 的 IETF 草案(draft-ietf-rmcat-gcc)从未最终发布为 RFC。BWE 领域存在多种实现(GCC、NADA、SCReAM),但 GCC + TWCC 是 Chrome/WebRTC 生态的事实标准。
配套 Lab:基于 examples/webrtc-lab/client/ch02-p2p-basic 扩展带宽监控脚本;信令服务见 examples/webrtc-lab/signaling/server.js。
本篇术语表
| 术语 | 英文 | 解释 |
|---|---|---|
| BWE | Bandwidth Estimation | 带宽估计——推断当前网络能承载多少发送码率 |
| GCC | Google Congestion Control | Google 提出的发送端拥塞控制算法,Delay + Loss 双模块 |
| TWCC | Transport-wide Congestion Control | 逐包传输层拥塞反馈,RTCP transport-cc |
| REMB | Receiver Estimated Maximum Bitrate | 接收端直接告知最大码率(Legacy,goog-remb) |
| TMMBR | Temporary Maximum Media Stream Bit Rate Request | 接收端请求降低某 SSRC 码率(Legacy) |
| Delay-based BWE | — | 通过包到达间隔变化检测队列积压 |
| Loss-based BWE | — | 通过丢包率检测拥塞 |
| AIMD | Additive Increase Multiplicative Decrease | 加性增、乘性减——拥塞控制经典策略 |
| Overuse Detector | — | GCC 中检测网络过载的模块 |
| Trendline Filter | 趋势线滤波器 | Delay-based BWE 核心:拟合延迟梯度斜率 |
| Pacing | 发包整形 | 平滑 RTP 发送间隔,避免 burst 触发丢包 |
| Probing | 带宽探测 | 周期性试探性增码,发现剩余带宽 |
| Target Bitrate | 目标码率 | GCC 输出,编码器实际跟随的发送速率上限 |
| Pacing Rate | 整形速率 | 通常略高于 Target Bitrate(~1.05x) |
| Quality Limitation | 质量限制 | getStats 中 qualityLimitationReason 指示降质原因 |
| NACK | Negative ACK | 丢包重传请求,增加发送压力 |
| FlexFEC | Flexible FEC | 前向纠错冗余,占用额外带宽预算 |
| rmcat | RTP Media Congestion Avoidance Techniques | IETF 拥塞控制工作组 |
一、GCC 在媒体路径中的位置
GCC 是发送端算法:接收端只负责收集包到达时间并通过 RTCP 反馈;真正的拥塞判断和码率决策在发送端完成。这与 TCP 的接收端窗口不同——WebRTC 选择发送端控制是为了让 SFU 能统一调度多路订阅者的下行带宽。
Marratech 早期在瑞典企业内网用固定码率推流,公网扩展后 Serge Lachapelle 团队发现:没有发送端拥塞控制的 RTC 在真实互联网上不可运维。今天 Meet、Teams、LiveKit 的 SFU Forwarder 都在发送路径上复用同一套 GCC 逻辑。
二、历史脉络:从 REMB 到 TWCC
Serge Lachapelle 在 Google 收购 Marratech 后主导 WebRTC 标准化。Marratech 早期视频会议在瑞典企业网内运行良好,但扩展到公网后,固定码率导致大量「能连上但马赛克」的体验。GCC 的设计目标很明确:
- 低延迟:不能像 TCP 那样把包堆在发送缓冲区
- 快速响应:200ms 内检测到拥塞并开始降码
- 公平性:与其他 TCP/UDP 流共存
- SFU 友好:发送端控制,SFU 可为每个订阅者独立 BWE
2.1 GCC 与 rmcat 家族对比
| 算法 | 控制点 | 反馈 | WebRTC 采用 |
|---|---|---|---|
| GCC | 发送端 | TWCC + Loss | Chrome/Firefox/Safari 默认 |
| NADA | 发送端 | ECN + 延迟 | 研究/部分实验 |
| SCReAM | 发送端 | 自包含反馈 | 物联网/低功耗场景 |
| REMB | 接收端建议 | RTCP REMB | Legacy,已淘汰 |
WebRTC 选择 GCC 不是因为它是唯一正确的算法,而是因为它在 Meet 规模下被验证过,且与 TWCC 配合最好。
三、GCC 双模块架构
| 模块 | 检测信号 | 响应策略 | 典型触发场景 |
|---|---|---|---|
| Delay-based | 包到达间隔增大(队列积压) | 快速乘性降码 | Wi-Fi 竞争、4G 基站拥塞 |
| Loss-based | 丢包率超过阈值(~2%) | 阶梯式降码 | 物理链路丢包、TURN 过载 |
| 合并策略 | 取两者较小值 | 保守估计 | 避免拥塞崩溃(congestion collapse) |
| Probing | 周期性试探性增码 | 发现剩余带宽 | 网络恢复后快速回升画质 |
3.1 Delay-based BWE 原理
核心公式直觉:到达间隔 − 发送间隔 = 队列增长速率。持续为正表示网络正在排队,GCC 必须降码;持续为负表示网络有余量,可以 AIMD 增码。
Trendline Filter 对延迟样本做线性回归,斜率持续为正触发 overusing 状态:
3.2 Loss-based BWE 原理
当丢包率超过阈值时,Delay-based 可能尚未触发(例如随机无线丢包),Loss-based 模块作为安全网:
3.3 Probing 带宽探测
网络从拥塞恢复后,GCC 不会立刻跳回最高码率,而是通过 Probing 阶梯试探:
Probing 与 Simulcast 层切换(Ch11)联动:带宽回升时先升 rid=m,再升 rid=h。
四、TWCC vs REMB 深度对比
| 特性 | TWCC | REMB |
|---|---|---|
| 粒度 | 逐包延迟反馈 | 接收端码率上限 |
| 控制点 | 发送端估计 | 接收端建议 |
| SFU 友好 | ✅ SFU 可代发 TWCC | ❌ 接收端 BWE 在 SFU 场景失效 |
| 标准化 | RFC 8888 相关 | Legacy,逐步淘汰 |
| SDP 协商 | a=rtcp-fb:* transport-cc | a=rtcp-fb:* goog-remb |
| 浏览器支持 | Chrome/Firefox/Safari 现代版 | 仅旧版兼容 |
| 反馈频率 | 每 10–100ms 一批 | 不定期 |
在 SFU 架构中,接收端 BWE(REMB)看到的是 SFU 到客户端的最后一段链路,无法感知发布者到 SFU 的上行拥塞。TWCC 让发送端(可以是浏览器或 SFU Forwarder)各自做 BWE,Simulcast 层选择(Ch11)才有准确输入。
4.1 SDP 中的 TWCC 协商
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=rtcp-fb:96 transport-cc
a=rtcp-fb:111 transport-cc
extmap 为 RTP 头扩展分配 transport-wide sequence number;rtcp-fb:transport-cc 声明支持 TWCC 反馈。若协商失败,GCC 退化为仅 Loss-based BWE,画质自适应能力大幅下降。
4.2 验证 TWCC 是否生效
// examples/webrtc-lab/client/ch02-p2p-basic 扩展
async function checkTwccNegotiated(pc) {
const stats = await pc.getStats();
let hasTransportCc = false;
stats.forEach((r) => {
if (r.type === "codec" && r.mimeType?.includes("video")) {
// 间接验证:outbound-rtp 有 targetBitrate 说明 GCC 在运行
}
if (r.type === "transport" && r.selectedCandidatePairChanges !== undefined) {
hasTransportCc = true;
}
});
// 更直接:chrome://webrtc-internals → Graphs → Goog-cc
const sdp = pc.localDescription?.sdp || "";
return sdp.includes("transport-cc");
}
五、码率控制与编码器集成
5.1 动态限制码率
// examples/webrtc-lab/client/ch02-p2p-basic 扩展
const sender = pc.getSenders().find((s) => s.track?.kind === "video");
const params = sender.getParameters();
if (!params.encodings.length) params.encodings = [{}];
params.encodings[0].maxBitrate = 500_000; // 500 kbps 硬上限
params.encodings[0].maxFramerate = 15;
params.encodings[0].scaleResolutionDownBy = 2; // 分辨率降级
await sender.setParameters(params);
Simulcast 场景下每个 encoding 独立设置 maxBitrate(见 Ch9):
const params = sender.getParameters();
params.encodings = [
{ rid: "h", maxBitrate: 1_500_000, active: true },
{ rid: "m", maxBitrate: 500_000, active: true },
{ rid: "l", maxBitrate: 150_000, active: true },
];
await sender.setParameters(params);
GCC 会在 maxBitrate 之下进一步自适应——maxBitrate 是天花板,不是目标值。
5.2 音频与视频码率分配
| 媒体 | 典型码率 | GCC 行为 |
|---|---|---|
| Opus 语音 | 32–64 kbps | 相对稳定,DTX 静音时接近 0 |
| Opus 立体声 | 64–128 kbps | 不受视频拥塞大幅影响 |
| VP8/VP9 视频 | 150k–3M | GCC 主控对象 |
| 屏幕共享 | 500k–5M | contentHint: "detail" 倾向保分辨率 |
5.3 Pacing 与发送平滑
没有 Pacing 时,视频编码器按帧输出 burst(如 30fps 每 33ms 一次大 burst),容易填满路由器队列触发 Delay-based 降码。GCC 的 Pacing Rate 通常略高于 Target Bitrate(~1.05x),平滑发包间隔。
5.4 degradationPreference 与 GCC 协作
const params = sender.getParameters();
params.degradationPreference = "maintain-framerate";
// maintain-framerate: 先降分辨率
// maintain-resolution: 先降帧率
// balanced: 均衡
await sender.setParameters(params);
degradationPreference 决定单流内降质策略;Simulcast 则由 SFU 做层切换,两者不要混用对抗。
六、getStats 中的拥塞信号
6.1 完整监控脚本
// examples/webrtc-lab/client/ch02-p2p-basic 扩展
async function collectCongestionMetrics(pc) {
const stats = await pc.getStats();
const metrics = {
outbound: [],
candidatePair: null,
remoteInbound: [],
transport: null,
};
stats.forEach((r) => {
if (r.type === "outbound-rtp" && r.kind === "video") {
metrics.outbound.push({
rid: r.rid,
ssrc: r.ssrc,
targetBitrate: r.targetBitrate,
bytesSent: r.bytesSent,
framesEncoded: r.framesEncoded,
qualityLimitationReason: r.qualityLimitationReason,
qualityLimitationDurations: r.qualityLimitationDurations,
encoderImplementation: r.encoderImplementation,
retransmittedPacketsSent: r.retransmittedPacketsSent,
});
}
if (r.type === "candidate-pair" && r.state === "succeeded" && r.nominated) {
metrics.candidatePair = {
availableOutgoingBitrate: r.availableOutgoingBitrate,
availableIncomingBitrate: r.availableIncomingBitrate,
currentRoundTripTime: r.currentRoundTripTime,
bytesSent: r.bytesSent,
bytesReceived: r.bytesReceived,
localCandidateType: r.localCandidateType,
remoteCandidateType: r.remoteCandidateType,
};
}
if (r.type === "remote-inbound-rtp") {
metrics.remoteInbound.push({
packetsLost: r.packetsLost,
fractionLost: r.fractionLost,
roundTripTime: r.roundTripTime,
jitter: r.jitter,
});
}
if (r.type === "transport") {
metrics.transport = {
bytesSent: r.bytesSent,
bytesReceived: r.bytesReceived,
dtlsState: r.dtlsState,
};
}
});
return metrics;
}
// 每 2 秒采样,计算码率变化率
let prevBytes = 0;
setInterval(async () => {
const m = await collectCongestionMetrics(pc);
const totalBytes = m.outbound.reduce((s, o) => s + (o.bytesSent || 0), 0);
const bitrate = ((totalBytes - prevBytes) * 8) / 2; // bps over 2s window
prevBytes = totalBytes;
console.table(m.outbound);
console.log("Instant bitrate:", Math.round(bitrate / 1000), "kbps");
console.log("Candidate pair:", m.candidatePair);
}, 2000);
6.2 关键字段解读
| 字段 | 类型 | 含义 | 告警条件 |
|---|---|---|---|
targetBitrate | outbound-rtp | GCC 当前目标码率 | 持续低于 maxBitrate 的 30% |
qualityLimitationReason | outbound-rtp | none / bandwidth / cpu / other | bandwidth 表示 GCC 主动降质 |
qualityLimitationDurations | outbound-rtp | 各原因累计时长 | bandwidth 占比 > 50% |
availableOutgoingBitrate | candidate-pair | 估计可用出站带宽 | 与 targetBitrate 差距 > 50% |
currentRoundTripTime | candidate-pair | 当前 RTT | > 300ms 交互延迟明显 |
fractionLost | remote-inbound-rtp | 最近报告周期丢包率 | > 0.02 (2%) |
retransmittedPacketsSent | outbound-rtp | NACK 重传包数 | 持续增长说明丢包严重 |
6.3 chrome://webrtc-internals 图表对照
| 图表名 | 对应 GCC 模块 | 用途 |
|---|---|---|
Goog-cc Target Bitrate | 合并输出 | 主监控曲线 |
Goog-cc Delay-based Estimate | Delay-based BWE | 队列积压诊断 |
Goog-cc Loss-based Estimate | Loss-based BWE | 丢包拥塞诊断 |
Outbound RTP Video Bitrate | 实际发送 | 与 Target 对比 |
Packets Lost | Loss-based 输入 | 丢包趋势 |
七、SFU 场景下的 GCC
在 SFU 架构中,GCC 出现在三个位置:
- 发布者 → SFU:浏览器上行 BWE,决定发送哪些 Simulcast 层
- SFU → 订阅者:SFU Forwarder 为每个订阅者独立做下行 BWE 和层选择
- 订阅者接收:浏览器可选择性做额外适配
LiveKit 的 Forwarder 实现了完整的 TWCC + LayerSelector 管线,详见 LiveKit 介绍 与 Ch12 SFU 架构。
7.1 上行 vs 下行 BWE 独立性
常见误区:发布者网络好 ≠ 订阅者能看到高清。SFU 为每个订阅者独立做下行 BWE。
八、应用层与 GCC 的边界
应用层自行实现 BWE 并与 GCC 对抗是常见反模式。正确做法是设置合理的 maxBitrate 边界,让 GCC 在边界内自适应。SFU 层选择应读取 TWCC 反馈而非自行估算。
| 应用层该做 | 应用层不该做 |
|---|---|
设置 maxBitrate / maxFramerate 上限 | 自行计算延迟梯度 |
| 选择 Simulcast 层数 | 绕过 GCC 强制固定码率 |
根据 qualityLimitationReason 提示用户 | 与 setParameters 高频对抗 |
屏幕共享设更高 maxBitrate | 用 DataChannel 灌满带宽 |
// 正确:根据 GCC 信号做 UI 提示
collector.onMetrics = (m) => {
const video = m.outbound.find((o) => o.kind === "video");
if (video?.qualityLimitationReason === "bandwidth") {
showBanner("网络带宽不足,已自动降低画质");
}
if (video?.qualityLimitationReason === "cpu") {
showBanner("设备性能不足,建议关闭高清或换设备");
}
};
九、常见陷阱
| # | 陷阱 | 现象 | 修复 |
|---|---|---|---|
| 1 | 未协商 TWCC | 码率固定,拥塞时大量丢包 | SDP 添加 transport-cc |
| 2 | maxBitrate 设太低 | 画质始终模糊 | 检查 setParameters 是否覆盖了默认值 |
| 3 | 混淆 targetBitrate 与 maxBitrate | 误以为已达上限 | targetBitrate ≤ maxBitrate 是正常的 |
| 4 | TURN relay 未计带宽 | 服务器带宽爆满 | Ch14 TURN 容量规划 |
| 5 | 多 Tab 共享上行 | 互相抢带宽 | 单页面单 PeerConnection |
| 6 | CPU 降质误判为带宽 | qualityLimitationReason=cpu | 降分辨率而非降码率 |
| 7 | 忽略 Pacing | 周期性卡顿 | 浏览器内置,无需手动干预 |
| 8 | NACK 风暴 | 重传包占比 > 20% | 降码率 + 检查网络丢包 |
| 9 | 屏幕共享用语音码率上限 | 文字模糊 | 屏幕共享单独设 2–5Mbps 上限 |
| 10 | SFU 忽略 per-subscriber BWE | 低带宽端卡顿 | Forwarder 独立 TWCC |
十、实战 Lab
Lab 1:DevTools 带宽限制
# 启动 P2P Demo
cd examples/webrtc-lab/signaling && npm start
# 另开终端
npx serve examples/webrtc-lab/client/ch02-p2p-basic
- 打开 Chrome DevTools → Network → Throttling → Add custom profile: 500 kbps
- 发起视频通话,运行
collectCongestionMetrics脚本 - 观察
targetBitrate从 ~1.5Mbps 降至 ~400kbps - 观察
qualityLimitationReason变为bandwidth
Lab 2:TWCC 反馈可视化
- 打开
chrome://webrtc-internals - 选择活跃的 PeerConnection → Graphs 标签
- 勾选
Goog-cc相关图表(Target Bitrate、Delay-based estimate) - 对比限速前后曲线变化
Lab 3:Simulcast 层与 GCC 联动
- 开启 Simulcast 三档(Ch9 代码)
- 限速 300kbps,观察
getStats中仅rid=l的 outbound-rtp 活跃 - 取消限速,观察
rid=h恢复
Lab 4:丢包注入
# Linux/macOS 需 root
sudo tc qdisc add dev en0 root netem loss 5%
# 通话中观察 fractionLost 与 targetBitrate 下降
sudo tc qdisc del dev en0 root
Lab 5:对比 REMB 时代行为
阅读 webrtcH4cKS — Bandwidth Estimation 中 Fippo 的实验,理解为何 REMB 在 SFU 场景失效。
Lab 6:CPU 降质 vs 带宽降质
- 开启 1080p 30fps 通话
- 用 Chrome Task Manager 观察 GPU/CPU
- 若
qualityLimitationReason=cpu,降低scaleResolutionDownBy - 若
qualityLimitationReason=bandwidth,检查网络限速
Lab 7:码率变化率计算
- 运行 §6.1 脚本的
bitrate计算 - 在限速切换瞬间观察码率下降斜率
- 记录从 1.5Mbps 到 400kbps 的收敛时间(通常 2–5 秒)
十一、本章小结
| 概念 | 要点 |
|---|---|
| GCC | Delay + Loss 双模块,取 min 合并 |
| TWCC | 现代标准,发送端 BWE,SFU 友好 |
| REMB | Legacy,接收端建议,逐步淘汰 |
| Probing | 网络恢复后阶梯试探回升 |
| 监控 | targetBitrate + qualityLimitationReason + availableOutgoingBitrate |
| 实践 | 设 maxBitrate 边界,让 GCC 自适应,不要对抗 |
下一篇(Ch11):Simulcast 与 SVC
系列导航
章节 主题 状态 0 架构全景与协议栈地图 ✅ 已发布 1 浏览器媒体 API 与设备管理 ✅ 已发布 2 第一个 P2P 视频通话 ✅ 已发布 3 信令服务器设计与会话状态机 ✅ 已发布 4 SDP 解剖与媒体协商 ✅ 已发布 5 ICE、STUN、TURN 与 NAT 穿透 ✅ 已发布 6 Data Channel 与 SCTP over DTLS ✅ 已发布 7 DTLS 握手与 SRTP 加密体系 ✅ 已发布 8 RTP/RTCP 媒体传输与 QoS ✅ 已发布 9 音视频编解码与 Simulcast 入门 ✅ 已发布 10 带宽估计与拥塞控制 GCC ✅ 已发布 11 Simulcast、SVC 与选择性订阅 ✅ 已发布 12 SFU/MCU/Mesh 架构与 Pion 实战 ✅ 已发布 13 调试工具链与可观测性 ✅ 已发布 14 TURN 集群部署与多区域扩展 ✅ 已发布 15 Capstone 生产级视频会议系统 ✅ 已发布
References
- draft-ietf-rmcat-gcc — Google Congestion Control
- RFC 8888 — RTP Control Protocol Feedback for Congestion Control
- webrtcH4cKS — Bandwidth Estimation in WebRTC
- Advancing WebRTC — GCC 分析 (Fippo)
- WebRTC for the Curious — Congestion Control
- WebRTC for the Curious — 历史(Serge Lachapelle 访谈)
- LiveKit 介绍 — 本站推荐