如果说信令与 ICE 解决的是「能不能连上」,那么编解码与拥塞控制解决的就是「连上之后好不好用」。同样的网络条件下,编码器选择、帧长设置、拥塞控制算法会带来天差地别的体验。这一层是实时通信工程中最需要专业判断的部分,也是最难通过「换个库」解决的部分。
本文从编解码的实时特性讲起,一路串到 RTP/RTCP 报文与 GCC 拥塞控制。读完你应该能看懂一个实时系统的传输层在做什么,也能在弱网投诉时迅速判断瓶颈在哪一环。
一、实时场景对编解码的特殊要求
通用编解码追求压缩率,实时编解码还要额外满足三个约束:低延迟、抗丢包、可动态调码率。这三点决定了实时编码器不能使用长 GOP 与 B 帧,必须支持快速重同步。
| 编解码 | 类型 | 典型码率 | 抗丢包 | 延迟 | 实时适用度 |
|---|---|---|---|---|---|
| Opus | 音频 | 6~510 kbps | 强(带内 FEC) | 低 | 首选 |
| AAC-LC | 音频 | 96~256 kbps | 弱 | 中 | 兼容用 |
| VP8 | 视频 | 0.1~2 Mbps | 中 | 低 | 通用 |
| VP9 | 视频 | 0.1~3 Mbps | 中 | 低 | 高质量 |
| H264 | 视频 | 0.1~3 Mbps | 中 | 低 | 硬件普遍 |
| AV1 | 视频 | 0.1~4 Mbps | 中 | 较高 | 新场景 |
选择的核心逻辑是:音频几乎总用 Opus,视频则在 VP8/VP9 与 H264 之间权衡。H264 的优势在于硬件编解码支持最广,移动端功耗低;VP9 压缩率更好但硬件支持参差;AV1 压缩率最高但编码开销大,目前多用于下行分发。
二、Opus 详解
2.1 帧长与码率
Opus 的独特之处在于它支持 2.5 到 60 毫秒的多种帧长,且可以在运行时切换。帧长越短延迟越低,但头部开销占比越高。常见取值如下:
| 帧长 | 特点 | 适用 |
|---|---|---|
| 2.5 ms | 延迟最低,开销最大 | 极少使用 |
| 10 ms | 平衡点 | 高互动场景 |
| 20 ms | WebRTC 默认 | 通用通话 |
| 40~60 ms | 开销低,延迟高 | 弱网、纯收听 |
2.2 带内 FEC 与 DTX
Opus 的两个关键抗丢包特性是带内 FEC(in-band FEC)与不连续传输(DTX)。带内 FEC 在编码时把前一帧的低码率副本嵌入当前帧,丢一个包时可以用后一包恢复;DTX 在检测到静音时停止发包,节省带宽。
const pc = new RTCPeerConnection();
const transceiver = pc.addTransceiver("audio", { direction: "sendrecv" });
const params = transceiver.sender.getParameters();
// 通过 codec 参数影响 Opus 行为(浏览器暴露程度有限,多数靠 SDP fmtp)
console.log(params.codecs); // 查看 opus 的 fmtp 参数
在 SDP 中,Opus 的抗丢包能力通过 a=fmtp 体现:
a=rtpmap:111 opus/48000/2
a=fmtp:111 minptime=10;useinbandfec=1;usedtx=1;maxaveragebitrate=32000
useinbandfec=1 开启带内 FEC,maxaveragebitrate 限制平均码率上限。这两个参数是弱网优化的第一手段。
三、视频编解码的关键概念
3.1 帧类型与依赖
视频帧分为 I 帧(关键帧,可独立解码)、P 帧(前向预测)、B 帧(双向预测)。实时场景通常禁用 B 帧,因为它需要缓存后续帧,增加延迟。
I 帧体积远大于 P 帧,通常为 5 到 10 倍。因此关键帧的频率是码率与恢复能力之间的权衡:I 帧越频繁,新加入者越快看到画面,但平均码率越高。
3.2 GOP 与关键帧请求
GOP(Group of Pictures)是一组从 I 帧开始的帧序列。实时场景的 GOP 通常设为 2 到 5 秒。当接收方丢包严重无法恢复时,会通过 RTCP 发送 PLI(Picture Loss Indication)或 FIR(Full Intra Request)请求发送方立即产生一个关键帧。
// 接收方主动请求关键帧
const receivers = pc.getReceivers();
for (const receiver of receivers) {
if (receiver.track.kind === "video") {
// 某些实现提供 requestKeyFrame,或通过 getStats 判断后触发
console.log(receiver.track.id);
}
}
频繁请求关键帧会显著抬升码率,形成「丢包-请求关键帧-码率更高-更丢包」的恶性循环。这是弱网下最常见的正反馈陷阱。
3.3 硬件与软件编码
| 方式 | 优点 | 缺点 |
|---|---|---|
| 硬件编码 | 功耗低、占用 CPU 少 | 参数受限、画质一般 |
| 软件编码 | 参数灵活、画质好 | CPU 占用高、移动端发热 |
移动端应优先硬件编码以控制功耗,桌面端可根据画质需求选择。浏览器会自动决策,但可以通过 setCodecPreferences 影响编解码选择。
四、RTP 与 RTCP
4.1 RTP 头字段
媒体数据以 RTP 包传输,固定头部 12 字节,字段如下:
| 字段 | 位宽 | 含义 |
|---|---|---|
| V | 2 | 版本,固定为 2 |
| P | 1 | 是否有填充 |
| X | 1 | 是否有扩展头 |
| CC | 4 | CSRC 计数 |
| M | 1 | 标记位,视频帧末尾常置 1 |
| PT | 7 | 载荷类型,与 SDP 的 rtpmap 对应 |
| sequence number | 16 | 序列号,用于检测丢包与乱序 |
| timestamp | 32 | 采样时刻,用于音视频同步 |
| SSRC | 32 | 同步源标识,标识一路媒体流 |
序列号与时间戳是接收端判断丢包、乱序与抖动的依据。时间戳的时钟频率由编解码决定:Opus 为 48000 Hz,VP8/VP9 为 90000 Hz。
// 手工解析一个 RTP 包的头部
function parseRtpHeader(buf) {
const version = buf[0] >> 6;
const padding = (buf[0] >> 5) & 0x1;
const extension = (buf[0] >> 4) & 0x1;
const csrcCount = buf[0] & 0x0f;
const marker = (buf[1] >> 7) & 0x1;
const payloadType = buf[1] & 0x7f;
const seq = buf.readUInt16BE(2);
const timestamp = buf.readUInt32BE(4);
const ssrc = buf.readUInt32BE(8);
return { version, padding, extension, csrcCount, marker, payloadType, seq, timestamp, ssrc };
}
4.2 RTCP 反馈报文
RTCP 负责传输控制信息,实时场景常用的报文类型如下:
| 报文 | 全称 | 作用 |
|---|---|---|
| SR / RR | Sender / Receiver Report | 统计丢包、抖动、RTT |
| NACK | Negative Acknowledgement | 请求重传特定序列号 |
| PLI | Picture Loss Indication | 请求关键帧 |
| REMB | Receiver Estimated Max Bitrate | 接收端估计带宽 |
| TWCC | Transport Wide CC | 逐包反馈到达时间 |
其中 TWCC 是现代拥塞控制的基础。它让接收端对每一个收到的包记录到达时间,通过 RTCP 扩展反馈给发送端,发送端据此计算延迟梯度,判断网络是否拥塞。相比仅依赖丢包的旧方案,TWCC 能在丢包发生前就感知拥塞。
4.3 解析 RTCP 反馈
RTCP 报文与 RTP 类似,也是「固定头 + 结构化载荷」。下面这段代码演示如何从裸包中区分 RTCP 类型,并提取 NACK 与 REMB 的关键字段:
const RTCP_TYPES = {
200: "SR",
201: "RR",
205: "NACK",
206: "PSFB", // PLI / FIR / REMB 都属于 PSFB
205.5: "TWCC",
};
function parseRtcp(buf) {
const results = [];
let offset = 0;
while (offset + 4 <= buf.length) {
const firstByte = buf[offset];
const version = firstByte >> 6;
const packetType = buf[offset + 1];
const lengthWords = buf.readUInt16BE(offset + 2);
const totalBytes = (lengthWords + 1) * 4;
if (version !== 2) break;
if (packetType === 205) {
// NACK:统计被请求重传的序列号
const pid = buf.readUInt16BE(offset + 8);
const blp = buf.readUInt16BE(offset + 10);
results.push({ type: "NACK", pid, blp });
} else if (packetType === 206) {
const fmt = firstByte & 0x1f;
if (fmt === 1) results.push({ type: "PLI" });
if (fmt === 4) {
// REMB:解析指数与尾数得到带宽估计
const exp = buf[offset + 12] >> 2;
const mantissa = ((buf[offset + 12] & 0x03) << 16) |
(buf[offset + 13] << 8) | buf[offset + 14];
const bitrate = mantissa * Math.pow(2, exp);
results.push({ type: "REMB", bitrate });
}
} else if (packetType === 200 || packetType === 201) {
results.push({ type: packetType === 200 ? "SR" : "RR", lengthWords });
}
offset += totalBytes;
}
return results;
}
理解 NACK 与 REMB 的字段结构,是排查「重传风暴」与「带宽估计异常」的前提。NACK 的 pid 与 blp 位图一起描述哪些序列号丢失,若短时间内出现大量 NACK,往往意味着上行链路质量恶化或码率超限。
五、拥塞控制
5.1 为什么需要拥塞控制
视频编码器产生的码率必须与网络可用带宽匹配。若发送码率持续超过带宽,路由器队列填满,延迟飙升,最终大量丢包。拥塞控制的目标是动态估计可用带宽,并让编码器与发送速率跟随这个估计。
WebRTC 发送端还有两个配套机制:
- Pacing:把编码器产出的帧在时间上打散发送,避免瞬时突发打满队列。
- Probing:主动以更高码率发送探测包,试探带宽上限,以便在带宽恢复时快速提升。
5.2 GCC 与 TWCC
Google Congestion Control(GCC)是 WebRTC 默认的拥塞控制算法,分两部分工作:
- 基于延迟的控制器:利用 TWCC 反馈计算到达时间梯度,判断排队延迟是否增长。
- 基于丢包的控制器:当丢包率超过阈值时下调目标码率。
两者输出取较小值,作为目标发送码率。这个码率通过 REMB 或 TWCC 反馈链路传递,最终约束编码器的输出。
| 算法 | 依据 | 优点 | 缺点 |
|---|---|---|---|
| GCC(延迟) | 到达时间梯度 | 丢包前感知拥塞 | 对突发敏感 |
| GCC(丢包) | 丢包率 | 简单直接 | 滞后 |
| REMB | 接收端估计 | 实现简单 | 反馈频率低 |
| TWCC | 逐包反馈 | 精度高 | 依赖 SDP 协商 |
a=rtcp-fb:96 transport-cc 这一行决定了能否启用 TWCC。缺少它,拥塞控制会退化到仅靠丢包与 REMB,弱网表现明显变差。
5.3 观察带宽估计
setInterval(async () => {
const stats = await pc.getStats();
stats.forEach((report) => {
if (report.type === "candidate-pair" && report.state === "succeeded") {
console.log("可用上行带宽估计:", report.availableOutgoingBitrate);
console.log("当前 RTT:", report.currentRoundTripTime * 1000, "ms");
}
if (report.type === "outbound-rtp" && report.kind === "video") {
console.log("实际发送码率:", report.bytesSent, "帧数:", report.framesEncoded);
}
});
}, 1000);
availableOutgoingBitrate 是排障弱网问题的关键指标:若它持续低于编码器目标码率,说明网络是瓶颈;若它充足但画质仍差,问题多半在编码侧。
六、分层编码:Simulcast 与 SVC
当接收端网络状况差异很大时,单一码流无法兼顾。Simulcast 让发送方同时编码多个分辨率层,SVC 则在单个码流内分层。
| 方案 | 机制 | 带宽开销 | 实现复杂度 |
|---|---|---|---|
| Simulcast | 独立编码多路 | 高(上行成倍) | 低,兼容好 |
| SVC | 单流分层 | 低 | 高,需 SFU 支持 |
Simulcast 上行开销大,适合上行带宽充足的场景;SVC(如 VP9 的 K-SVC)上行更省,但对编解码与 SFU 要求高。弱网对抗的具体策略见 弱网对抗与自适应码率 。
七、音视频同步
7.1 为什么会出现不同步
音频与视频是两条独立的 RTP 流,各自有不同的时间戳基准与网络路径,到达时间必然存在差异。若不做同步处理,就会出现「口型对不上」的现象。同步的依据是 RTP 时间戳与 RTCP 中的 NTP 时间,接收端据此把两条流对齐到同一时间轴。
7.2 同步的工程做法
// 通过 getStats 观察两条流的时间基准
async function inspectSync(pc) {
const stats = await pc.getStats();
const tracks = new Map();
stats.forEach((r) => {
if (r.type === "inbound-rtp") {
tracks.set(r.kind, {
jitter: r.jitter,
packetsLost: r.packetsLost,
framesDecoded: r.framesDecoded,
});
}
});
console.log("音频:", tracks.get("audio"));
console.log("视频:", tracks.get("video"));
}
浏览器的 MediaStreamTrack 已经在底层做了 A/V 同步,应用层通常无需手动对齐。但当音频与视频来自不同的 RTCPeerConnection(如音频走一路、视频走另一路)时,同步就不再自动,需要借助 RTCP SR 中的 NTP 时间戳手工对齐。
7.3 抖动缓冲
抖动缓冲(jitter buffer)是同步的前提。它把到达的包先缓冲一小段时间再播放,以平滑网络抖动。缓冲越大越平滑但延迟越高,缓冲越小延迟越低但容易卡顿。WebRTC 会动态调整抖动缓冲深度,弱网下自动加大以换取连续性。
| 场景 | 推荐抖动缓冲 | 延迟影响 |
|---|---|---|
| 高质量网络 | 小 | 低 |
| 一般公网 | 自适应 | 中 |
| 移动弱网 | 大 | 高 |
八、常见坑清单
- SDP 中缺少
transport-cc,拥塞控制退化,弱网卡顿。 - 频繁请求 PLI 导致码率暴涨,形成恶性循环。
- Opus 未开启
useinbandfec,丢包直接导致音频断续。 - 视频 GOP 过长,新订阅者等待关键帧时间过久。
- 忽略 Pacing,瞬时突发打满队列,延迟抖动。
- 把
availableOutgoingBitrate当成编码器码率上限而不做缓冲,估计抖动时码率剧烈震荡。 - 移动端强制软件编码,发热降频后帧率崩塌。
- 只用丢包判断拥塞,不启用 TWCC,恢复慢。
小结
编解码与拥塞控制构成了实时媒体的传输核心。音频用 Opus 并开启带内 FEC 是几乎无争议的选择;视频则在压缩率、硬件支持与功耗之间权衡。RTP 头字段与 RTCP 反馈报文是理解传输行为的钥匙,而 TWCC 支撑的 GCC 是现代拥塞控制的基础。能否启用 TWCC,往往在一次 SDP 协商时就已注定。下一环的 弱网对抗与自适应码率 会把这些机制组合成可落地的策略,而多人场景下的转发决策则属于 SFU 与 MCU 架构选型 的范畴。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。