SDP 协商与 Offer/Answer 流程

深入 SDP 与 Offer/Answer 模型:逐行解读真实 SDP、拆解 m-line 与 a= 属性、讲清 signalingState 状态机与再协商流程、编解码协商中的 rtpmap 与 fmtp、方向属性与 SDP 改写风险,并给出常见协商失败坑与排障方法。

SDP(Session Description Protocol)是 WebRTC 里最容易被忽视、也最容易出问题的一环。它看起来只是一段文本,实际却同时承担了媒体描述、编解码能力声明、ICE 凭证传递、DTLS 指纹交换四项职责。很多人把 createOffer 的结果当成不透明的字符串直接转发,一旦协商失败就束手无策。

本文的目标是让你能读懂一条 SDP 的每一行,理解 Offer/Answer 状态机如何约束操作顺序,并掌握编解码协商与再协商的工程细节。读完你应该能在浏览器控制台里定位绝大多数协商类故障。

一、SDP 是什么

1.1 一段纯文本的会话描述

SDP 最早由 RFC 4566 定义,是一种纯文本的键值描述格式。每一行形如 <字符>=<值>,其中字符表示行类型。WebRTC 使用的 SDP 是 RFC 8829(JSEP)规定的子集,并扩展了 ICE、DTLS 相关属性。

常见行类型如下:

前缀名称含义
v=version协议版本,固定为 0
o=origin会话标识与版本号
s=session name会话名,WebRTC 中固定为 -
t=timing时间描述,WebRTC 中为 0 0
m=media媒体行,描述一条媒体流
c=connection连接信息,通常为 IN IP4 0.0.0.0
a=attribute属性行,数量最多、变化最大
b=bandwidth带宽提示

其中 m= 与 a= 是理解 WebRTC 协商的关键,其余行在 WebRTC 中几乎恒定不变。

1.2 SDP 不是配置,而是能力提议

一个常见的误解是认为 SDP 是「我要用这个编码」的指令。实际上它是一份能力提议:Offer 方列出自己支持的全部编解码与参数,Answer 方从中挑选交集并回填。这种「提议-应答」模型决定了 SDP 必须成对出现,单方面修改往往导致协商失败。

二、一条真实 SDP 逐行解读

下面是一条典型的音视频 Offer(已省略部分无关行):

v=0
o=- 4611731400430051336 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0 1
a=msid-semantic: WMS stream0
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 0 8 110
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:8hhY
a=ice-pwd:asd88fgpdd777uzjYhagZg
a=ice-options:trickle
a=fingerprint:sha-256 6B:8B:5F:...:2A
a=setup:actpass
a=mid:0
a=sendrecv
a=rtcp-mux
a=rtpmap:111 opus/48000/2
a=fmtp:111 minptime=10;useinbandfec=1
a=rtpmap:63 red/48000/2
a=rtpmap:9 G722/8000
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:110 telephone-event/48000
a=ssrc:1234567 cname:abc
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
c=IN IP4 0.0.0.0
a=mid:1
a=sendrecv
a=rtcp-mux
a=rtcp-fb:96 nack
a=rtcp-fb:96 nack pli
a=rtcp-fb:96 goog-remb
a=rtcp-fb:96 transport-cc
a=rtpmap:96 VP8/90000
a=rtpmap:97 rtx/90000
a=fmtp:97 apt=96
a=rtpmap:98 H264/90000
a=fmtp:98 profile-level-id=42e01f;packetization-mode=1

2.1 会话级属性

a=group:BUNDLE 0 1 表示把 mid 为 0 和 1 的两条媒体复用同一条传输通道(同一个五元组)。BUNDLE 能显著减少候选与端口的数量,现代实现默认开启。a=msid-semantic: WMS 声明媒体流语义。

2.2 媒体级属性

以音频 m-line 为例,几个关键属性值得单独说明:

  • a=ice-ufrag 与 a=ice-pwd:ICE 凭证,用于连通性检查的完整性校验。每次 ICE 重启都会变化。
  • a=fingerprint:sha-256 ...:DTLS 证书指纹,用于在不依赖 PKI 的情况下验证对端身份,是 DTLS-SRTP 的信任根。
  • a=setup:actpass:DTLS 角色协商,actpass 表示「我既能主动也能被动」,由 Answer 方决定最终角色。
  • a=mid:0:媒体标识,BUNDLE 与候选配对都依赖它。
  • a=sendrecv:方向属性,决定这条流是收发还是单向。

三、m-line 与媒体描述

3.1 m-line 的七个字段

一条 m-line 形如 m=<media> <port> <proto> <fmt> ...,字段含义如下:

位置示例含义
mediaaudio / video媒体类型
port9端口,WebRTC 中为占位符 9
protoUDP/TLS/RTP/SAVPF传输协议,SAVPF 表示带反馈的 SRTP
fmt111 63 9 …载荷类型列表,按优先级排序

UDP/TLS/RTP/SAVPF 这一串几乎可以作为 WebRTC 的指纹:UDP 传输、TLS(即 DTLS)握手、RTP 承载、SAVPF 表示安全音频视频与反馈。

