WebRTC II: Packetization, Connectivity & Signaling
WebRTC 的媒体和数据全部跑在 UDP 上,而不是 TCP。这不是习惯,是实时性硬要求:
同一份 UDP 承载了三套不同协议,各管一件事:
| 路径 | 协议栈 | 用途 |
|---|---|---|
| 媒体 | RTP → SRTP → UDP | 音视频帧 |
| 数据 | SCTP → DTLS → UDP | DataChannel 任意应用数据 |
| 握手 | DTLS → UDP | 为 SRTP/SCTP 协商密钥 |
连接前双方需要交换 SDP(编码能力、候选地址)。这套信令不属于 WebRTC 协议栈——WebRTC 规范刻意不规定它,由应用自带:WebSocket、HTTP、XMPP、甚至一张纸条都行。
这是常见误解点:以为”信令服务器”是 WebRTC 的标准组件。它只是个搬运 SDP/ICE 候选的快递员,不参与媒体传输,可以随意替换。
每个 RTP 包开头是固定的 12 字节(CSRC 与扩展另算),逐字段含义:
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|X| CC |M| PT | sequence number |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| timestamp |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| synchronization source (SSRC) identifier |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
| 字段 | 位 | 含义 |
|---|---|---|
| V | 2 | 版本号,恒为 2 |
| P | 1 | 填充位(padding),包尾是否补了 0 字节对齐加密块 |
| X | 1 | 扩展位,头部是否挂了 extension |
| CC | 4 | CSRC 计数,混音器(mixer)场景下记录贡献源 |
| M | 1 | 标记位,视频常用来标”一帧最后一个包”,音频里标说话边界 |
| PT | 7 | 负载类型 payload type,对应协商的 codec(如 96=VP8 动态映射) |
| sequence number | 16 | 包序号,丢包检测全靠它 |
| timestamp | 32 | 采样时刻,决定播放节奏 |
| SSRC | 32 | 这路流的”身份证”,随机选 |
16 位单调递增(环绕),接收方据此发现”该来的包没来”,触发 NACK 重传或 PLI 关键帧请求。注意 RTP 本身不重传——丢包检测在 RTP 层,补救动作(重传、降码率)由 RTCP/应用层决定。
时间戳单位是 每路 codec 自己的时钟频率,不是秒也不是毫秒:
播放端据此做抖动缓冲(jitter buffer),决定”这个包什么时候播”。时钟频率必须双方一致,否则时间差全错。
32 位随机数标识一路流。同一会话里多路流(多个摄像头、音频)靠它区分。SSRC 是随机生成的,可能撞车,规范要求碰撞检测——发现重复就重新随机一个并通过 RTCP 通知。
视频流中 M=1 表示”这个包是一帧的最后一个分片”。接收端据此立刻开始解码本帧,不必等到下一个包到达才推断帧结束——能省下一帧的等待。对低延迟非常关键。
96-127 通过 SDP 协商绑定到具体 codec;不能看到 96 就认定是 VP8。H.264/AVC 把编码后的数据切成 NAL 单元(Network Abstraction Layer),每个 NAL 带 1 字节头加 payload。一个关键帧(IDR)常达几十 KB,远超网络 MTU(约 1500 字节,UDP 路径上安全值约 1200)。RTP 必须决定怎么把 NAL 塞进 RTP 包,有三种模式(RFC 6184)。
一个 RTP 包正好装一个 NAL。NAL 头的第一字节同时充当 RTP payload 的第一个字节。默认模式,适用于 NAL 比包小、网络可承载的常规情况。
几个 小 NAL 塞进一个包。典型场景:每帧开头都有的 SPS(序列参数集)和 PPS(图像参数集),两者加起来才几十字节,单独发浪费包头开销。STAP-A 把多个小 NAL 用长度前缀串起来:
RTP payload:
[ STAP-A NAL头(1字节, NRI继承最大) ][ NAL长度(2) | NAL单元 ][ NAL长度(2) | NAL单元 ]...
省去每个小 NAL 各自一份 RTP 头的开销。
大 NAL(如 IDR 关键帧)必须切片。FU-A 把一个 NAL 拆成多个 RTP 包,每片前加 2 字节 FU 头:
FU indicator(1字节: NAL原头) | FU header(1字节: S E R Type)
FU header 里三个关键位:
S (Start):本片是 NAL 的第一片E (End):本片是 NAL 的最后一片Type:原 NAL 类型(如 IDR=5)接收端按 RTP 序列号顺序拼回完整 NAL,靠 S/E 标记知道起止。
这是关键帧分片最痛的点:IDR 被切成 N 个 FU-A 包,任意一片丢了,整帧拼不回来,本帧无法解码。而且关键帧是后续 P/B 帧的参考,丢帧会让画面长时间花屏,直到下一个关键帧。
补救路径:
RTP 跟 RTCP 绑定成对运行在相邻两个端口上(偶数 RTP,奇数 RTCP)。RTP 只发媒体载荷,RTCP 跑控制信息。双方定期互发 RTCP 报文,构成一条反馈回路:发送方据此调整码率、决定是否重传、是否编新关键帧。
最核心的两类报告:
两者都带一组 report block,关键字段:
| 字段 | 含义 |
|---|---|
| fraction lost | 间隔期丢包率 |
| cumulative lost | 自开始累计丢包数 |
| highest sequence | 见过的最高序号 |
| interarrival jitter | 包间抖动(无单位,需换算) |
| LSR / DLSR | 用于算 RTT |
往返时延 RTT 就靠 LSR + DLSR 算出。报告里记”我上次收到 SR 的时间戳(LSR)“和”我收到 SR 到现在过了多久(DLSR)“,发送方收到报告后:
其中 是发送方收到 RR 的当前时刻(按 SR 同一个时钟)。这条公式来自 RFC 3550 §6.4.1。
抖动衡量到达间隔的波动,是码率自适应(GCC)的重要输入。设 是包 的 RTP 时间戳, 是到达时刻(同一时钟),则:
用 的低通系数,平滑掉突发尖刺。这条递推公式是 RFC 3550 的标准实现。
WebRTC 在 SR/RR 之外用一堆反馈消息(RFC 4585 扩展):
| 包 | 全称 | 触发 |
|---|---|---|
| NACK | Negative ACK | 单包丢了,请重传这一个 |
| PLI | Picture Loss Indication | 参考帧丢了,请发关键帧 |
| FIR | Full Intra Request | 强烈请求完全关键帧 |
| REMB | Receiver Estimated Max Bitrate | 接收方报告”我带宽就这么多” |
| transport-cc | Transport-wide Congestion Control | 全局序号反馈,做发送端拥塞控制 |
这套反馈正是上集”自适应”的神经:NACK 补丢、PLI 重同步、REMB/transport-cc 调码率,整套实时自适应就靠 RTCP 这条回路传信号。
WebRTC 有一条铁律:所有媒体和数据必须加密,不存在明文模式。这是规范强制(W3C 强制 SRTP,DTLS-SRTP 用作握手)。原因是浏览器环境天然不可信,明文会让用户的视频流轻易被中间人窥探。
很多人误以为”媒体包塞进 DTLS 里发”。恰恰相反,媒体不进 DTLS:
握手阶段: DTLS 在 UDP 上跑一次完整握手 → 导出 SRTP 密钥
传输阶段: 用导出的密钥做 SRTP 加密 RTP 包 → 直接走 UDP
为什么这么分工?
所以 DTLS 只在开始时握一次手,之后媒体全程走 SRTP。握手归 DTLS,加密归 SRTP,各司其职。
DTLS 握手完成后,双方已有共享的 master secret。WebRTC 用 TLS 标准的 keying material exporter(RFC 5705)从这个 secret 派生出 SRTP 用的密钥:
keying_material = PRF(master_secret,
"EXTRACTOR-dtls_srtp",
client_random + server_random,
key_length)
切出 6 段:客户端/服务端各一对 (encryption key, salt),分给 SRTP 加密和认证。SRTP 不自己协商密钥,全靠 DTLS 喂。 RFC 5764 定义这套 “dtls_srtp” 用法。
每个 RTP/SRTCP 包:
关键设计:SRTP 只加密 payload,RTP 头本身保留明文——因为路由/抖动缓冲/序号检测要看头。这是它和”整包加密”的 TLS 不一样的地方,也是为什么媒体能在加密的同时仍保持极低开销。
WebRTC 除了音视频,还能传任意应用数据,靠 DataChannel。它的协议栈是:
应用数据 → SCTP → DTLS → UDP
为什么不用 TCP?因为 DataChannel 也要躲队头阻塞——一条通道上丢包不能卡住其它消息。
SCTP 最妙的一点:每个流可以独立配置可靠性,覆盖 TCP/UDP 之间的整条谱系(RFC 3758 的 PR-SCTP 部分可靠扩展):
| 配置 | 行为 | 适用 |
|---|---|---|
| 完全可靠、有序 | 像 TCP,丢了必须重传到对 | 文件传输 |
| 完全可靠、无序 | 丢了重传但不按序 | 已分片的数据 |
| 限定重传次数 | 试 N 次后丢弃 | 实时但偶尔可丢的控制 |
| 限定生存时间 | 超过 T 毫秒就丢弃 | 键鼠/手柄指令 |
| 不可靠 | 像 UDP,丢了不管 | 实时遥测 |
远程遥控的键鼠指令典型走”限定生存时间”:你 50ms 前的鼠标位置现在还没到,再发也没意义(已经有更新的位置了),不如丢掉。这避免了 TCP 那种”老消息堵新消息”的延迟堆积。
一个 DataChannel 连接里可以开成千上万个 流(stream),每个流独立 ID,互不阻塞:
stream 0: 文件分片(完全可靠)
stream 1: 鼠标位置(限定 TTL=50ms)
stream 2: 键盘事件(完全可靠)
流 0 上一个消息丢了需要重传,不会卡住流 1、流 2 的传递——这正是 SCTP 多流要解决的核心问题,也是它相对 TCP 的关键优势。
SCTP over DTLS 是 WebRTC 的”重资产”:
DCEP 协议,RFC 8832)。所以 DataChannel 强,但不是免费午餐。
两台分别在家用网络里的电脑,互相不知道对方 IP——它们各自的 NAT 后面,对外只暴露 NAT 的公网地址,自己用的是内网地址。直接发 UDP 包,会被对端 NAT 当成”来历不明的入站”丢弃。WebRTC 用 ICE 框架(RFC 8445)系统解决这件事。
ICE 让每一端收集 候选地址(candidate),分三类:
| 类型 | 缩写 | 来源 | 适用 |
|---|---|---|---|
| 主机候选 | host | 本机网卡地址 | 同子网直连 |
| 服务器反射 | srflx | 经 STUN 问”我在公网是哪个 IP:port” | NAT 不太刁钻时直连 |
| 中继 | relay | TURN 服务器分配一个它上面的地址 | 任何 NAT 都能连 |
STUN(Session Traversal Utilities for NAT,RFC 5389)工作极简单:
srflx 候选,经信令通道告诉对端。对端往这个公网地址发包,NAT 把它转给客户端。前提是 NAT 类型”温和”。锥形 NAT(cone NAT)允许这种映射被复用;对称型 NAT(symmetric NAT)会为每个对端分配不同的映射,STUN 拿到的地址对其它对端无效,直连就失败了。
当直连穿不过(对称型 NAT、严格企业防火墙),TURN(RFC 8656)兜底:客户端连到 TURN 服务器,TURN 分配一个它自己的地址当候选。对端发到 TURN,TURN 中转给客户端。
代价是延迟和带宽:所有媒体绕一圈 TURN 服务器,多出几十毫秒甚至上百毫秒,还要 TURN 服务器扛流量。所以 TURN 是”宁可慢也要通”的最后选择,直连能成绝不用 TURN。
收集完候选后,两边把候选组合成 candidate pair(我的候选 × 你的候选),逐一发 STUN binding 请求互相试探:
host × host → 试本地直连(最快但常因 NAT 不通)
srflx × srflx → 试公网映射直连
srflx × relay → 一端中转
relay × relay → 双中转(最差但最稳)
成功握手的 pair 记下 RTT。ICE 选 RTT 最小的那条——优先 host,其次 srflx,最后 relay。这就是”能直连就直连,穿不过才走 TURN”的实现。
整个过程叫 ICE 不断连接性检查(ICE continuous checks),允许在已建立连接后继续找更优路径(trickle ICE)。
候选地址列表和编码能力一起塞进 SDP(Session Description Protocol)。SDP 通过带外信令(WebSocket、HTTP)互换:A 发 offer,B 回 answer。注意 SDP 本身不传媒体,只传协商结果,媒体还是走 ICE 选出来的那条 P2P 通道(或 TURN)。
Parsec、Moonlight 这类游戏串流能在局域网把延迟压到十几毫秒(一帧之内),比 WebRTC 的常规百毫秒还低。它们做对的事不是”更猛的编码”,而是在 WebRTC 留足余量的三处做减法:
WebRTC 的 jitter buffer 为了通用网络(移动网络、跨洲链路),会缓存几十到上百毫秒的包,等齐再播。串流方案把这段砍到只剩一两帧甚至零缓冲:
这是延迟里最值钱的一刀,因为缓冲是”为防患而主动加入的延迟”,砍它不损失画质。
普通抓屏流程:GPU 渲染画面 → 回读到内存(系统/编码器)→ 编码 → 发送。回读这一步极慢,PCIe 带宽有限,还会打乱 GPU 流水线。
Parsec/Moonlight 用 NVENC/AMF 的 Direct NVENC 输入:GPU 渲染完的帧直接送进同 GPU 上的硬件编码器,不回读内存。
加上 NVENC 的 low-latency preset(关闭 B 帧、限制参考帧、调小码率缓冲),编码端这一段几乎不再增加帧时间。
WebRTC 在浏览器里跑,多了渲染合成、沙箱、通用 codec 抽象的层。Parsec/Moonlight 是原生客户端:
WebRTC 选择了通用性:
串流方案牺牲通用性换延迟:它要求两端同质化(都用 NVENC)、网络稳定(局域网/专线)、客户端原生。WebRTC 输在极限延迟,赢在随处可用——这是通用 vs 专用的经典权衡。