一次通话的完整时序
把"建立一路 1v1 视频通话"拆成 7 个阶段,每个阶段都说清楚谁触发、传什么、失败时是什么样。掌握这一张图,后续所有问题都能定位到具体阶段。
1. 全景时序图
2. 阶段 1:媒体采集
触发:用户点"加入会议"。
关键 API:
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: 30 },
});
易错点:
- 非 HTTPS(含 localhost 之外)直接被浏览器拒绝。
- iOS Safari 必须由用户手势触发,不能在
load自动调用。 constraints不满足时浏览器会降级而不是报错,必须用track.getSettings()复核真实参数。
3. 阶段 2:连接建立
应用自定义的房间模型,常见的最简模板:
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:turn.example.com:3478', username: 'u', credential: 'p' },
],
iceTransportPolicy: 'all', // 'relay' 强制走 TURN,仅排障用
bundlePolicy: 'max-bundle', // 推荐
rtcpMuxPolicy: 'require', // 推荐
});
stream.getTracks().forEach(t => pc.addTrack(t, stream));
易错点:
iceServers只配 STUN 没配 TURN:现网 NAT 穿透成功率掉到 80%。- 不设置
bundlePolicy: 'max-bundle':每条 m-line 单独建连,端口数翻倍。 - 在
addTrack之前调用createOffer:得到的 SDP 没有 m-line。
4. 阶段 3:协商(Offer/Answer)
JSEP 状态机的最小子集:
stable
├─ setLocalDescription(offer) → have-local-offer
│ └─ setRemoteDescription(answer) → stable
└─ setRemoteDescription(offer) → have-remote-offer
└─ setLocalDescription(answer) → stable
典型代码(A 是发起方):
const offer = await pcA.createOffer();
await pcA.setLocalDescription(offer);
sendSignal({ type: 'offer', sdp: offer.sdp });
// B 收到 offer
await pcB.setRemoteDescription({ type: 'offer', sdp });
const answer = await pcB.createAnswer();
await pcB.setLocalDescription(answer);
sendSignal({ type: 'answer', sdp: answer.sdp });
// A 收到 answer
await pcA.setRemoteDescription({ type: 'answer', sdp });
glare(双方同时 offer):用 Perfect Negotiation 模式解决,分配一个 polite 角色让步。详见 03-信令与NAT穿透/Perfect-Negotiation模式.md。
5. 阶段 4:候选交换(Trickle ICE)
候选不是一次性给出的,浏览器边收集边触发 icecandidate:
pc.addEventListener('icecandidate', ({ candidate }) => {
if (candidate) sendSignal({ type: 'candidate', candidate });
// candidate === null 表示收集结束
});
// 收到对端候选
pc.addIceCandidate(remoteCandidate);
候选类型:
| type | 来源 | 说明 |
|---|---|---|
host | 本机 IP | 同一局域网可直连 |
srflx | STUN 返回 | 公网映射,最常用 |
prflx | 对端发现 | 连通性检查中产生 |
relay | TURN 中继 | 兜底,所有 NAT 穿透失败时用 |
Chrome 默认隐藏本地 IP,host 候选会变成 *.local mDNS 域名。服务器端 SFU 解析不了 mDNS,必须有 TURN 兜底。
6. 阶段 5:连通性检查
ICE Agent 把所有 (本地候选, 远端候选) 配对成 candidate pair,按优先级发送 STUN Binding 请求。第一个双向通过的 pair 被选中(nominated)。
调试入口:chrome://webrtc-internals → 选中 PeerConnection → 看 selected-candidate-pair 的变化。
7. 阶段 6:DTLS 握手
ICE 选定 pair 后,立刻在这条路径上跑 DTLS 1.2 握手。每端有一个临时证书,指纹(fingerprint)已经在 SDP 里交换过,互相校验后从 DTLS master secret 派生 SRTP 密钥。
SDP a-line: a=fingerprint:sha-256 4A:AD:B9:B1:...
DTLS 握手实际验证证书是否匹配此指纹
失败现象:pc.connectionState 卡在 connecting 然后 failed,webrtc-internals 显示 DTLS error。常见原因:客户端时间不同步、证书过期、TURN 中继丢包导致握手包超时。
8. 阶段 7:媒体传输
握手完成后:
- 音视频走 SRTP,每个 RTP 包加密 + 鉴权。
- DataChannel 走 SCTP-over-DTLS,提供可选的可靠/有序语义。
- 控制信息走 SRTCP,包括 NACK、PLI、REMB、TWCC 反馈。
pc.connectionState === 'connected' 即代表已可用。
9. 状态机速查
| 状态 | 含义 | 排查 |
|---|---|---|
iceGatheringState | 候选收集进度 | complete 表示本地候选收齐 |
iceConnectionState | ICE 连通性 | checking → connected → completed |
connectionState | 整体连接(ICE + DTLS) | 看这个就够了 |
signalingState | SDP 协商进度 | 卡在 have-local-offer 通常是信令链路断了 |
10. 常见失败位置 Top 5
- 阶段 1:HTTPS 缺失 →
getUserMedia直接拒绝。 - 阶段 3:
addTrack与createOffer顺序错 → SDP 没 m-line。 - 阶段 4:忘了交换 ICE 候选 → 永远
iceConnectionState: checking。 - 阶段 5:没部署 TURN,对称 NAT 用户 → ICE 失败。
- 阶段 6:客户端时间偏差 > 5min → DTLS 握手失败。
11. 权威资料
- W3C WebRTC 1.0 §4 PeerConnection: https://www.w3.org/TR/webrtc/#peer-to-peer-connections(核对日期:2026-06-22)
- IETF RFC 8829 JSEP: https://www.rfc-editor.org/rfc/rfc8829
- W3C Perfect Negotiation: https://www.w3.org/TR/webrtc/#perfect-negotiation-example
- Chrome
webrtc-internals文档: https://www.chromium.org/developers/design-documents/webrtc-internals/