3.2 载荷类型与 rtpmap

m-line 里出现的数字是动态载荷类型(Payload Type),必须通过 a=rtpmap 映射到具体编解码:

a=rtpmap:111 opus/48000/2

这行表示载荷类型 111 对应 Opus,采样率 48000 Hz,双声道。数字本身没有固定含义,只有结合 rtpmap 才能确定编解码。

四、Offer/Answer 状态机

4.1 signalingState 取值

RTCPeerConnection.signalingState 约束了合法操作的顺序,取值如下:

状态含义允许的操作
stable无进行中的协商createOffer / createAnswer
have-local-offer已设置本地 OffersetRemoteDescription(answer)
have-remote-offer已设置远端 OffercreateAnswer / setLocalDescription
have-local-pranswer已设置本地临时应答setRemoteDescription(answer)
have-remote-pranswer已设置远端临时应答setLocalDescription(answer)
closed连接已关闭无

最常见的新手错误是在 have-local-offer 状态下再次调用 createOffer,或在未设置远端描述时调用 createAnswer,都会抛出 InvalidStateError。

4.2 一次完整的协商

// 发起方
const pcA = new RTCPeerConnection();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
stream.getTracks().forEach((t) => pcA.addTrack(t, stream));

const offer = await pcA.createOffer();
console.log("协商前状态:", pcA.signalingState); // stable
await pcA.setLocalDescription(offer);
console.log("设置本地描述后:", pcA.signalingState); // have-local-offer

// 通过信令发送 offer.sdp 给对端
signaling.send({ type: "offer", sdp: pcA.localDescription.sdp });

// 接收方
const pcB = new RTCPeerConnection();
pcB.ontrack = (e) => (document.querySelector("#remote").srcObject = e.streams[0]);

async function handleOffer(sdp) {
  await pcB.setRemoteDescription({ type: "offer", sdp });
  console.log("接收方状态:", pcB.signalingState); // have-remote-offer
  const answer = await pcB.createAnswer();
  await pcB.setLocalDescription(answer);
  signaling.send({ type: "answer", sdp: pcB.localDescription.sdp });
}

// 发起方收到 answer
async function handleAnswer(sdp) {
  await pcA.setRemoteDescription({ type: "answer", sdp });
  console.log("回到稳定态:", pcA.signalingState); // stable
}

4.3 临时应答 PRANSWER

状态机里还有 have-local-pranswer 与 have-remote-pranswer 两个状态,对应临时应答(provisional answer)。它允许接收方在正式 Answer 之前先回一个临时的 SDP,例如先接受音频再慢慢协商视频。这在 PSTN 网关与呼叫中心场景里有实际用途,但普通 WebRTC 应用极少使用,多数开发者甚至不会遇到。若排障时看到这两个状态,说明链路里存在 SIP 网关一类的中间设备。

五、编解码协商

5.1 交集与优先级

Answer 方不是简单接受 Offer 的全部编解码,而是计算双方支持的交集,并按自己的偏好排序后回填到 Answer 的 m-line 中。因此在 WebRTC 中,最终使用哪个编解码由 Answer 方决定,Offer 方只能通过调整 m-line 中载荷类型的顺序来施加影响。

编解码典型载荷类型用途关键 fmtp 参数
Opus111(动态)音频useinbandfec=1、minptime
PCMU / PCMA0 / 8(静态)音频兜底无
VP896(动态)视频无
H26498(动态)视频profile-level-id、packetization-mode
rtx97(动态)重传apt=96 指向被重传的编码

5.2 反馈机制与 RTCP

a=rtcp-fb 行声明了本端支持的反馈类型,它直接决定了拥塞控制与抗丢包能力:

a=rtcp-fb:96 nack        // 支持 NACK,可按包请求重传
a=rtcp-fb:96 nack pli    // 支持 PLI,可请求关键帧
a=rtcp-fb:96 goog-remb   // 支持 REMB 带宽估计
a=rtcp-fb:96 transport-cc // 支持 TWCC 传输层拥塞控制

如果某条 SDP 里缺少 transport-cc,那么基于 TWCC 的带宽估计就无从谈起,弱网表现会明显变差。这也是很多「同一套代码在某个浏览器上更卡」的根因之一。

六、方向属性与再协商

6.1 四种方向

方向语义
sendrecv双向收发
sendonly只发送
recvonly只接收
inactive暂停

方向是相对的:一方的 sendonly 对应另一方的 recvonly。修改方向不需要重建连接,通过再协商即可完成,这也是实现「静音」「暂停视频」的优雅方式。

6.2 再协商的触发

新增或移除轨道会自动触发 negotiationneeded,从而开启一轮新的 Offer/Answer:

pc.onnegotiationneeded = async () => {
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  signaling.send({ type: "offer", sdp: pc.localDescription.sdp });
};

// 后续新增屏幕共享轨道,会自动触发再协商
const screen = await navigator.mediaDevices.getDisplayMedia({ video: true });
pc.addTrack(screen.getVideoTracks()[0], screen);

