跳到主要内容

ICE全流程

ICE 全流程​

ICE(Interactive Connectivity Establishment,RFC 8445/8838)回答一个问题:两个不知道对方真实地址的端点,怎么找出一条能互通的网络路径。

本文讲清楚 ICE 的 5 个核心环节——候选收集、Trickle 交换、配对、连通性检查、提名——以及网络变化时的 ICE Restart。不重述 RFC 全部状态机,只覆盖工程上需要决策的点。

1. 全流程一张图​

2. 候选类型与收集来源​

type来源优先级何时出现
host本机网卡 IP最高总会有,除非禁用
srflxSTUN 返回的公网映射中配置了 STUN 服务器
prflx对端 STUN 检查时发现中对称 NAT 场景偶现
relayTURN 分配的中继地址最低配置了 TURN 且分配成功

Chrome 默认开启 mDNS 隐藏本地 IP:host 候选不是 192.168.1.10 而是 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.local。同局域网两个 Chrome 之间能解析,但服务器端 SFU 解析不了。这就是为什么服务器架构必须配 TURN。

2.1 看一眼真实候选​

const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:turn.example.com:3478', username: 'u', credential: 'p' },
],
});
pc.addTransceiver('audio'); // 必须有 m-line 才会收集
await pc.setLocalDescription();
pc.onicecandidate = (e) => {
if (!e.candidate) return console.log('收集结束');
console.log(e.candidate.candidate);
// candidate:842163049 1 udp 1677729535 203.0.113.5 50000 typ srflx raddr 192.168.1.10 rport 50000
};

字段含义(按 RFC 5245/8839 candidate-attribute 格式):

candidate:<foundation> <component> <protocol> <priority> <ip> <port>
typ <type> [raddr <rel-addr> rport <rel-port>] [tcptype <type>] [generation <g>] [ufrag <u>] [network-id <id>]
  • component=1 RTP,component=2 RTCP(开了 rtcp-mux 只有 1)
  • protocol=udp|tcp
  • typ=host|srflx|prflx|relay
  • raddr/rport:srflx/relay 才有,表示"内层"地址

3. Trickle ICE:边收集边发送​

非 Trickle(RFC 5245 老式):必须等所有候选收齐再发 SDP,建链慢 1-3 秒。

Trickle(RFC 8838,WebRTC 默认):

  1. setLocalDescription 立即返回,SDP 里可能一个候选都没有。
  2. icecandidate 事件异步逐个吐出候选。
  3. 每收到一个就通过信令发给对端。
  4. 对端 addIceCandidate 边收边加。
pc.onicecandidate = ({ candidate }) => {
signaling.send({ type: 'candidate', candidate: candidate?.toJSON() ?? null });
// null 表示 "end of candidates"
};

// 对端
signaling.on('candidate', async ({ candidate }) => {
if (candidate === null) return; // end-of-candidates 标记
try { await pc.addIceCandidate(candidate); }
catch (e) { /* setRemoteDescription 还没完成时正常,要缓冲,见信令协议设计.md */ }
});

重点:end-of-candidates 不是必须发的——只是一个加速信号,让对端不再等新候选。即使不发,ICE 失败超时一样会触发状态变化。

4. 优先级与配对​

每个候选的 priority 按 RFC 8445 公式计算:

priority = (2^24) * type_preference
+ (2^8) * local_preference
+ (2^0) * (256 - component_id)

type_preference: host=126, prflx=110, srflx=100, relay=0

relay 优先级最低是有意的——只在所有其它路径失败时兜底。

配对:本端每个候选 × 对端每个候选 = 候选对。pair 的优先级:

pair_priority = 2^32 * MIN(G, D) + 2 * MAX(G, D) + (G > D ? 1 : 0)
G = controlling 端优先级,D = controlled 端优先级

两端按 pair_priority 从高到低有序做连通性检查。这是为什么 host-host pair 一定先被尝试,relay-relay pair 最后才轮到。

5. 连通性检查(STUN Binding)​

每个候选对都跑双向 STUN Binding 检查:

A → B: STUN Binding Request (用 SDP 里交换的 ice-ufrag / ice-pwd 鉴权)
B → A: STUN Binding Response
B → A: STUN Binding Request
A → B: STUN Binding Response

双向都通才算 valid pair。这是为什么"我能 ping 通你不等于你能 ping 通我"在 ICE 里被自动校验。

5.1 提名(Nomination)​

WebRTC 用 RFC 8445 的 "regular nomination"(不是老式 aggressive):

  1. controlling 端从 valid list 选优先级最高的 pair。
  2. 重发一次带 USE-CANDIDATE 属性的 STUN Binding Request。
  3. controlled 端收到后把这个 pair 设为 selected。
  4. iceConnectionState 变为 connected。

pc.iceConnectionState === 'completed' 表示 controlling 端确认所有候选检查完,没有更高优先级的 pair 能用了。connected 即可用,不必等 completed。

6. 调试 ICE 的标准动作​

6.1 chrome://webrtc-internals​

打开后选中目标 PeerConnection,关键看:

  • iceconnectionstatechange 时间线
  • ICE candidate grid:本地 / 远端各有几个候选,类型分布
  • selected candidate pair:最终走的是哪条
  • bytesSent / bytesReceived 是否持续增长

