ICE全流程
ICE 全流程
ICE(Interactive Connectivity Establishment,RFC 8445/8838)回答一个问题:两个不知道对方真实地址的端点,怎么找出一条能互通的网络路径。
本文讲清楚 ICE 的 5 个核心环节——候选收集、Trickle 交换、配对、连通性检查、提名——以及网络变化时的 ICE Restart。不重述 RFC 全部状态机,只覆盖工程上需要决策的点。
1. 全流程一张图
2. 候选类型与收集来源
| type | 来源 | 优先级 | 何时出现 |
|---|---|---|---|
host | 本机网卡 IP | 最高 | 总会有,除非禁用 |
srflx | STUN 返回的公网映射 | 中 | 配置了 STUN 服务器 |
prflx | 对端 STUN 检查时发现 | 中 | 对称 NAT 场景偶现 |
relay | TURN 分配的中继地址 | 最低 | 配置了 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=1RTP,component=2RTCP(开了rtcp-mux只有 1)protocol=udp|tcptyp=host|srflx|prflx|relayraddr/rport:srflx/relay 才有,表示"内层"地址
3. Trickle ICE:边收集边发送
非 Trickle(RFC 5245 老式):必须等所有候选收齐再发 SDP,建链慢 1-3 秒。
Trickle(RFC 8838,WebRTC 默认):
setLocalDescription立即返回,SDP 里可能一个候选都没有。icecandidate事件异步逐个吐出候选。- 每收到一个就通过信令发给对端。
- 对端
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):
- controlling 端从 valid list 选优先级最高的 pair。
- 重发一次带
USE-CANDIDATE属性的 STUN Binding Request。 - controlled 端收到后把这个 pair 设为 selected。
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/3478 | 15% | TURN 监听 443 TCP |
| Wi-Fi 切 4G 未做 ICE Restart | 12% | 监听 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 |
| 网络切换后重建 PC | DTLS 重新握手,体验比 ICE Restart 差 | 用 restartIce() |
| 等所有候选收齐再发 SDP | 失去 Trickle,建链慢 1-3s | 边收边发 |
| 服务器端 ICE Agent 跑完整模式 | 浪费 CPU | 用 ice-lite |
| 把 mDNS host 候选发给 SFU | SFU 解析不了,浪费一轮检查 | 服务端忽略 *.local 候选 |
iceConnectionState === 'disconnected' 立刻重连 | 误伤短暂抖动 | 等 5 秒,看是否自愈 |
12. 权威资料
- IETF RFC 8445 Interactive Connectivity Establishment: https://www.rfc-editor.org/rfc/rfc8445
- IETF RFC 8838 Trickle ICE: https://www.rfc-editor.org/rfc/rfc8838
- IETF RFC 8839 SDP for ICE: https://www.rfc-editor.org/rfc/rfc8839
- IETF RFC 5389 / 8489 STUN: https://www.rfc-editor.org/rfc/rfc8489
- IETF RFC 5766 / 8656 TURN: https://www.rfc-editor.org/rfc/rfc8656
- W3C WebRTC 1.0 §5.6 ICE: https://www.w3.org/TR/webrtc/#rtcicetransport-interface
- Chrome
webrtc-internals字段说明: https://chromium.googlesource.com/external/webrtc/+/HEAD/docs/native-code/rtp-hdrext/ - 核对日期:2026-06-22