音视频能否连通,本质上是两个位于各自 NAT 之后的端点能否找到一条互相可达的路径。WebRTC 把这件事交给了 ICE 框架,而 ICE 又依赖 STUN 与 TURN 两个协议。三者经常被混为一谈,实际上分工明确:STUN 负责「问出我在公网看来是什么地址」,TURN 负责「实在直连不上时帮我中转」,ICE 负责「把所有可能的路径都试一遍,挑最好的那条」。
本文从 NAT 的行为讲起,逐层拆到候选收集、连通性检查与中继部署。读完你应该能判断一次连接失败究竟卡在哪一步,也能理解为什么生产环境几乎一定要自建 TURN。
一、NAT 与连通性难题
1.1 NAT 的四种映射行为
NAT 的核心问题在于:内网主机发出的包,经过 NAT 后源地址被改写为公网地址与端口,而对端看到的是改写后的地址。不同 NAT 对「同一内网地址映射到哪个公网端口」的处理方式不同,直接决定了穿透难度。
| NAT 类型 | 映射行为 | 过滤行为 | 穿透难度 |
|---|---|---|---|
| Full Cone | 内网地址固定映射到一个公网端口 | 任意外部地址都能发包进来 | 最易 |
| Restricted Cone | 同上 | 只有内网先发过包的 IP 能进来 | 较易 |
| Port Restricted Cone | 同上 | 只有内网先发过包的 IP 与端口能进来 | 中等 |
| Symmetric | 每个目标地址映射到不同公网端口 | 严格 | 最难,常需 TURN |
关键区别在于 Symmetric NAT:内网主机向 STUN 服务器探测得到的公网端口,与它向对端发包时使用的公网端口不是同一个。因此 STUN 探测出的地址对对方无效,直连几乎不可能,必须回落到 TURN 中继。
1.2 为什么需要「先探测再协商」
由于端点无法自省自己在 NAT 后的公网表示,只能借助一个第三方服务器来观察。这就是 STUN 的角色:它站在公网,把看到的源地址告诉端点。ICE 则把「我自己的、我探测到的、我中继的」所有地址作为候选全部列出,交给双方逐一尝试。
二、STUN 协议原理
2.1 消息结构
STUN 消息由 20 字节头部与若干属性(TLV)组成。头部固定字段如下:
| 字段 | 长度 | 含义 |
|---|---|---|
| Message Type | 2 字节 | 如 Binding Request 0x0001、Success Response 0x0101 |
| Message Length | 2 字节 | 属性部分总长度,不含头部 |
| Magic Cookie | 4 字节 | 固定 0x2112A442,用于区分 STUN 与旧协议 |
| Transaction ID | 12 字节 | 请求与响应配对 |
Binding 请求中通常携带 SOFTWARE、PRIORITY、ICE-CONTROLLED / ICE-CONTROLLING 等属性。响应里最关键的是 XOR-MAPPED-ADDRESS:它把观察到的公网地址用 Magic Cookie 异或编码,避免被某些 NAT 设备「聪明地」改写。
2.2 一次 Binding 交互
下面的 Node.js 片段演示如何手工构造一个 Binding 请求并解析 XOR-MAPPED-ADDRESS,这有助于理解 ICE 底层在做什么:
import dgram from "node:dgram";
import crypto from "node:crypto";
const MAGIC_COOKIE = 0x2112a442;
const BINDING_REQUEST = 0x0001;
function buildBindingRequest(transactionId) {
const buf = Buffer.alloc(20);
buf.writeUInt16BE(BINDING_REQUEST, 0);
buf.writeUInt16BE(0, 2); // 无属性
buf.writeUInt32BE(MAGIC_COOKIE, 4);
transactionId.copy(buf, 8);
return buf;
}
function parseXorMappedAddress(buf) {
let offset = 20;
while (offset + 4 <= buf.length) {
const type = buf.readUInt16BE(offset);
const len = buf.readUInt16BE(offset + 2);
const value = buf.subarray(offset + 4, offset + 4 + len);
if (type === 0x0020) {
const family = value[1];
if (family === 0x01) {
const port = value.readUInt16BE(2) ^ (MAGIC_COOKIE >> 16);
const ip = value.readUInt32BE(4) ^ MAGIC_COOKIE;
return {
port,
ip: [ip >>> 24, (ip >> 16) & 0xff, (ip >> 8) & 0xff, ip & 0xff].join("."),
};
}
}
offset += 4 + len + ((4 - (len % 4)) % 4); // 属性 4 字节对齐
}
return null;
}
const socket = dgram.createSocket("udp4");
const transactionId = crypto.randomBytes(12);
socket.on("message", (msg) => {
console.log("公网映射:", parseXorMappedAddress(msg));
socket.close();
});
socket.send(buildBindingRequest(transactionId), 3478, "stun.l.google.com", () => {
console.log("Binding 请求已发送");
});
三、ICE 候选的收集
3.1 四类候选
ICE 会把端点所有可能的地址整理成候选,按获取方式分为四类:
| 候选类型 | 来源 | 典型地址 | 成本 |
|---|---|---|---|
| host | 本机网卡地址 | 192.168.1.5 | 最低,仅同网段可达 |
| srflx | 经 STUN 探测的公网地址 | 203.0.113.7:54321 | 低,NAT 后直连 |
| prflx | 连通性检查中被动发现 | 运行时才知 | 低,对端反射 |
| relay | TURN 中继地址 | 198.51.100.9:49152 | 最高,占用中继带宽 |
候选的优先级由 type preference、local preference 与 component ID 组合计算,公式为 priority = (2^24) * typePref + (2^8) * localPref + (256 - componentID)。RFC 8445 给出的推荐 type preference 是 host 126、prflx 110、srflx 100、relay 0。因此默认总是优先尝试直连,中继作为兜底。
3.2 在浏览器中观察候选
const pc = new RTCPeerConnection({
iceServers: [
{ urls: "stun:stun.example.com:3478" },
{
urls: "turn:turn.example.com:3478?transport=udp",
username: "user",
credential: "secret",
},
],
iceTransportPolicy: "all", // 设为 "relay" 可强制走 TURN,用于验证中继
});
pc.onicecandidate = ({ candidate }) => {
if (!candidate) {
console.log("候选收集结束");
return;
}
console.log("候选:", candidate.type, candidate.protocol, candidate.address, candidate.port);
};
candidate.type 与 candidate.protocol 是排障时最有用的两个字段,能立刻看出是否收集到了 srflx 或 relay 候选。
3.3 候选优先级的计算示例
理解优先级公式有助于解释「为什么同一台机器上某些候选总是先被尝试」。假设 component ID 对 RTP 为 1、对 RTCP 为 2,local preference 取默认值 65535,则不同类型候选的优先级大致如下:
| 候选类型 | type preference | 计算出的优先级(近似) | 尝试顺序 |
|---|---|---|---|
| host | 126 | 2130706431 | 最先 |
| prflx | 110 | 1862270975 | 次之 |
| srflx | 100 | 1694498815 | 再次 |
| relay | 0 | 16777215 | 最后 |
可以看到 relay 的优先级比 host 低了两个数量级,这正是 ICE 「优先直连、中继兜底」策略在数值上的体现。若某次连接最终选中了 relay 候选,说明所有直连路径都失败了,应当去排查 NAT 类型或防火墙策略。
// 在候选事件中直接读取浏览器计算好的优先级
pc.onicecandidate = ({ candidate }) => {
if (!candidate) return;
console.log(
`${candidate.type.padEnd(6)} priority=${candidate.priority} ` +
`${candidate.protocol} ${candidate.address}:${candidate.port}`
);
};
四、连通性检查与候选配对
收集只是第一步,真正的连通性检查(Connectivity Checks)由双方对候选配对逐对发起 STUN Binding 请求完成。这一过程有几个容易忽略的细节:
- 检查请求走的是媒体路径本身(同一个五元组),而不是另开一条连接,因此检查成功即代表媒体能通。
- 请求携带
USE-CANDIDATE属性的一方是 controlling 角色,通常是发起 Offer 的一方。 - 双方各自维护候选对列表并按优先级排序,成功的候选对中最优先的一对成为选中对。
- 一旦某对成功并进入
succeeded,ICE 状态机推进到connected,媒体开始发送。
在浏览器里观察状态迁移:
pc.oniceconnectionstatechange = () => {
console.log("ICE 状态:", pc.iceConnectionState);
if (pc.iceConnectionState === "failed") {
// 触发 ICE 重启,重新收集候选
pc.restartIce();
}
};
pc.onicegatheringstatechange = () => {
console.log("收集状态:", pc.iceGatheringState); // new -> gathering -> complete
};
五、TURN 中继
5.1 Allocate 与中继地址
TURN 的核心流程是客户端向 TURN 服务器发送 Allocate 请求,服务器分配一个中继地址(relayed transport address)并返回。此后客户端把媒体发往该中继地址,服务器再转发给对端。为了让中继高效,TURN 提供两种承载方式:
| 方式 | 机制 | 开销 |
|---|---|---|
| Send / Data Indication | 每条消息带指示头 | 较高,逐包解析 |
| ChannelData | 用 2 字节 channel number 标记 | 较低,适合媒体流 |
媒体流通常协商到 ChannelData 模式,因为它的头部开销远小于 Indication 模式。
5.2 凭证与安全
TURN 凭证不应硬编码在客户端。生产环境推荐使用临时凭证(time-limited credential),即 username 为过期时间戳、credential 为用共享密钥对其做 HMAC 的结果。这样即使凭证泄露,也只在一小段时间内有效。
// 服务端签发临时 TURN 凭证
import crypto from "node:crypto";
function makeTurnCredential(secret, ttlSeconds = 3600) {
const username = String(Math.floor(Date.now() / 1000) + ttlSeconds);
const credential = crypto
.createHmac("sha1", secret)
.update(username)
.digest("base64");
return { username, credential };
}
六、Trickle ICE
传统 ICE 要等候选全部收集完毕才发送 SDP,而收集 relay 候选可能耗时数百毫秒甚至更久。Trickle ICE 让候选随收集随发送,双方可以提前开始连通性检查,从而显著缩短首帧时间。
// 发起方:本地描述设置后立即发送 SDP,候选随后单独发
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signaling.send({ type: "offer", sdp: pc.localDescription });
pc.onicecandidate = ({ candidate }) => {
if (candidate) signaling.send({ type: "candidate", candidate });
};
// 接收方:需要缓冲早到的候选,等 remoteDescription 就绪后再添加
const pending = [];
pc.addEventListener("signalingstatechange", async () => {
if (pc.remoteDescription) {
for (const c of pending.splice(0)) await pc.addIceCandidate(c);
}
});
七、ICE 重启与排障实战
7.1 什么时候需要 ICE 重启
ICE 重启(ICE Restart)会保留已建立的 RTCPeerConnection,但丢弃旧的候选对,重新收集候选并做连通性检查。它在以下场景必须使用:
- 网络切换,例如手机从 Wi-Fi 切到蜂窝,旧候选对全部失效。
- 对端 IP 发生变化,例如 NAT 重映射。
iceConnectionState长时间停留在disconnected且无法恢复。
pc.oniceconnectionstatechange = async () => {
if (pc.iceConnectionState === "failed") {
const offer = await pc.createOffer({ iceRestart: true });
await pc.setLocalDescription(offer);
signaling.send({ type: "offer", sdp: pc.localDescription });
}
};
关键点是 createOffer({ iceRestart: true }):不传这个参数时,浏览器可能复用旧的 ICE 凭证,导致重启无效。同时接收方必须能正确处理带新 ice-ufrag / ice-pwd 的 Offer。
7.2 状态机取值与含义
排障时先看状态,再看候选。iceConnectionState 的几个取值含义如下:
| 状态 | 含义 | 常见原因 |
|---|---|---|
| new | 尚未开始检查 | 候选还没开始配对 |
| checking | 正在做连通性检查 | 正常中间态 |
| connected | 至少一对成功 | 媒体已可发送 |
| completed | 所有检查完成 | 正常终态 |
| disconnected | 暂时失联 | 网络抖动,可自愈 |
| failed | 检查全部失败 | 需 ICE 重启或人工介入 |
| closed | 已关闭 | 主动关闭 |
disconnected 与 failed 的区别常被误用:前者是暂时的,ICE 会自己重试;后者才是需要主动干预的信号。
7.3 用 webrtc-internals 定位问题
Chrome 内置的 chrome://webrtc-internals 会记录每一次会话的完整事件流,包括创建的 Offer/Answer、收发的候选、状态迁移与 RTP 统计。排查连通性问题的标准流程是:
- 打开该页面,复现一次失败的连接。
- 查看
icecandidate事件里候选的类型分布,确认是否收集到了 srflx 与 relay。 - 查看
iceConnectionState的迁移轨迹,定位卡在哪一步。 - 查看
selectedCandidatePair,确认最终选中了哪一对,是直连还是中继。
如果候选列表里只有 host 类型,说明 STUN 服务器不可达或被防火墙拦截;如果有 relay 候选却仍然 failed,问题多半出在 TURN 凭证或中继带宽上。
八、TURN 部署的工程要点
- 带宽与并发是主要成本,TURN 中继的流量按双向计费,大规模场景需评估是否值得优化直连率。
- 优先部署 UDP 3478,同时提供 TCP 443 与 TLS,应对只放行 HTTPS 的企业网络。
- 多节点 TURN 需要负载均衡,且同一会话的往返包要落到同一节点,否则中继地址不匹配。
- 监控 relay 候选的使用比例,它直接反映直连成功率;比例过高说明 STUN 或网络策略有问题。
九、常见坑清单
- 只配置 STUN 不配置 TURN,Symmetric NAT 用户永远连不上。
- TURN 用 TCP 承载媒体,延迟与抖动明显高于 UDP,仅在必要时回落。
- 忘记给候选补充
sdpMid/sdpMLineIndex,导致候选无法匹配 m-line。 - 未处理
iceConnectionState === "failed",网络切换后连接无法恢复。 - TURN 凭证长期有效且硬编码,一旦泄露可被滥用中继带宽。
- 把
iceTransportPolicy设为relay后忘记改回,导致所有流量都走中继。 - 忽略 ICE 收集超时,某些网络下收集永不完成,需设置超时兜底。
小结
NAT 穿透的复杂度来自 NAT 行为的多样性,而 ICE 用「全列候选、逐一尝试、优先直连」的策略把这个复杂度封装了起来。STUN 让端点知道自己对外的样子,TURN 在直连失败时兜底,Trickle ICE 则把建连时间压到最低。生产环境的可靠性几乎完全取决于 TURN 的部署质量,这一点值得在项目早期就投入。理解了候选类型与状态机,排障时就能迅速定位是收集、检查还是中继出了问题。相关信令流程见 WebRTC 架构与信令设计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。