再协商会复用已建立的 ICE 与 DTLS 通道,只更新媒体描述,因此开销远小于首次建连。但要注意,某些实现会在再协商时重排 m-line 顺序,如果信令层按位置而非 mid 关联媒体,就会错位。

七、SDP 改写的风险

生产系统里常见「拦截并改写 SDP」的需求,例如强制某个编解码、删除某些候选、插入自定义属性。改写本身可行,但风险很高:

  • 改写 m= 行的载荷类型顺序会改变协商偏好,但若改得与 rtpmap 不一致会导致解析失败。
  • 删除 a=fingerprint 或 a=ice-ufrag 会直接破坏加密与连通性。
  • 修改 a=setup 角色可能与对端冲突,导致 DTLS 握手死锁。
  • 不同浏览器生成的 SDP 顺序与属性集差异很大,正则替换极易误伤。

更稳妥的做法是优先使用浏览器提供的 API(如 setCodecPreferences)而非字符串改写:

const transceiver = pc.getTransceivers()[0];
const capabilities = RTCRtpSender.getCapabilities("video").codecs;
const preferred = capabilities.filter((c) => c.mimeType === "video/VP8");
transceiver.setCodecPreferences(preferred);

八、SFU 场景下的 SDP 差异

8.1 服务端也会改写 SDP

在多人会议中,客户端往往不直接与对端交换 SDP,而是与 SFU 交换。此时 SFU 会对 SDP 做一次「翻译」:把客户端的 Offer 转成自己作为对端的 Answer,再为每个订阅者生成新的 Offer。这个过程叫 SDP 归一化(normalization)。

这意味着客户端看到的 SDP 与对端真实能力并不完全一致。例如 SFU 可能把多个发布者的轨道合并进同一条 m-line,也可能把某个编解码统一转码。理解这一点,才能解释「为什么两端明明都支持 VP9,实际却在用 VP8」——因为 SFU 中间做了取舍。

8.2 Simulcast 在 SDP 中的表达

Simulcast 让发送方同时编码多个分辨率层,SFU 按订阅者网络状况转发不同层。它在 SDP 中通过 a=simulcast 与 a=rid 表达:

a=rid:0 send pt=96;max-width=1280;max-height=720
a=rid:1 send pt=96;max-width=640;max-height=360
a=rid:2 send pt=96;max-width=320;max-height=180
a=simulcast:send 0;1;2

浏览器侧通过 sendEncodings 声明这些层:

const sender = pc.addTrack(track, stream);
const params = sender.getParameters();
params.encodings = [
  { rid: "0", maxBitrate: 1500000, scaleResolutionDownBy: 1 },
  { rid: "1", maxBitrate: 500000, scaleResolutionDownBy: 2 },
  { rid: "2", maxBitrate: 150000, scaleResolutionDownBy: 4 },
];
await sender.setParameters(params);

注意 setParameters 必须在 sender 已有轨道后调用,且在部分浏览器上首次调用需要先 getParameters 拿到当前值再修改,不能凭空构造。

8.3 从控制台观察协商结果

排障时最直接的手段是把 pc.localDescription.sdp 与 pc.remoteDescription.sdp 打印出来对比,重点看三处:m-line 的数量与顺序、每条 m-line 的载荷类型列表、a=rtcp-fb 行是否包含 transport-cc。这三处能覆盖绝大多数协商异常。

九、常见坑清单

  • 把 SDP 当作不可变字符串缓存,导致 ICE 重启后使用了过期的 ice-ufrag。
  • 在 have-local-offer 状态下重复 createOffer,抛出 InvalidStateError。
  • 用 sdpMLineIndex 关联媒体,但再协商后 m-line 顺序变化,关联错乱。
  • 忽略 a=rtcp-fb 中的 transport-cc,导致拥塞控制退化。
  • Answer 方未回填 a=setup:active/passive,DTLS 角色协商失败。
  • 手工拼接 SDP 时丢失 a=mid,BUNDLE 复用失败。
  • 忘记 a=msid,导致对端 ontrack 拿不到 streams,只能拿到裸轨道。
  • 在移动端浏览器上强行指定 H264 profile-level-id,与硬件编码器不匹配。
  • 把 a=rtcp-mux 去掉,导致 RTP 与 RTCP 使用不同端口,BUNDLE 复用失败。
  • 未设置 a=ice-options:trickle 却使用 Trickle ICE,候选无法及时发送。
  • 把 a=setup 两端都写成 active,DTLS 角色冲突导致握手停滞。

小结

SDP 是 WebRTC 的「合同文本」,它把编解码、ICE 凭证、DTLS 指纹与方向语义都写在同一份描述里。读懂 m-line 与 a= 属性,理解 Offer/Answer 状态机对操作顺序的约束,是排查协商问题的前提。生产代码应尽量避免字符串改写 SDP,优先使用 setCodecPreferences 等标准 API。编解码协商的结果会直接影响拥塞控制能力,这部分细节与 音视频编解码与拥塞控制 紧密相关。SDP 只是描述,真正把路径打通的是 ICE / STUN / TURN 与 NAT 穿透原理 里讲的候选与检查。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 信令服务与房间水平扩展
  3. 低延迟直播:LL-HLS 与 WebRTC 直播