如果 selected-candidate-pair 长时间是 (host, host) 但两个 host 都是不同子网 → 实际上根本没连通,但 stat 字段还没更新,要看 succeeded 标记。

6.2 强制走 TURN 排除穿透问题​

const pc = new RTCPeerConnection({
iceServers: [{ urls: 'turn:turn.example.com:3478', username, credential }],
iceTransportPolicy: 'relay', // 关键
});

如果设为 'relay' 后能通,但 'all' 不通——是 NAT 穿透问题,不是 TURN 问题。

6.3 getStats 取真实 selected pair​

const stats = await pc.getStats();
for (const r of stats.values()) {
if (r.type === 'candidate-pair' && r.nominated && r.state === 'succeeded') {
const local = stats.get(r.localCandidateId);
const remote = stats.get(r.remoteCandidateId);
console.log('selected:', local.candidateType, '↔', remote.candidateType);
console.log('rtt:', r.currentRoundTripTime * 1000, 'ms');
}
}

Chrome 90+ 已经把 chrome://webrtc-internals 里的 selected pair 改用 nominated && state === 'succeeded' 判断,老的 selected 字段已经废弃。

7. ICE Restart​

ICE 检查通过后建立的 pair 包含了 IP / 端口。任何一端的 IP 变化都会让原 pair 失效:

  • 笔记本从 4G 切到 Wi-Fi
  • 路由器 NAT 表项被回收(典型 UDP NAT 超时 30-300 秒)
  • 移动端横屏切应用后回到前台
pc.oniceconnectionstatechange = async () => {
if (pc.iceConnectionState === 'failed') {
pc.restartIce(); // 生成新 ufrag/pwd 触发 negotiationneeded
}
if (pc.iceConnectionState === 'disconnected') {
// 临时抖动,5s 内不重启,给浏览器自愈机会
}
};

restartIce() 的好处:

  • 不重新建 DTLS(密钥延续,0 损失)
  • 不重新协商 codec
  • 只换 ICE 凭据,重新跑候选收集 + 检查

如果没有 Perfect Negotiation 模板,restartIce 也会产生 glare(两端同时 failed 同时 restart)。这是 Perfect Negotiation 不只是首次建链时有用的原因。

7.1 主动检测网络变化​

Network Information API 可以提前感知:

navigator.connection?.addEventListener('change', () => {
if (pc.iceConnectionState === 'connected') {
// 网络刚变,主动重启不等 failed
pc.restartIce();
}
});

但要注意:connection.change 在切 IP 之前可能就触发,太早 restart 会浪费一次。生产上建议等 1-2 秒再 restart,给系统时间稳定。

8. ICE Lite​

服务器端(SFU / MCU)通常在公网上有固定的可路由 IP,不需要做完整的 ICE:

  • 只发 host 候选(公网 IP)
  • 不主动发 STUN 检查请求,只回应
  • 不参与提名

SDP 里加一行 a=ice-lite。LiveKit、mediasoup 默认开启,可以减少服务器侧的 ICE 处理开销。

客户端不应该用 ICE Lite——客户端身处 NAT 后面,必须主动检查。

9. ICE 失败的真实分布(生产经验)​

某中型音视频产品(约 1000 万 DAU)的 ICE 失败原因分布:

原因占比处理
未配 TURN,对称 NAT 用户35%必配 TURN(含 TLS/443 TCP 兜底)
TURN 凭据过期18%短期凭据自动续期
防火墙阻塞 UDP/347815%TURN 监听 443 TCP
Wi-Fi 切 4G 未做 ICE Restart12%监听 connection.change
mDNS 候选给到服务器端8%服务端走 TURN
SDP 信令丢失5%信令重试
客户端时间偏差4%NTP 同步(DTLS 才失败,ICE 是另一回事)
其它3%-

结论:90% 以上的 ICE 失败靠"配齐 TURN(含 TLS/443 TCP)+ 写好 ICE Restart"就能解决。

10. iceCandidatePoolSize 优化​

new RTCPeerConnection({ iceServers, iceCandidatePoolSize: 4 });

预先开 4 个 ICE Agent 实例,在 setLocalDescription 之前就开始收候选。首次建链能省 200-500ms。

代价:

  • 资源占用增加(每个 agent 一组 socket)
  • TURN 服务器收到一堆"白嫖"的 allocate 请求

适合:用户大概率会发起通话的场景(如点击"开始会议"前)。不适合:纯被动接听的场景。

11. 反模式​

反模式后果替代
只配 STUN 不配 TURN对称 NAT / 企业网用户连不上TURN 必配
TURN 只开 3478/udp防火墙拦 UDP 的环境失败同时开 443/tcp + TLS
网络切换后重建 PCDTLS 重新握手,体验比 ICE Restart 差用 restartIce()
等所有候选收齐再发 SDP失去 Trickle,建链慢 1-3s边收边发
服务器端 ICE Agent 跑完整模式浪费 CPU用 ice-lite
把 mDNS host 候选发给 SFUSFU 解析不了,浪费一轮检查服务端忽略 *.local 候选
iceConnectionState === 'disconnected' 立刻重连误伤短暂抖动等 5 秒,看是否自愈

12. 权威资料​