做WebRTC服务端的时间长了,你会发现SDP协商是所有“看起来简单、跑起来折磨”的问题里最典型的一个。文本就那么几行,但每一行都对应着一段连接的生命周期。MediaSoup的SDP协商尤其有意思——它并没有像传统服务器那样去解析和重写SDP文本,而是把SDP里的传输参数、加密参数、媒体参数拆成结构化JSON,交给上层信令和客户端SDK去完成最终协商。这种设计让服务端保持干净,也让排障变得直观很多。
这篇文章是想给正在搭MediaSoup房间服务、或者被“为什么浏览器能看到对方但没画面”这类问题折磨的同学,把SDP协商这条链路从头到尾拆一遍。不涉及转码,也不讲传输优化,就专注讲清楚一件事:MediaSoup到底怎么和浏览器完成一次有效协商。看完之后,你应该能自己定位大部分协商失败问题,也能在自定义信令时少踩几个我踩过的坑。
1. 为什么MediaSoup不老老实实抠SDP
先纠正一个常见的认知偏差:很多人以为MediaSoup服务器会像传统SIP服务器那样,把客户端的SDP拿过来,改几个字段,再作为answer返回。实际上,在MediaSoup的设计里,服务器既不完整解析你的SDP,也不生成一个完整SDP回给你。它做的是更“底层”的事——把SDP里真正影响媒体传输的参数抽出来,变成JSON协议里的字段。
1.1 透传SDP给SFU的问题出在哪
SFU(选择性转发单元)和MCU最大的区别,就是不转码、不改编码格式,只做RTP包的转发和可能的SSRC重写、payload type重映射。但这里有个前提:SFU必须“认识”这些RTP流。RTP包本身是没有编解码上下文信息的,48开头的RTP包头只告诉你payload type、sequence number、SSRC、timestamp,至于这个payload type对应的是H264还是VP8、分辨率多少、用不用RTX,全靠协商阶段双方约定的SDP。
如果SFU真的把SDP当黑盒透传,它就拿不到几个关键映射表:哪个payload type代表哪个codec、SSRC对应的RTP流是哪一路、RTCP反馈到底支持NACK还是transport-cc。没有这些信息,转发就无从谈起。
还有一个很容易忽视的问题:WebRTC的SDP是“端到端”语义的,里面带了一堆sfu完全不关心的东西,比如o=字段的会话ID、a=msid这种媒体流标识、m=行的端口号。这些字段在P2P场景里很有用,但在SFU场景里反而会引入噪声。传输层本来就是客户端和SFU之间的,再套一层“端到端SDP”容易把排查方向带偏。
1.2 MediaSoup的路线:把SDP拆成三个参数包
MediaSoup的答案很干脆:不碰SDP文本,直接暴露结构化参数。它把协商相关的内容分成三大块:
- ICE参数与候选:负责网络路径发现和连通性检查
- DTLS参数:负责端到端加密握手
- RTP参数:负责媒体编码、SSRC、RTP头扩展等语义
在服务端API里,你会看到iceParameters、iceCandidates、dtlsParameters、rtpParameters这些JSON对象,它们和SDP里的字段几乎是一一对应的映射关系。客户端SDK拿到这些JSON后,再在浏览器内部拼装成真正符合WebRTC规范的SDP。
这种设计让服务器端代码变得非常“薄”。服务端不需要维护一套完整的SDP parser,不需要处理大小写、换行、各种浏览器怪癖;它只需要把这些参数保存下来,等RTP包到的时候按参数映射去转发。
打个比方:SDP就像是一张手写合同,里面混杂了地址、门禁卡信息、货物清单。传统B2BUA会拿这张合同抄一遍再改几个字,MediaSoup的做法是把合同拆开,把关键信息录入系统,然后让各自的系统用标准格式重新生成一份新合同。
1.3 能力协商:router.rtpCapabilities的诞生
除了传输和加密,MediaSoup还引入了“RTP能力”的概念。每个Router在创建时,你要给它声明一组mediaCodecs,比如opus、VP8、H264。Router会把这组codec做一次标准化,生成一份router.rtpCapabilities。客户端在启动时通过信令拿到这份能力列表,再和自己的浏览器能力取交集,只留下双方都支持的codec。
这就是为什么MediaSoup在协商链路上比传统方案多了一个“加载设备能力”的步骤。你必须在客户端device.load({ routerRtpCapabilities })之后,再创建transport。否则客户端SDK不知道远端支持哪些codec,生成的offer里可能只带浏览器默认的OPUS和VP8,服务端如果只声明了H264,协商就直接失败了。
2. 拆解协商中的三件套:ICE、DTLS与RTP
如果说SDP是一条完整的信令链路,那么ICE、DTLS、RTP就是这条链路上三个完全不同的协议层。经常有同学在排查问题时,一上来就盯着SDP里的a=fmtp看半天,其实问题出在ICE没连通。所以在MediaSoup的体系里,我习惯把这三层拆开排查。
2.1 ICE与候选:先让双方能找到彼此
ICE负责解决“网络路径怎么走”的问题。在MediaSoup服务端,你调用createWebRtcTransport之后,transport对象上会直接带出三组数据:
// transport.iceParameters { ufrag: 'xxxx', pwd: 'yyyy' } // transport.iceCandidates [ { foundation: 'abc', ip: '203.0.113.10', port: 40000, protocol: 'udp', type: 'host' } ]从SDP角度看,ufrag对应a=ice-ufrag,pwd对应a=ice-pwd,iceCandidates对应一行行的a=candidate。MediaSoup服务端在创建transport的瞬间,就分配好了本端的ICE用户名片段、密码和候选地址。这些数据会通过你的信令服务器,原样传给客户端。
客户端SDK拿到这些服务端ICE参数后,会在浏览器内部创建自己的ICE代理,再和服务端的候选做连通性检查。好消息是,你不需要自己写STUN Binding请求的逻辑;坏消息是,如果服务端的候选地址是错的,或者端口被防火墙挡了,客户端就永远卡在connecting状态。
这里有一个高频配置错误:用Docker部署时,listenIps没有设置announcedIp。MediaSoup在容器里看到的IP是内网地址,服务器通过announcedIp把公网地址发给客户端。如果你忘了这个配置,客户端拿到的候选是172.x.x.x或者10.x.x.x,根本连不进来。
2.2 DTLS指纹与角色:握手之前先交换“身份证”
ICE连通之后,加密握手才会开始。WebRTC的媒体面加密用的是DTLS-SRTP,双方必须先完成DTLS握手,才能协商出SRTP密钥。
SDP里a=fingerprint:sha-256 ...就是DTLS指纹,相当于一端的“身份证”。MediaSoup有两种方式交换这个指纹:一种是服务端创建transport后,把自身的dtlsParameters.fingerprints通过信令下发给客户端;另一种是客户端通过connect事件回调,把自己的dtlsParameters(包含指纹)上行发给服务端。
// 服务端创建transport后返回 { dtlsParameters: { role: 'auto', fingerprints: [ { algorithm: 'sha-256', value: 'AA:BB:CC:...' } ] } }重点在于role字段。DTLS会话有active、passive、actpass三种角色:主动方发起连接,被动方等待连接,actpass表示都可以。在P2P的offer/answer里,通常offer方声明actpass,answer方根据情况决定自己是active还是passive。
MediaSoup服务端初始角色是auto,也就是等客户端传过来的dtlsParameters.role来决定。浏览器端一般会根据自己的角色生成。如果你在自定义信令时把角色写反,或者两端都设成active,那么双方都在主动发起连接,握手包会“擦肩而过”,连接状态会一直在connecting和connected之间反复横跳,最后超时。
2.3 RTP参数:codec能不能玩的真正门槛
ICE和DTLS解决了“通不通”和“安不安全”的问题,真正决定“能不能出画面”的,是RTP参数协商。
MediaSoup里RTP参数主要包含三块:
codecs:codec的MIME类型、payload type、clock rate、channels、codec-specific参数、rtcpFeedbackencodings:SSRC、RTX对应的SSRC、simulcast的多个空间层、码率上限headerExtensions:RTP头扩展,比如绝对时间戳、transport-wide拥塞控制序号等
SDP里的m=video行会有a=rtpmap:101 VP8/90000,对应到MediaSoup就是codecs: [{ mimeType: 'video/VP8', payloadType: 101, clockRate: 90000 }]。SDP里的a=fmtp:101 packetization-mode=1对应parameters字段,SDP里的a=ssrc:12345 cname:xxx对应encodings[0].ssrc。
这里建议收藏下面的映射表,排查问题的时候对着看,比满屏翻SDP舒服得多:
| SDP原文 | MediaSoup参数 | 说明 |
|---|---|---|
a=ice-ufrag/a=ice-pwd | iceParameters.ufrag/pwd | 连通性检查凭据 |
a=candidate | iceCandidates[] | 候选地址列表 |
a=fingerprint | dtlsParameters.fingerprints | DTLS证书指纹 |
a=setup | dtlsParameters.role | DTLS握手角色 |
m=audio/m=video | kind | 媒体类型 |
a=rtpmap/a=fmtp | codecs[] | 编码格式与参数 |
a=rtcp-fb | codecs[].rtcpFeedback | 丢包重传、拥塞控制反馈 |
a=ssrc/a=ssrc-group | encodings[] | RTP流与RTX映射 |
a=extmap | headerExtensions[] | RTP头扩展 |
这张表是我个人最常用的一张速查表。每次遇到“代码看起来没问题但就是没画面”,我都会先把SDP里的信息和MediaSoup的JSON参数逐行对照,通常能找到线索。
3. 走一遍真实协商流程:服务端与客户端对照
纸上谈兵到这里差不多了,直接上一套最小可跑通的协商流程。这里默认你用的是Node.js服务端和浏览器客户端的组合,这也是MediaSoup最主流的用法。
3.1 服务端创建Router和WebRtcTransport
服务端第一步是创建Worker和Router。Router创建时要显式声明媒体能力,这一步会直接影响后面所有协商:
const { createWorker } = require('mediasoup'); const worker = await createWorker(); const router = await worker.createRouter({ mediaCodecs: [ { kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 }, { kind: 'video', mimeType: 'video/VP8', clockRate: 90000 }, { kind: 'video', mimeType: 'video/H264', clockRate: 90000, parameters: { 'packetization-mode': 1, 'profile-level-id': '42e01f' } } ] });接着是创建WebRtcTransport。这个Transport就是协商的“载体”,每个参与通信的客户端都要有一个自己的Transport实例:
const transport = await router.createWebRtcTransport({ listenIps: [{ ip: '0.0.0.0', announcedIp: '203.0.113.10' }], enableUdp: true, enableTcp: true, preferUdp: true, enableSctp: true, numSctpStreams: { OS: 1024, MIS: 1024 } });创建完成后,通过信令把下面三个对象发给客户端:
{ id: transport.id, iceParameters: transport.iceParameters, iceCandidates: transport.iceCandidates, dtlsParameters: transport.dtlsParameters }这些数据是协商的“服务端半场”。请注意,服务端此刻并没有生成SDP,也没有生成Offer或Answer,它只是把协商所需的“材料”交给了客户端。
3.2 客户端Device、SendTransport与connect事件
客户端拿到上面的参数后,先用Router的rtpCapabilities初始化Device:
const device = new mediasoupClient.Device(); await device.load({ routerRtpCapabilities: rtpCapabilities });然后创建发送方向Transport:
const sendTransport = device.createSendTransport({ id: transportId, iceParameters, iceCandidates, dtlsParameters });创建之后,MediaSoup客户端SDK会陆续触发一系列事件,其中最核心的就是connect和produce。connect事件的意思是:“底层需要把DTLS参数同步给服务端,请信令跑一次transport-connect”:
sendTransport.on('connect', async ({ dtlsParameters }, callback, errback) => { try { await api.request('transport-connect', { transportId, dtlsParameters }); callback(); } catch (err) { errback(err); } });注意dtlsParameters里包含的是客户端的DTLS指纹和角色。服务端收到这个请求后,要调用transport.connect({ dtlsParameters }),这样服务端的DTLS层才会知道对端是谁、角色固定成什么。这一步不做,后续DTLS握手必失败。
接下来是创建Producer。produce事件会在真正发送媒体前触发,要求你的信令把“要发送的媒体参数”上报给服务端,并在服务端创建Producer:
sendTransport.on('produce', async ({ kind, rtpParameters }, callback, errback) => { try { const { id } = await api.request('produce', { transportId, kind, rtpParameters }); callback({ id }); } catch (err) { errback(err); } }); const videoProducer = await sendTransport.produce({ track: videoTrack });调用sendTransport.produce({ track })后,SDK内部会把本地轨道的媒体信息生成SDP里的m=行,通过浏览器底层的PeerConnection完成offer/answer流程。但对你而言,这些都被封装掉了。你需要关注的就是connect和produce这两个事件能否正确回传。
3.3 消费端协商:consume事件里发生了什么
消费端流程和发送端类似,但多了“服务端主动创建Consumer”这一步。通常的逻辑是:服务端收到某个Producer后,会广播给房间内的其他人;其他客户端在信令层收到“有新Producer”的通知,然后请求服务端开始消费。
服务端收到消费请求后,执行:
const consumer = await transport.consume({ producerId, rtpCapabilities: deviceRtpCapabilities });这里有个常见误区:rtpCapabilities必须是发起消费的那个客户端的真实能力,不能直接拿Router的rtpCapabilities硬塞。客户端在device.load之后会暴露自己的device.rtpCapabilities,把它通过信令传给服务端是最靠谱的方式。
服务端创建好Consumer后,会把consumer.id、consumer.rtpParameters等信息通过信令回给客户端。客户端SDK那边的接收Transport会触发consume事件:
recvTransport.on('consume', async ({ id, kind, rtpParameters }, callback, errback) => { try { const { id } = await api.request('consume', { transportId, producerId, rtpCapabilities: device.rtpCapabilities }); callback({ id }); } catch (err) { errback(err); } }); const consumer = await recvTransport.consume({ id: consumerId });拿到consumer.track之后,把它塞进<video>元素或者AudioContext里,播放链路就通了:
const remoteVideo = document.getElementById('remote-video'); remoteVideo.srcObject = new MediaStream([consumer.track]);整条协商链路的时序其实是:服务端参数下发 → 客户端创建Transport → connect事件同步DTLS → produce/consume事件同步媒体参数 → 浏览器内部完成SDP Offer/Answer和ICE连接。一旦你理解了这三层,MediaSoup的协商就不再是一团迷雾。
4. 协商失败排查实录:常见坑与诊断方法
下面这些坑,我从实际项目里逐一踩过,每一个都是真实的“线上事故”。有些看起来是MediaSoup的问题,最后定位却是网络环境或配置细节。
4.1 ICE连不上:listenIps和候选注入
典型现象是:浏览器端iceConnectionState一直checking,服务端transport的connectionState始终不变成connected。这时候先别查SDP里的媒体参数,先把ICE候选拎出来看。
第一个要检查的是announcedIp。MediaSoup默认使用本机网卡IP生成候选,但如果你的服务跑在Docker里,或者服务器有多块网卡,或者前端通过NAT访问,客户端拿到的候选地址必须是公网可达地址。我遇到过最典型的案例,是同事把listenIps设成了0.0.0.0,于是候选里出现了一堆10.x.x.x的内网IP。客户端和服务器明明在同一台机器上调试没问题,一部署到云端就完全连不上。
第二个要检查的是UDP/TCP端口范围。默认情况下,MediaSoup的RTP端口范围很大,如果云安全组没有放行,ICE即使成功,媒体包也可能被防火墙丢掉。建议在worker创建时显式设置端口范围,比如rtcMinPort: 40000, rtcMaxPort: 49999,并在防火墙里放行这段UDP端口,以及可选的TCP端口。
第三个容易忽略的点是TCP fallback。某些网络环境UDP被QoS惩罚或者直接封禁,如果服务端只开了enableUdp,客户端死活连不上。打开enableTcp: true,会把可用的传输层协议多一条路。
4.2 DTLS失败:指纹、role和握手时序
如果ICE状态已经connected,但dtlsTransport状态一直卡在connecting,或者在服务端日志里看到DTLS握手超时,那就该查DTLS了。
第一步检查指纹。打开浏览器chrome://webrtc-internals,看SDP里远端指纹,再对比服务端transport.dtlsParameters.fingerprints的值。如果客户端SDK传入的dtlsParameters里的fingerprints是空数组,服务端就永远不知道对端的“身份证”,握手自然失败。
第二步检查role。服务端dtlsParameters.role初始是auto,transport.connect({ dtlsParameters })调用之后,服务端会根据客户端传上来的role确定最终角色。如果客户端传的是client,服务端应该最终保持server;如果两边都是client,DTLS就互相等对方先发ClientHello。注意客户端SDK的connect事件里带上来的role通常是浏览器算好的,一般不需要你手动改;但如果你在信令层面做转发,千万不要把这个role字段丢掉。
第三步检查握手时序。曾有同学把transport.connect()放在了transport.produce()之后,结果DTLS还没建立,媒体参数就先到了,服务端只能干等。MediaSoup客户端SDK的设计里,connect事件一定会先于produce和consume事件触发,但这是在你正确注册事件监听器的前提下。如果你在自定义信令里用了异步乱序的中转,顺序就很难保证了。
4.3 黑屏卡顿:RTCP反馈和H264参数不齐
一到“画面一直黑屏”“偶尔出图但马上卡死”这类问题,基本就不是传输层的事,而是媒体协商没对齐。
最常见的黑屏原因之一,是接收端没有成功的RTCP反馈机制。H264和VP8的视频流,丢包之后如果没有NACK或者PLI反馈,接收端只会一直等关键帧,等不到就永远黑着。MediaSoup的codec声明里,rtcpFeedback字段要显式包含nack和nack pli这类反馈能力:
{ mimeType: 'video/VP8', clockRate: 90000, rtcpFeedback: [ { type: 'nack', parameter: '' }, { type: 'nack', parameter: 'pli' }, { type: 'ccm', parameter: 'fir' }, { type: 'transport-cc', parameter: '' } ] }如果服务端声明codec时漏了rtcpFeedback,浏览器生成的SDP里就没有对应反馈字段。一旦网络抖动,接收端拿不到关键帧请求,画面就会一直卡在最后一帧。
H264的问题则是另一个方向。很多团队在服务端只声明了一种H264 profile,比如42e01f,但某些浏览器(尤其是特定型号的摄像头采集)只支持64001f或者其他profile。MediaSoup不会做转码,Consumer能否播放取决于两端codec的交集。最好的做法是在Router的mediaCodecs里多声明几个常用H264 profile,并保证packetization-mode一致,否则会出现“生产端正常、部分消费端黑屏”的怪象。
4.4 调试工具与参数速查表
最后给一套我常用的排查顺序,按这个顺序走,90%的协商问题能在十分钟内定位:
- 第一步:浏览器打开
chrome://webrtc-internals,看Ice Connection State和DtlsTransport State。如果ICE没connected,查候选和端口;如果ICE已connected但DTLS没connected,查指纹和role。 - 第二步:看SDP里协商出的实际媒体参数。用
webrtc-internals能看到完整Offer/Answer,对照服务端MediaSoup JSON参数,重点比对payload type、rtcpFeedback、header extensions。 - 第三步:看服务端日志。给
mediasoup进程加上调试日志级别,通常能看到DTLS握手的指纹、RTP包配置等关键信息。别把日志级别设成warn就完事,排查协商问题时至少要开到debug。 - 第四步:如果怀疑网络层,用Wireshark抓包,过滤UDP端口段,看有没有STUN Binding请求和DTLS ClientHello。有请求没响应,基本就是防火墙问题。
再附上一张快速问题定位表:
| 现象 | 大概率原因 | 验证方法 |
|---|---|---|
ICE状态卡在checking | 候选地址不可达、端口未放行 | 检查announcedIp、防火墙放行UDP/TCP |
ICEconnected但DTLS不变 | 指纹错误或DTLS角色冲突 | 比对指纹、检查role方向 |
| 有音频无视频 | 视频codec交集为空 | 比对Router codec与device.rtpCapabilities |
| 画面黑屏但不报错 | 缺少PLI/FIR反馈 | 检查rtcpFeedback是否包含nack pli |
| 视频模糊且频繁关键帧 | 带宽估计异常 | 检查transport-cc头扩展是否协商成功 |
| iOS Safari连不上 | H264 profile或packetization-mode不匹配 | 多声明几种H264 profile |
这张表打印出来贴在工位上,比翻文档快得多。
5. 一点个人经验
做MediaSoup相关的项目做了几年,我最大的体会是:SDP协商不是一道“文件解析题”,而是一道“状态机题”。它的难点不在于你有没有看懂SDP文本,而在于你是否能理解ICE、DTLS、RTP这三条独立状态机如何交错推进。
我自己的排障习惯是永远遵循“先传输、再加密、最后媒体语义”的顺序。很多团队一看到没画面就怀疑编解码,实际上只要ICE还卡在checking,后面的一切讨论都没有意义。
最后分享一个小技巧:上线前把服务端Router的mediaCodecs固定为一组稳定值,尽量只留OPUS、VP8和一个广泛兼容的H264 profile,不要从数据库里动态组装codec列表。协商问题最怕的就是“每次会话能力不一样”,固定能力有助于大幅缩短复现和定位的时间。MediaSoup的SDP协商机制本身设计得很干净,真正让项目变乱的,往往是我们自己把信令流程写复杂了。保持参数透明、状态单向流动,问题自然少一半。