跳到主要内容

拥塞控制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 长期是 bandwidthavailableOutgoingBitrate 抖动剧烈(一会儿 200kbps 一会儿 3Mbps)。

2. 心智模型

四个关键组件:

  1. Pacer:平滑发送,避免突发流量打爆链路。
  2. 带宽估计器:根据 RTT、丢包、排队延迟产出 Target Bitrate。
  3. 编码器码率控制:把 Target Bitrate 翻译成 QP / bitrate 参数。
  4. 反馈通道: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 反馈给发送端。问题:

  1. 粒度粗:接收端只告诉一个数,发送端拿不到每个包的到达时间。
  2. SFU 不友好:SFU 是转发节点,REMB 只能告诉 SFU 它自己估的带宽,无法推断到真正的接收端。
  3. 算法绑定:发送端只能信接收端的估计。

TWCC 的核心改动:接收端不再估带宽,而是把"每个 RTP 包到达的时间戳"全部回传给发送端。发送端自己跑算法。

维度REMBTWCC
反馈内容估计的最大码率每个包的到达时间
计算位置接收端发送端
多 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带宽不够看是否需要降分辨率
cpuCPU 跟不上降分辨率或换 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
反馈间隔适用场景副作用
50ms1v1 通话,要求灵敏RTCP 流量增加
100ms(默认)通用平衡
250ms大会议(百人以上)反应迟钝,但省 RTCP 带宽

8. 参数调优实战

8.1 初始带宽估计

const transport = await router.createWebRtcTransport({
initialAvailableOutgoingBitrate: 1_500_000, // 1.5Mbps 起步
});
场景推荐起步
移动端会议600kbps
桌面端 1v11.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 重连
关掉 paddingunderuse 时无法探测,码率上不去保持默认

11. 排障对照

症状看什么可能根因
availableOutgoingBitrate 一直贴 maxBitrate配置maxBitrate 设太低
availableOutgoingBitrate 在 100kbps 抖动TWCC 未启用检查 SDP transport-cc
fractionLost > 10% 持续 30s链路切 TURN/TCP,降码率
qualityLimitationReason=cpu设备切硬编码或降分辨率
RTT 周期性 > 1s排队启用 ECN / 降码率

12. 权威资料