跳到主要内容

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 隐藏本地 IPhost 候选不是 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. 权威资料