拥塞控制GCC与TWCC
拥塞控制是 WebRTC 工业级体验的命门。GCC(Google Congestion Control)回答"现在能发多少码率",TWCC(Transport-wide Congestion Control)回答"每个包在网络里经历了什么"。少了任何一个,SFU 都做不出"按订阅端体验调码率"的能力。
本文按"为什么需要 → 算法机制 → 扩展头格式 → SFU 集成 → 参数调优 → 反模式"的顺序展开,所有代码片段都可在 Chrome 120+ 直接跑。
1. 为什么需要拥塞控制
UDP 没有拥塞控制。WebRTC 跑在 UDP 上,必须自己做。否则会出现:
| 失败现象 | 根因 |
|---|---|
| 视频从清晰变马赛克再变黑屏 | 发送码率超过链路容量,丢包堆积,编码器跟不上 |
| 同一个会议有人卡顿有人流畅 | SFU 给所有订阅者转一档高码率,弱网订阅者扛不住 |
| Wi-Fi 切 4G 后画面"雪崩" | 没有快速降码率机制,缓冲队列瞬间爆 |
| 多人会议越加人越卡 | 上行没有 Simulcast,下行没有按需切层 |
什么时候需要:所有承载实时音视频的链路。
什么时候不需要:录播、点播、文件传输——它们用 TCP/QUIC 的拥塞控制即可。
失败时的样子:outbound-rtp.qualityLimitationReason 长期是 bandwidth,availableOutgoingBitrate 抖动剧烈(一会儿 200kbps 一会儿 3Mbps)。
2. 心智模型
四个关键组件:
- Pacer:平滑发送,避免突发流量打爆链路。
- 带宽估计器:根据 RTT、丢包、排队延迟产出 Target Bitrate。
- 编码器码率控制:把 Target Bitrate 翻译成 QP / bitrate 参数。
- 反馈通道:TWCC(首选)或 REMB(旧版兼容)。
3. GCC 的两个估计器
GCC 不是一个算法,是 基于延迟 和 基于丢包 两个估计器取最小值。
3.1 基于延迟的估计器(trendline filter)
核心思想:排队延迟在持续增加 = 网络快撑不住了。
对每个收到的 RTP 包,计算 inter-arrival delay:
d(i) = (t_recv[i] - t_recv[i-1]) - (t_send[i] - t_send[i-1])
把最近 N 个 d(i) 做线性回归,得到斜率 trendline:
trendline > 阈值 → overuse(拥塞)→ 降码率
trendline < -阈值 → underuse(链路空)→ 试探性上调
其它 → normal → 保持
阈值 gamma 是自适应的:丢包越多、抖动越大,gamma 越大(更保守)。
3.2 基于丢包的估计器
fraction_lost < 2% → 当前码率 × 1.05(探测)
2% ≤ fraction_lost ≤ 10% → 保持
fraction_lost > 10% → 当前码率 × (1 - 0.5 × fraction_lost)
最终发送码率 = min(delay_estimate, loss_estimate)。
3.3 为什么要两个
| 场景 | 哪个先报警 |
|---|---|
| Wi-Fi 弱信号,路由器开始排队 | 延迟估计器(排队延迟先涨) |
| 蜂窝信号瞬间丢失 | 丢包估计器(包直接没了) |
| 跨国链路,长 RTT 但带宽充足 | 谁都不报警,留余量 |
4. TWCC:为什么取代 REMB
REMB(Receiver Estimated Maximum Bitrate)是接收端把估计的可用带宽通过 RTCP 反馈给发送端。问题:
- 粒度粗:接收端只告诉一个数,发送端拿不到每个包的到达时间。
- SFU 不友好:SFU 是转发节点,REMB 只能告诉 SFU 它自己估的带宽,无法推断到真正的接收端。
- 算法绑定:发送端只能信接收端的估计。
TWCC 的核心改动:接收端不再估带宽,而是把"每个 RTP 包到达的时间戳"全部回传给发送端。发送端自己跑算法。
| 维度 | REMB | TWCC |
|---|---|---|
| 反馈内容 | 估计的最大码率 | 每个包的到达时间 |
| 计算位置 | 接收端 | 发送端 |
| 多 SFU 转发 | 不准 | 端到端可追踪 |
| 反馈频率 | RTCP 间隔(约 1s) | 50~250ms |
| 现代 SFU 支持 | 已废弃 | mediasoup / LiveKit / Janus 默认 |
5. TWCC 扩展头格式
5.1 RTP 扩展头
每个 RTP 包都带 transport-wide-sequence-number(2 字节):
a=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
发送端为这个 SSRC 维护一个 transport-wide 递增序号(注意:不是 RTP 序号,所有 m-line 共享一个序号空间)。
5.2 TWCC Feedback 包
接收端用 RTCP Transport Feedback(PT=205, FMT=15)回传:
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=15 | PT=205 | length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| SSRC of packet sender |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| SSRC of media source |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| base sequence number | packet status count |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| reference time (24bit, 64ms 单位) | fb pkt count
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| packet chunk | packet chunk |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| recv delta | recv delta |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
关键字段:
reference time:基准时间,单位 64ms。packet chunk:用 run-length 或 status vector 标记每个包的状态(收到/丢失/收到且带 delta)。recv delta:相对前一个包到达时间的差值,8 或 16 位。
5.3 解析示例(伪代码)
function parseTwccFeedback(buffer) {
const view = new DataView(buffer);
const baseSeq = view.getUint16(8);
const pktCount = view.getUint16(10);
const refTime = (view.getUint32(12) >> 8) * 64; // ms
let cursor = 16;
const results = [];
let arrival = refTime;
let seq = baseSeq;
// 解析 chunks → 得到每个包的 status
const statuses = decodeChunks(view, cursor, pktCount);
cursor += statuses.bytes;
for (const status of statuses.list) {
if (status === 'received-small-delta') {
arrival += view.getUint8(cursor) * 0.25; // 0.25ms 单位
results.push({ seq, arrival });
cursor += 1;
} else if (status === 'received-large-delta') {
arrival += view.getInt16(cursor) * 0.25;
results.push({ seq, arrival });
cursor += 2;
} else {
results.push({ seq, arrival: null }); // 丢失
}
seq++;
}
return results;
}
6. 浏览器端怎么用
6.1 启用 TWCC
浏览器默认开启,但你需要确认 SDP 里有:
a=extmap:5 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=rtcp-fb:96 transport-cc
如果 SFU 删了这个扩展头,前端拿不到任何 TWCC 反馈,会退化到 REMB 或纯丢包估计。
6.2 设置码率上下限
const sender = pc.getSenders().find(s => s.track?.kind === 'video');
const params = sender.getParameters();
params.encodings = [
{
maxBitrate: 2_500_000, // 上限,编码器不会超过这个值
minBitrate: 200_000, // 下限(实验特性,Chrome 支持)
networkPriority: 'high', // DSCP 标记
priority: 'high',
},
];
await sender.setParameters(params);
注意:maxBitrate 是给编码器的硬性上限。GCC 估出来的 Target Bitrate 还会小于 maxBitrate。
6.3 从 getStats 看 GCC 实际工作
async function inspectBandwidth(pc) {
const stats = await pc.getStats();
for (const r of stats.values()) {
if (r.type === 'candidate-pair' && r.nominated) {
console.log('可用上行', r.availableOutgoingBitrate);
console.log('可用下行', r.availableIncomingBitrate);
}
if (r.type === 'outbound-rtp' && r.kind === 'video') {
console.log('当前发送码率', r.targetBitrate, '编码码率', r.bytesSent);
console.log('受限原因', r.qualityLimitationReason);
console.log('受限时长', r.qualityLimitationDurations);
}
}
}
setInterval(() => inspectBandwidth(pc), 1000);
qualityLimitationReason 取值:
| 值 | 含义 | 调优方向 |
|---|---|---|
none | 没限制 | 健康 |
bandwidth | 带宽不够 | 看是否需要降分辨率 |
cpu | CPU 跟不上 | 降分辨率或换 H.264 硬编 |
other | 其它(如分辨率切换中) | 短暂可忽略 |
7. SFU 端集成 TWCC
SFU 是 TWCC 的"放大器"——它把每个客户端的 TWCC 反馈聚合,并向上行发送方报告"路径瓶颈"。
7.1 mediasoup 配置示例
// router 配置
const routerOptions = {
mediaCodecs: [
{
kind: 'video',
mimeType: 'video/VP8',
clockRate: 90000,
rtcpFeedback: [
{ type: 'nack' },
{ type: 'nack', parameter: 'pli' },
{ type: 'ccm', parameter: 'fir' },
{ type: 'goog-remb' },
{ type: 'transport-cc' }, // 关键:必加
],
},
],
};
// 创建 Transport 时
const transport = await router.createWebRtcTransport({
enableTcp: true,
enableUdp: true,
enableSctp: true,
initialAvailableOutgoingBitrate: 1_000_000,
// mediasoup 内部根据 TWCC 反馈调整这个值
});
transport.on('trace', (trace) => {
if (trace.type === 'bwe') {
// type=bwe: 带宽估计变化
console.log('SFU 带宽估计', trace.info);
}
});
7.2 SFU 转发时的关键决策
SFU 必须给每个订阅者维护独立的带宽估计,否则一个弱网用户就会拖垮所有人。
7.3 反馈频率调优
// 发布端控制反馈间隔(Chrome 字段)
const params = sender.getParameters();
// params 里没有直接字段,需通过 SDP fmtp 或 SFU 协商
// SDP 里的写法:
// a=fmtp:96 transport-cc-fb-interval=50
| 反馈间隔 | 适用场景 | 副作用 |
|---|---|---|
| 50ms | 1v1 通话,要求灵敏 | RTCP 流量增加 |
| 100ms(默认) | 通用 | 平衡 |
| 250ms | 大会议(百人以上) | 反应迟钝,但省 RTCP 带宽 |
8. 参数调优实战
8.1 初始带宽估计
const transport = await router.createWebRtcTransport({
initialAvailableOutgoingBitrate: 1_500_000, // 1.5Mbps 起步
});
| 场景 | 推荐起步 |
|---|---|
| 移动端会议 | 600kbps |
| 桌面端 1v1 | 1.5Mbps |
| 大屏会议 / 协作白板 | 2.5Mbps |
| 直播 / 互动直播 | 3Mbps |
起步太高 → 首帧时丢包猛涨;起步太低 → 前几秒画质上不来。
8.2 padding 策略
GCC 在 underuse 时会发 padding 包探测带宽:
// libwebrtc 默认开启 padding,可通过 SDP 控制
// a=rtcp-fb:96 transport-cc
// 实际由编码器/Pacer 内部触发
如果你在 SFU 看到 SSRC 出现"无对应 track 的 RTX 包",那是 padding,不是 bug。
8.3 应对短时丢包尖峰
pc.addEventListener('connectionstatechange', () => {
if (pc.connectionState === 'disconnected') {
// 临时性丢包,等 8s 看是否恢复
setTimeout(() => {
if (pc.connectionState === 'disconnected') pc.restartIce();
}, 8000);
}
});
GCC 在 ICE 短暂中断后会从最低码率重新探测,3~5s 内恢复正常码率。
9. 完整代码:自建简易带宽监控
class BandwidthMonitor {
constructor(pc, opts = {}) {
this.pc = pc;
this.interval = opts.interval ?? 1000;
this.history = [];
this.maxHistory = 60;
this.timer = null;
}
start() {
this.timer = setInterval(() => this.tick(), this.interval);
}
stop() {
clearInterval(this.timer);
}
async tick() {
const stats = await this.pc.getStats();
const sample = { ts: Date.now() };
for (const r of stats.values()) {
if (r.type === 'candidate-pair' && r.nominated) {
sample.availableOut = r.availableOutgoingBitrate;
sample.rtt = r.currentRoundTripTime;
}
if (r.type === 'outbound-rtp' && r.kind === 'video') {
sample.targetBitrate = r.targetBitrate;
sample.limitReason = r.qualityLimitationReason;
sample.framesEncoded = r.framesEncoded;
}
if (r.type === 'remote-inbound-rtp' && r.kind === 'video') {
sample.fractionLost = r.fractionLost;
sample.remoteJitter = r.jitter;
}
}
this.history.push(sample);
if (this.history.length > this.maxHistory) this.history.shift();
this.analyze(sample);
}
analyze(s) {
if (s.limitReason === 'bandwidth' && s.fractionLost > 0.05) {
console.warn('[BWE] 带宽受限且丢包 > 5%,考虑降分辨率');
}
if (s.rtt > 0.3) {
console.warn('[BWE] RTT > 300ms', s.rtt);
}
if (this.history.length >= 10) {
const recent = this.history.slice(-10);
const variance = this.variance(recent.map(x => x.availableOut));
if (variance > 5e11) {
console.warn('[BWE] 带宽估计波动剧烈,链路不稳');
}
}
}
variance(arr) {
const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
return arr.reduce((acc, x) => acc + (x - mean) ** 2, 0) / arr.length;
}
}
const monitor = new BandwidthMonitor(pc, { interval: 500 });
monitor.start();
10. 反模式
| 反模式 | 后果 | 正确做法 |
|---|---|---|
删 SDP 里的 transport-cc 扩展头 | 全程没有 TWCC,退化到 REMB | 不动 SDP |
把 maxBitrate 设得很低想"主动限速" | 编码器一直撞顶,看似稳定其实没用上带宽 | 让 GCC 自适应 |
| 在前端实现自己的"丢包检测降码率" | 与 GCC 打架,码率震荡 | 信任浏览器的 BWE |
| SFU 用全局带宽估计转发给所有订阅者 | 一个弱网拖垮所有人 | 每订阅者独立估计 |
把 initialAvailableOutgoingBitrate 设到 5Mbps 想"快速达到清晰度" | 首 2 秒丢包暴涨 | 起步 1~1.5Mbps |
用 qualityLimitationReason 触发重连 | bandwidth 是常态,重连会更糟 | 只在 connectionState=failed 重连 |
| 关掉 padding | underuse 时无法探测,码率上不去 | 保持默认 |
11. 排障对照
| 症状 | 看什么 | 可能根因 |
|---|---|---|
availableOutgoingBitrate 一直贴 maxBitrate | 配置 | maxBitrate 设太低 |
availableOutgoingBitrate 在 100kbps 抖动 | TWCC 未启用 | 检查 SDP transport-cc |
fractionLost > 10% 持续 30s | 链路 | 切 TURN/TCP,降码率 |
qualityLimitationReason=cpu | 设备 | 切硬编码或降分辨率 |
| RTT 周期性 > 1s | 排队 | 启用 ECN / 降码率 |
12. 权威资料
- TWCC draft:https://datatracker.ietf.org/doc/draft-holmer-rmcat-transport-wide-cc-extensions/
- GCC draft:https://datatracker.ietf.org/doc/draft-ietf-rmcat-gcc/
- RFC 8888(更通用的 Congestion Control Feedback):https://www.rfc-editor.org/rfc/rfc8888
- libwebrtc bwe 实现:https://webrtc.googlesource.com/src/+/refs/heads/main/modules/congestion_controller/
- mediasoup TWCC:https://mediasoup.org/documentation/v3/mediasoup/api/#WebRtcTransport
- W3C
RTCStats:https://www.w3.org/TR/webrtc-stats/ - 核对日期:2026-06-22