4. ICE 与 NAT 穿透:ICE 框架、STUN/TURN 协议、Candidate 收集与连通性检查
WebRTC 要解决的核心问题之一,就是让两个身处不同内网的用户能直接连上。说白了,就是打穿 NAT。我早年做 VoIP 网关时,被 NAT 问题折磨得够呛——明明两边都能上网,就是连不通。后来 WebRTC 把 ICE 框架标准化了,才算是有了统一的解法。
这一节我们深入 ICE 的底层逻辑。你理解了 ICE,就理解了 WebRTC 连接建立的本质。
4.1 为什么需要 ICE?
先看一个典型场景:
- 用户 A 在办公室内网(192.168.1.x)
- 用户 B 在家里的内网(10.0.0.x)
- 两边都通过 NAT 设备访问公网
如果 A 直接往 B 的私有 IP 发数据,路由器会直接丢弃。因为私有 IP 在公网上不可路由。ICE 的作用就是:帮双方找到一条能通的路径,不管是直连、通过 STUN 打洞,还是走 TURN 中继。
ICE 的核心思想:收集所有可能的网络路径(Candidate),然后两两测试,选出最优且可用的那条。
4.2 ICE 框架概览
ICE 不是一个单独的协议,而是一个框架。它整合了 STUN 和 TURN,再加上自己的连通性检查逻辑。整个流程可以概括为三步:
- 收集 Candidate——找出本机所有可能的 IP:Port 组合
- 交换 Candidate——通过信令通道把列表发给对端
- 连通性检查——双方互相发送 STUN Binding Request,验证哪些路径能通
下面这张图展示了 ICE 的整体流程:
4.3 STUN 协议:打洞的探针
STUN(Session Traversal Utilities for NAT)是 ICE 中最基础的组件。它的作用很简单:帮我看看我的公网 IP 和端口是什么。
工作原理:
- 客户端向公网 STUN 服务器发送 Binding Request
- STUN 服务器在响应中返回:
你来自 X.X.X.X:YYYYY - 这个地址就是 srflx(Server Reflexive)Candidate
我在项目中遇到过一个问题:有些对称型 NAT 对 STUN 打洞不友好。每次请求拿到的端口都不一样,导致对端无法预测。这种情况就需要 TURN 上场了。
小技巧:STUN 默认使用 UDP 端口 3478。如果 UDP 被防火墙拦截,可以尝试 TLS 加密的 STUN(端口 5349)。
4.4 TURN 协议:最后的保底方案
当 STUN 打洞失败时,TURN(Traversal Using Relays around NAT)就派上用场了。它通过一个公网中继服务器转发媒体数据。
代价很明显:
- 带宽成本:所有流量都经过 TURN 服务器
- 延迟增加:多了一跳,延迟至少增加 10-30ms
- 服务器压力:每个流都需要占用中继资源
所以 TURN 是最后的选择。ICE 框架会优先尝试直连和 STUN 打洞,只有都失败时才走 TURN。
| 特性 | STUN | TURN |
|---|---|---|
| 主要作用 | 获取公网地址 | 中继转发媒体 |
| 服务器负载 | 极低(仅信令) | 高(转发媒体) |
| 延迟 | 无额外延迟 | 增加 10-30ms |
| 适用场景 | 大部分 NAT 类型 | 对称 NAT / 防火墙限制 |
| 默认端口 | 3478 (UDP) | 3478 (UDP/TCP) |
4.5 Candidate 收集:三种类型
ICE 定义了三种 Candidate 类型,优先级从高到低:
- host:本机网卡地址(192.168.1.10:5000)——优先级最高
- srflx:STUN 获取的公网地址(203.0.113.5:5000)——优先级中等
- relay:TURN 服务器分配的中继地址(TURN 服务器 IP:端口)——优先级最低
收集过程大致如下:
// 伪代码:Candidate 收集流程
function collectCandidates() {
// 1. 收集 host candidate
for each local IP {
addCandidate("host", ip, port, priority);
}
// 2. 向 STUN 服务器查询公网地址
stunRequest(server: "stun.l.google.com:19302");
onStunResponse(publicIp, publicPort) {
addCandidate("srflx", publicIp, publicPort, priority);
}
// 3. 向 TURN 服务器申请中继地址
turnAllocate(server: "turn.example.com:3478", credential);
onTurnAllocate(relayIp, relayPort) {
addCandidate("relay", relayIp, relayPort, priority);
}
}
注意:我曾经遇到过一个坑——在 Docker 容器中运行 WebRTC 客户端时,host candidate 收集到的是容器内网 IP(172.17.0.x),而不是宿主机的 IP。这会导致对端无法直连。解决方案是在启动容器时使用 --network=host 模式,或者手动指定 ICE 的网卡绑定。
4.6 连通性检查:谁先通就用谁
Candidate 收集完成后,双方会通过信令交换各自的 Candidate 列表。然后 ICE 开始做连通性检查。
检查逻辑:
- 每一对 Candidate(本地 vs 对端)组成一个 Check Pair
- 双方互相发送 STUN Binding Request
- 如果收到 STUN Binding Response,说明这对 Candidate 是通的
- ICE 会按优先级顺序检查,一旦找到可用路径就停止
举个例子:
// Peer A 的 Candidate 列表
host: 192.168.1.10:5000 (优先级 100)
srflx: 203.0.113.5:5000 (优先级 80)
relay: 198.51.100.10:3478 (优先级 50)
// Peer B 的 Candidate 列表
host: 10.0.0.5:6000 (优先级 100)
srflx: 198.51.100.20:6000 (优先级 80)
relay: 198.51.100.10:3478 (优先级 50)
// 连通性检查顺序(按优先级组合):
// 1. A:host(192.168.1.10:5000) <-> B:host(10.0.0.5:6000) → 不通(内网隔离)
// 2. A:host(192.168.1.10:5000) <-> B:srflx(198.51.100.20:6000) → 不通
// 3. A:srflx(203.0.113.5:5000) <-> B:srflx(198.51.100.20:6000) → 通!使用此路径
// 4. 后续检查不再执行
嗯,这里要注意:连通性检查是有超时的。默认情况下,每个检查对会在 500ms 内重试 3 次。如果全部超时,就认为该路径不可用。
4.7 ICE 状态机
ICE 内部维护了一个状态机,用来跟踪连接建立的过程。主要状态有:
| 状态 | 说明 |
|---|---|
| New | ICE 刚创建,尚未开始收集 |
| Gathering | 正在收集 Candidate |
| Complete | Candidate 收集完成 |
| Checking | 正在进行连通性检查 |
| Connected | 至少有一条路径可用 |
| Failed | 所有路径都不可用 |
我个人习惯在调试时监听 iceConnectionState 的变化。如果卡在 Checking 状态超过 5 秒,基本可以断定是 NAT 穿透出了问题。
4.8 避坑指南
最后分享几个我踩过的坑:
- STUN 服务器不可达:有些企业防火墙会拦截 UDP 3478 端口。建议同时配置 TCP 回退方案。
- TURN 凭证过期:TURN 的临时凭证(TURN REST API 生成)有时效性。如果连接建立时间过长,凭证可能失效。我建议在 ICE 开始前刷新凭证。
- Candidate 数量过多:如果机器有多个虚拟网卡(VPN、Docker 等),Candidate 列表会非常长,导致连通性检查耗时增加。可以通过
iceCandidatePoolSize控制并发检查数量。 - IPv6 优先级:现代系统默认 IPv6 优先级高于 IPv4。但如果对端不支持 IPv6,会导致检查失败。建议在 ICE 配置中明确指定
iceTransportPolicy: "relay"或"all"。
总结一句话:ICE 的本质是穷举所有可能的路径,然后用 STUN 探针验证。直连不行就打洞,打洞不行就中继。理解了这个逻辑,你就能在遇到连接问题时快速定位瓶颈。