WebRTC 入门到上手:从 1v1 通话到低延迟直播
更新: 5/30/2026 字数: 0 字 时长: 0 分钟
适合读者:要做互动直播、连麦、云游戏、远程协作的团队。本文不堆 RFC,只讲工程师上手要知道的那些事。

一、WebRTC 是什么,不是什么
一句话:WebRTC 是浏览器/移动端自带的一套"实时音视频 + 数据通道"能力,端到端延迟 200ms–800ms,全平台原生支持。
几个常见误解先拆掉:
- 不是"点对点"一定不经服务器:浏览器 P2P 只解决"媒体传输"这一段,信令仍然必须走服务器。
- 不是"开箱即用的直播方案":1v1 通话很爽,规模化到万人直播时,WebRTC 的拓扑成本会劝退大多数团队——需要 SFU。
- 不是"零 CDN":规模化 WebRTC 直播依然要部署边缘节点(SFU 集群),本质上是"另一种 CDN"。
- 不是"只能传音视频":
RTCDataChannel可以传任意二进制,云游戏、白板协作、文件传输都在用。
二、三条链路:信令、NAT 穿透、媒体传输
WebRTC 建联要打通三件事,缺一不可:
┌──────────┐ 信令服务器 ┌──────────┐
│ Peer A │ ◄──── SDP / ICE Offer ───► │ Peer B │
│ │ │ │
│ │ ──── STUN/TURN 探测 ─────► │ │
│ │ ◄── 直连 / 中继 (RTP) ──► │ │
└──────────┘ └──────────┘2.1 信令(Signaling)
WebRTC 不规定信令协议——这是刻意留的口子。你可以用 WebSocket、Socket.IO、MQTT、甚至裸 HTTP 轮询。信令只干三件事:
- 交换 SDP(Session Description Protocol)——谁用什么编码、端口、能力;
- 交换 ICE candidate——每个端可能的网络地址;
- 控制信令——加入/离开房间、静音、举手等业务语义。
最小经典流程(Offer/Answer):
A: createOffer → setLocalDescription → 发给 B
B: setRemoteDescription → createAnswer → setLocalDescription → 发回 A
A: setRemoteDescription
双方持续交换 ICE candidate(Trickle ICE)工程要点:
- Trickle ICE 默认开启:候选地址是"边收集边发"的,不要等全部收齐才发送 SDP,否则建联慢 2–3 秒。
- 信令要幂等:掉线重连、ICE restart 时会重发 SDP,服务端要能识别"这是同一路会话"。
- 房间模型 vs 点对点模型:直播必做房间抽象;1v1 通话可以更轻量。
2.2 STUN / TURN / ICE
NAT 穿透是 WebRTC 的死穴。三个角色:
| 组件 | 做什么 | 带宽成本 |
|---|---|---|
| STUN | 告诉客户端"你在公网看起来是什么地址" | 极低(几字节) |
| TURN | STUN 穿透不过去时,帮你中继所有媒体流量 | 高(全量转发) |
| ICE | 一套算法,把所有候选地址打分、尝试、选出最优路径 | 逻辑层 |
ICE 候选类型(Network 面板里 iceConnectionState 常见的词):
- host:本机网卡直连地址;
- srflx(server reflexive):STUN 探测出来的公网映射;
- prflx(peer reflexive):对端发来的未知候选;
- relay:TURN 中继地址。
实战结论:
- STUN 足够便宜,TURN 必须自建或买。公开免费 STUN(Google
stun:stun.l.google.com:19302)可以用于 demo,生产必须自建。 - TURN 命中率通常 10%–30%:企业网、对称 NAT、运营商级 NAT 穿不过去的流量全走 TURN。带宽成本按 上下行翻倍 算。
- TURN 部署要靠近用户:TURN 服务器在新加坡,用户在美西,延迟直接翻倍。多区域部署是必修课。
iceTransportPolicy: 'relay'强制走 TURN,调试 NAT 问题时超好用:
new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.example.com:3478' },
{
urls: 'turn:turn.example.com:3478?transport=udp',
username: 'user', credential: 'pwd',
},
],
iceTransportPolicy: 'all', // 调试时改 'relay'
bundlePolicy: 'max-bundle', // 强烈推荐,省端口省握手
})2.3 媒体传输
建联成功后,音视频走 SRTP(加密 RTP)over UDP;数据走 SCTP over DTLS。这些你基本不用动,浏览器帮你处理了 99% 的复杂度——这也是 WebRTC 相比自研 UDP 方案最大的价值。
三、三大 API:这就是全部
3.1 getUserMedia:拿摄像头和麦克风
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
frameRate: { ideal: 30, max: 30 },
facingMode: 'user', // 前置 / 'environment' 后置
},
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
})
document.querySelector('video').srcObject = stream坑:
- 必须 HTTPS(或
localhost)。HTTP 页面直接 reject。 - iOS 必须在用户手势里调用:否则权限弹窗直接跳过,返回 NotAllowedError。
- 摄像头只能被一个 getUserMedia 占用:同一页面多次调用也会冲突,要统一管理流。
MediaTrackConstraints是"建议"不是"强制":给的分辨率和帧率设备可能不支持,会返回最接近的。用track.getSettings()看实际值。
3.2 getDisplayMedia:屏幕共享
const screen = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 30 },
audio: true, // 系统音频,各浏览器支持差异大
})坑:
- 必须用户手势触发;
- Chrome 支持 Tab/Window/Screen 三种粒度,Safari 只给整个屏幕;
- 系统音频采集:Windows Chrome 支持,macOS Chrome 不行(只能采 Tab 音频),Safari 不支持;
- 共享结束事件:
track.onended监听用户主动停止。
3.3 RTCPeerConnection:核心中的核心
const pc = new RTCPeerConnection(config)
// 加流
stream.getTracks().forEach(t => pc.addTrack(t, stream))
// 协商
pc.onicecandidate = e => e.candidate && signaling.send('ice', e.candidate)
pc.ontrack = e => remoteVideo.srcObject = e.streams[0]
pc.oniceconnectionstatechange = () => console.log(pc.iceConnectionState)
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
signaling.send('offer', offer)几个一定要记住的:
addTrack不是addStream:老 APIaddStream已废弃;RTCRtpTransceiver是现代玩法:pc.addTransceiver('video', { direction: 'sendrecv' })可以在没 track 时先占坑,SFU 场景很常用;getStats()是排查神器:返回实时 RTT、丢包、码率、Jitter Buffer 长度等,必须接入监控;restartIce()用于网络切换:Wi-Fi→4G 时主动触发 ICE restart 重建路径,比等超时快 10 秒。
四、SFU vs MCU:规模化直播的架构选型
1v1 通话可以纯 P2P。一旦超过 3 个人,必须引入中心节点,就有了两种主流架构。
4.1 Mesh(纯 P2P,仅 1v1 或 ≤4 人可用)
每个端都要给所有其他端推一路流
N 人 = N × (N-1) 路上行 + 下行4 人就要每端上行 3 路,带宽爆炸。只有视频会议 demo 还在用。
4.2 MCU(Multipoint Control Unit,混流器)
所有端 → MCU → 解码 → 混流合成一路 → 编码 → 下发给所有端| 优点 | 缺点 |
|---|---|
| 客户端下行只有 1 路,省带宽 | 服务器要解码+编码,CPU/GPU 成本极高 |
| 画面布局固定,移动端友好 | 不灵活,用户无法自定义布局 |
| 兼容传统 SIP/PSTN 最好 | 多加 100–300ms 延迟 |
4.3 SFU(Selective Forwarding Unit,选择性转发)
所有端 → SFU → 不解码,只按需转发 RTP 包给订阅者| 优点 | 缺点 |
|---|---|
| 服务器不转码,CPU 成本低 1–2 个数量级 | 客户端下行要订阅 N-1 路 |
| 支持 Simulcast/SVC 按需选层,端侧自适应 | 带宽省在服务端,但客户端压力大 |
| 延迟最低(只转发不重编码) | 布局由客户端自己合成 |
业内共识:规模化 WebRTC 直播/会议全部走 SFU。Jitsi、mediasoup、Janus、LiveKit、声网、Zoom 都是 SFU 为核。
4.4 SFU 的两个关键优化
- Simulcast(联播):推流端同时推 1080p/720p/360p 三路,SFU 根据订阅者网络情况选一路下发。上行成本换下行灵活性,是规模化互动直播的标配。
- SVC(Scalable Video Coding):单路分层编码(时域/空域/质量),SFU 只转发部分层。比 Simulcast 更省上行,但编码器支持差(VP9/AV1 支持,H.264 SVC 生态弱)。
4.5 混合架构:小规模 SFU + 大规模 CDN
真正的"百万互动直播"架构往往是:
主播 + 连麦嘉宾 ─► SFU(低延迟 WebRTC 互动路)
│
▼ 混流/转封装
RTMP/HLS/FLV ─► CDN ─► 百万观众核心:互动路上 SFU,观看路上 CDN。连麦的 10 个人走 WebRTC,看直播的 100 万人走 LL-HLS/FLV。成本与延迟的最优平衡点。
五、WHIP / WHEP:终于有个标准了
WebRTC 没有标准信令,历史上每家都造轮子。2022 年开始落地的 WHIP(WebRTC-HTTP Ingestion Protocol)和 WHEP(WebRTC-HTTP Egress Protocol)解决了这个问题。
5.1 WHIP:推流标准(主播 → 服务器)
就是一次 HTTP POST 把 SDP Offer 发过去,服务器回 Answer。之后媒体走 WebRTC。
POST /whip/endpoint HTTP/1.1
Content-Type: application/sdp
Authorization: Bearer <token>
v=0
o=- ...
m=video ...
...
HTTP/1.1 201 Created
Location: /whip/resource/abc123
Content-Type: application/sdp
v=0
o=- ...之后用 PATCH /whip/resource/abc123 更新 ICE、DELETE 结束推流。就这么简单。
5.2 WHEP:拉流标准(服务器 → 观众)
和 WHIP 对称,POST /whep/endpoint 拿到媒体。
5.3 为什么这事重要
- 推拉流终于可以换厂商了:以前每家 SFU 的推流 SDK 都不一样,换云厂商等于重写;现在 WHIP 标准化了。
- OBS / FFmpeg / xgplayer 都在接入 WHIP/WHEP:OBS 30+ 原生支持 WHIP 推流,替代 RTMP 只差时间。
- 前端代码可以很短:
// WHEP 拉流(极简版)
async function playWhep(whepUrl, video) {
const pc = new RTCPeerConnection({ iceServers: [...] })
pc.addTransceiver('video', { direction: 'recvonly' })
pc.addTransceiver('audio', { direction: 'recvonly' })
pc.ontrack = e => video.srcObject = e.streams[0]
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
const res = await fetch(whepUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: offer.sdp,
})
const answer = await res.text()
await pc.setRemoteDescription({ type: 'answer', sdp: answer })
// res.headers.get('location') 拿到资源 URL,后续用于 DELETE
}对比传统 RTMP 推流:RTMP 是 TCP,抖动大时排队爆炸;WHIP 是 UDP 底层,拥塞控制现代得多。RTMP 被 WHIP 取代只是时间问题。
六、前端六大常见坑
6.1 NAT 穿透失败率
症状:iceConnectionState 卡在 checking,最终 failed。
- 原因:对称 NAT(Symmetric NAT)、运营商级 NAT(CGNAT)、企业防火墙。
- 定位:打开
chrome://webrtc-internals/,看 ICE candidate pair 的状态,能一眼看出是走到 TURN 还是直接 failed。 - 解法:
- TURN 必配且必须 TCP+UDP 双协议:
turn:host:3478?transport=tcp兜底企业防火墙只允许 TCP 443 出口的情况; - TURN over TLS(
turns:):443 端口 + TLS,穿透最硬的企业防火墙; - 多 TURN 服务器并行尝试:
iceServers数组里放多个。
- TURN 必配且必须 TCP+UDP 双协议:
6.2 丢包与抗弱网
WebRTC 自带一套抗丢包机制,了解下就知道它在哪儿:
- NACK:接收端发现包丢了,请求重传。适合低延迟场景。
- FEC(Forward Error Correction):发送端主动加冗余,接收端直接恢复。适合丢包率高的网络。
- PLI / FIR:关键帧请求。接收端解码失败时主动请求一个 I 帧。
- Jitter Buffer:接收端的缓冲区,平滑网络抖动。太小易卡顿,太大延迟高。
前端能干预的:
RTCRtpSender.setParameters()动态调整码率、分辨率;encodings[].priority指定多路流的优先级;- 监听
getStats()里的packetsLost、jitter、framesDropped,做体验上报。
6.3 回音消除(AEC)
浏览器的 AEC 默认开启(echoCancellation: true),但这些场景会失效:
- 外接声卡、蓝牙耳机延迟大:AEC 的参考信号对不齐,回音漏出来;
- 通过 Web Audio API 处理过音频:
getUserMedia拿到的 track 一旦过AudioContext路由再回 PeerConnection,AEC 的参考链路就断了; - 多人同屏时系统声音被采:共享屏幕带系统音频,对端的声音被采回去形成回音。
解法:
- 能用
getUserMedia的 AEC 就不要自己接 WebAudio; - 必须接 WebAudio 时,用
MediaStreamTrackProcessor+WebCodecs路径,AEC 自己做或用 RNNoise 一类 wasm 方案; - 提醒用户戴耳机是最便宜的方案。
6.4 带宽自适应:GCC
WebRTC 的拥塞控制算法叫 GCC(Google Congestion Control),基于延迟梯度+丢包的混合模型。它做两件事:
- 探测可用带宽(BWE, Bandwidth Estimation);
- 指导编码器调整码率/分辨率/帧率。
工程上你一般不用碰 GCC 内部,但要理解它:
- 上行码率不是你设多少就多少:GCC 会根据网络压回去;
- Simulcast 三路的 BWE 是独立的:上行弱的端会被降级到只推 360p;
- 自研应用层逻辑不要和 GCC 打架:比如你主动把分辨率拉到 1080p 但 GCC 判定带宽不够,会频繁切换,体验更差。
监控关键指标:getStats() 里的 availableOutgoingBitrate、qualityLimitationReason(bandwidth / cpu / none)。
6.5 音视频同步
WebRTC 帮你做了 RTP 时间戳同步,但有两个场景翻车:
- Canvas 合成画面 + 麦克风音频:
canvas.captureStream()的时间戳可能和音频对不上; - WebCodecs 自研链路:如果你用 WebCodecs 解完自己渲染,同步逻辑要自己写。
6.6 移动端特有坑
- iOS Safari 的后台策略:App 进后台 WebRTC 连接会被暂停,音频可能继续但视频停止。PWA 模式能缓解。
- Android WebView 的 echo:小米、OPPO 某些型号 AEC 实现有 bug,建议手动降级音频参数或提示用户戴耳机。
- 省电策略:移动端低电量模式会降低 WebRTC 的最大码率和帧率,表现为"一到 20% 电量画面就糊"。
- H.264 vs VP8:iOS 只硬解 H.264,Android 看厂商。跨平台推流 codec 协商要给 H.264 最高优先级。
七、最小可用 Demo:1v1 视频通话
// ===== 公用:信令(这里用 WebSocket 示意)=====
const sig = new WebSocket('wss://sig.example.com/room/123')
sig.onmessage = async ({ data }) => {
const msg = JSON.parse(data)
if (msg.type === 'offer') await onOffer(msg)
else if (msg.type === 'answer') await pc.setRemoteDescription(msg)
else if (msg.type === 'ice' && msg.candidate) await pc.addIceCandidate(msg.candidate)
}
const send = obj => sig.send(JSON.stringify(obj))
// ===== Peer 初始化 =====
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.example.com' },
{ urls: 'turn:turn.example.com', username: 'u', credential: 'p' },
],
bundlePolicy: 'max-bundle',
})
pc.onicecandidate = e => e.candidate && send({ type: 'ice', candidate: e.candidate })
pc.ontrack = e => document.querySelector('#remote').srcObject = e.streams[0]
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === 'failed') pc.restartIce()
}
// ===== 加本地流 =====
const local = await navigator.mediaDevices.getUserMedia({ video: true, audio: true })
document.querySelector('#local').srcObject = local
local.getTracks().forEach(t => pc.addTrack(t, local))
// ===== 主叫方发起 Offer =====
async function call() {
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
send({ type: 'offer', sdp: offer.sdp })
}
// ===== 被叫方处理 Offer =====
async function onOffer(msg) {
await pc.setRemoteDescription(msg)
const answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
send({ type: 'answer', sdp: answer.sdp })
}这 30 行就是 1v1 的全部核心。规模化时把 pc 换成针对 SFU 的 PeerConnection 管理即可。
八、学习路径建议
- 跑通 1v1:用上面 30 行 demo,加一个最简单的 WebSocket 信令,先让两个浏览器 tab 能通话。
- 引入 TURN:自建一个
coturn(开源 TURN 实现),把iceTransportPolicy: 'relay'跑通,理解中继的带宽代价。 - 读一个开源 SFU:mediasoup 代码量适中、架构清晰、文档好,是理解 SFU 最快的路径。
- 接入 WHIP/WHEP:用 OBS 推到 MediaMTX(支持 WHIP),浏览器用 WHEP 拉,全链路跑一遍。
- 做监控:
getStats()每秒采样,上报 RTT、丢包、码率、iceConnectionState。没监控的 WebRTC 项目 = 黑盒,出了问题只能猜。 - 进阶话题:Simulcast 接入、SVC(AV1)编码、E2EE(端到端加密 SFrame)、拥塞控制调优。
九、一张速查
| 问题 | 看哪里 |
|---|---|
| 建联失败 | chrome://webrtc-internals/ → ICE candidate pair state |
| 画面卡顿 | getStats() → framesDropped, jitter, packetsLost |
| 码率上不去 | qualityLimitationReason = bandwidth / cpu |
| 回音 | 检查 echoCancellation 是否被 WebAudio 旁路 |
| 跨网络切换断 | iceConnectionState = disconnected → restartIce() |
| 规模化成本爆 | 确认架构是 Mesh 还是 SFU;互动路 + 观看路是否分离 |
| RTMP 迁移 | WHIP/WHEP |
| iOS 特殊 | playsinline、H.264、必须手势 |
结语
WebRTC 的学习曲线有个典型特征:前 30 行代码 10 分钟跑通,后面的每一个百分点稳定性都要花一个月。真正的门槛从来不在 API,而在:
- TURN 部署与成本控制;
- SFU 的架构选型与运维;
- 移动端的长尾兼容性;
- 监控体系与故障定位。
对大多数团队,最务实的路径是:用成熟的 SFU(mediasoup / LiveKit / 云厂商)打底 + 前端专注业务逻辑 + WHIP/WHEP 做接入层标准化。把精力花在 UX 和监控上,比自己啃 RFC 5245 重写 ICE 有价值得多。
WebRTC 不是银弹,但在"互动性 > 规模"的场景里,它目前没有对手。