1. 从一次线上事故说起:candidate属性为什么值得单独拎出来讲
去年冬天,团队里一个刚转正的同学在群里发了一张截图,线上环境里 ICE 协商卡在checking状态整整四十秒,最后超时失败。日志里翻来覆去就是几行local candidate和remote candidate的打印,看起来一切正常,但连接就是建不起来。当时我们几个人围着屏幕看了半天,最后发现问题出在一个谁都没注意的地方——候选地址的优先级计算里,type preference和local preference的取值逻辑被改错了,导致一对本该优先配对的 host 候选被排到了最后,而 relay 候选又因为网络策略压根不可达。
这件事之后我就养成了一个习惯:凡是涉及 ICE 的排查,第一件事就是把双方所有 candidate 的完整属性打出来,逐字段过一遍。很多人觉得 candidate 不就是个 IP 加端口吗,有什么好看的。但恰恰是这个"没什么好看"的东西,藏着 ICE 能不能连通、多快连通、走哪条路径的全部秘密。
这篇内容就是围绕candidate 属性展开的。我会把 candidate 的每一个字段拆开讲清楚:它是什么、为什么这么设计、实际抓包时长什么样、哪些字段最容易出问题、怎么根据这些字段快速定位连通性故障。适合正在做实时音视频、WebRTC 相关开发的同学,也适合那些被 ICE 连接问题折磨过、想彻底搞明白底层逻辑的人。读完之后,你至少能做到:拿到一份 candidate 列表,能判断出配对顺序合不合理,能看出哪条路径大概率能通,能定位到是采集、信令还是网络层的问题。
需要说明的是,ICE 本身是一套标准化的协商框架,不同实现(浏览器、移动端 SDK、服务端网关)在细节上会有差异。下面讲的内容以通用机制为主,具体参数取值我会标注出常见实践,你对照自己用的实现去验证即可。
2. candidate 属性的完整字段拆解
2.1 一行 candidate 到底包含哪些信息
先看一条真实的 candidate 字符串长什么样。这是从一次抓包里摘出来的,格式是标准的 SDP 属性行:
a=candidate:1 1 udp 2130706431 192.168.1.100 54321 typ host a=candidate:2 1 udp 1694498815 203.0.113.45 12345 typ srflx raddr 192.168.1.100 rport 54321 a=candidate:3 1 udp 41885439 198.51.100.7 3478 typ relay raddr 203.0.113.45 rport 12345别看这一行不长,它其实塞了七八个关键信息。我按位置逐个拆:
| 位置 | 示例值 | 字段名 | 含义 |
|---|---|---|---|
| 1 | 1 | foundation | 候选基础标识,相同 foundation 的候选来自同一类网络接口 |
| 2 | 1 | component-id | 组件 ID,1 通常代表 RTP,2 代表 RTCP |
| 3 | udp | transport | 传输协议,常见 udp,也有 tcp |
| 4 | 2130706431 | priority | 优先级,决定配对顺序的核心数字 |
| 5 | 192.168.1.100 | connection-address | 候选的 IP 地址 |
| 6 | 54321 | port | 候选的端口 |
| 7 | typ host | candidate-type | 候选类型:host / srflx / prflx / relay |
| 8 | raddr/rport | 相关地址 | 仅 srflx 和 relay 有,表示该候选映射前的本地地址 |
这里最容易被人忽略的是foundation和priority这两个字段。foundation 看起来只是个标识,但它在 ICE 的冗余消除机制里扮演关键角色——相同 foundation 的候选被认为是"同源"的,在配对时会被特殊处理,避免重复计算。而 priority 直接决定了谁先被配对、谁后配对,是影响连通速度的头号因素。
2.2 priority 的计算公式与常见取值
priority 不是一个随便填的数字,它有明确的计算规则。标准里给出的公式是:
priority = (2^24) * type_preference + (2^8) * local_preference + (256 - component_id)三个部分各有讲究。type_preference反映候选类型的"质量等级",host 最高,因为它是本机直连地址,不需要经过任何中转;srflx 次之,它经过了 NAT 映射但至少是点对点的;prflx 再次;relay 最低,因为它要经过中继服务器转发,延迟和成本都最高。常见的取值是:
- host:126
- prflx:110
- srflx:100
- relay:0
local_preference是本地策略的调节项,通常和网络接口的优先级挂钩。比如同时有有线和无线网卡,有线接口的 local_preference 会被设得更高,这样在配对时会优先尝试有线路径。这个值在不同实现里差异很大,有的直接用接口索引,有的用一套自定义的权重表。
component_id那一项(256 - component_id)是为了让 RTP 组件(id=1)比 RTCP 组件(id=2)稍微优先一点,差值只有 1,影响很小但存在。
拿上面那条 host 候选算一下:type_preference=126,local_preference 假设是 65535(最大值),component_id=1,那么 priority = 126 * 16777216 + 65535 * 256 + 255 = 2113929216 + 16776960 + 255 = 2130706431。和抓包里的数字完全对得上。你可以用这个公式反推任何一条候选的 type_preference 和 local_preference,这在排查"为什么这条候选没被优先选中"时特别有用。
2.3 foundation 的生成逻辑与冗余消除
foundation 的生成规则是:相同类型、相同基础地址、相同传输协议的候选,共享同一个 foundation。注意这里说的是"基础地址"(base),对于 host 候选就是它自己的 IP,对于 srflx 和 relay 则是它们映射前的本地地址(也就是 raddr)。
举个例子,一台机器有两个网卡,分别拿到 192.168.1.100 和 10.0.0.50,那么它会生成两条 host 候选,foundation 不同,因为基础地址不同。但如果它通过同一个 STUN 服务器拿到了两个 srflx 候选,且这两个 srflx 都映射自 192.168.1.100,那么它们的 foundation 就是相同的。
为什么要有 foundation?因为 ICE 在配对时,如果发现两条候选的 foundation 相同,就可以认为它们"同源",在冗余检查时可以直接跳过一部分计算。更实际的作用是:当一条候选的连通性检查失败时,同 foundation 的其他候选大概率也会失败,可以快速剪枝,减少无效的检查次数。这个机制在候选数量多的时候能显著降低协商开销。
我遇到过一种情况:某移动端设备在弱网下频繁切换网络,导致 foundation 不断变化,ICE 每次都要重新做全量配对,协商时间从正常的几百毫秒涨到三四秒。后来在信令层做了 foundation 的稳定性优化,把同一物理接口的 foundation 固定下来,问题才缓解。所以 foundation 不只是个标识,它直接影响协商效率。
3. 四种候选类型的本质区别与适用场景
3.1 host 候选:最直接但也最受限
host 候选就是本机网络接口上的地址,它不经过任何 NAT 映射,是"最原始"的地址。在局域网内,host 候选之间的配对几乎总能成功,延迟也最低。但一旦跨公网,host 候选基本就废了——因为对方的 host 地址在你的网络里根本路由不到。
很多人有个误区,觉得 host 候选优先级最高就一定要用它。实际上在公网场景下,host 候选的连通性检查大概率是失败的,只是因为它优先级高,会被优先尝试,反而拖慢了整体协商速度。所以有些实现会在检测到对端不在同一网段时,主动降低 host 候选的 local_preference,让 srflx 或 relay 先被尝试。这是一个典型的"优先级高不等于好用"的例子。
host 候选的另一个特点是数量可能很多。一台开发机如果有虚拟网卡、Docker 网桥、多个物理网卡,host 候选能列出七八条。这里面很多是无效的(比如 Docker 的 172.17.x.x),如果不做过滤,会白白增加配对组合数。常见做法是在采集阶段就根据接口类型做一轮筛选,把明显不可达的接口排除掉。
3.2 srflx 候选:NAT 映射后的公网地址
srflx 是 server reflexive 的缩写,意思是"通过服务器反射得到的地址"。它的获取过程是:客户端向 STUN 服务器发一个请求,服务器看到的是客户端经过 NAT 映射后的公网地址和端口,然后把这个地址回给客户端。客户端拿到后,就生成一条 srflx 候选。
srflx 候选的价值在于:它让两个都在 NAT 后面的客户端有机会直接通信。只要双方的 NAT 允许这种"打洞"行为,srflx 之间的配对就能成功,不需要经过中继。这是 P2P 通信最理想的情况。
但 srflx 有个关键限制:它只对锥形 NAT 有效。如果一方是对称 NAT,那么它每次向不同目标发请求时,NAT 分配的端口都不一样,STUN 服务器看到的端口和实际通信时用的端口对不上,srflx 候选就失效了。这种情况下只能退回到 relay。
判断是否对称 NAT 的方法,是向两个不同的 STUN 服务器发请求,比较返回的映射端口是否一致。一致就是锥形,不一致就是对称。这个检测在很多 SDK 里是内置的,但检测本身也要花时间,所以有些实现会跳过检测直接采集 srflx,等配对失败再降级。
3.3 prflx 候选:被动发现的映射地址
prflx 是 peer reflexive 的缩写,它不是在采集阶段主动获取的,而是在连通性检查过程中被动发现的。具体来说:当 A 向 B 的某个候选地址发检查请求时,B 收到的请求里带着 A 的源地址,如果这个源地址不在 B 已知的候选列表里,B 就会把它作为一条 prflx 候选加进来。
prflx 的存在是为了处理一种特殊情况:双方的 NAT 映射在采集之后发生了变化,或者某些候选在采集时没被发现。它相当于一个"兜底"机制,让协商过程能适应动态变化的网络环境。
prflx 的优先级通常介于 srflx 和 relay 之间。它不需要中继,但因为是事后发现的,可靠性不如主动采集的 srflx。在实际排查中,如果看到大量 prflx 候选,往往说明网络环境不稳定,或者采集阶段的 STUN 请求没有覆盖到所有接口。
3.4 relay 候选:最后的保底通道
relay 候选是通过中继服务器(TURN)分配的地址。客户端向 TURN 服务器申请一个中继地址,之后所有发往这个地址的数据都会由 TURN 服务器转发。它的优点是几乎总能连通——因为双方都是主动连到 TURN 服务器的,不存在 NAT 穿透问题。缺点是延迟高、带宽成本高,而且所有流量都要经过中继,对服务器压力大。
relay 候选的优先级最低,这是合理的:能直连就直连,直连不行才走中继。但在实际部署中,relay 的可用性直接决定了"最坏情况下能不能通"。我见过一些项目为了省成本,TURN 服务器配置得很弱,结果在对称 NAT 场景下大量连接失败。所以 relay 虽然优先级低,但绝对不能省。
四种候选类型的关系可以用一句话概括:host 是理想,srflx 是常态,prflx 是补充,relay 是底线。一个健壮的 ICE 实现应该四种都支持,并根据实际网络情况动态调整它们的优先级。
4. 候选配对与优先级排序的实战逻辑
4.1 配对组合是怎么产生的
ICE 的配对逻辑说起来简单:把本地的每一条候选和远端的每一条候选两两组合,形成候选对(candidate pair),然后按优先级排序,依次做连通性检查。但实际组合数量是本地候选数乘以远端候选数,如果双方各有 10 条候选,就是 100 个候选对,每个都要发检查包,开销不小。
所以实际实现里会做大量剪枝。第一轮剪枝是按foundation去重,相同 foundation 的组合只保留一个代表。第二轮是按组件 ID过滤,RTP 的候选只和 RTP 的候选配对,不会和 RTCP 混。第三轮是按传输协议过滤,udp 和 tcp 不会互相配对。经过这几轮,候选对数量通常能降到原来的三分之一甚至更少。
候选对的优先级计算也有公式:
pair_priority = 2^32 * MIN(G, D) + 2 * MAX(G, D) + (G > D ? 1 : 0)其中 G 是本地候选的 priority,D 是远端候选的 priority。这个公式的核心思想是:取双方优先级中较小的那个作为主导。也就是说,一条候选对的质量取决于"木桶最短的那块板"。如果本地是 host(高优先级)但远端是 relay(低优先级),那这个候选对的优先级就由 relay 决定,不会因为本地是 host 就被优先尝试。这个设计很合理,避免了"一厢情愿"式的配对。
4.2 连通性检查的顺序与超时控制
候选对排好序之后,ICE 会按顺序发起连通性检查。每个检查是一个 STUN Binding 请求,发出去之后等响应。如果收到响应,这个候选对就被标记为 succeeded;如果超时,标记为 failed,继续下一个。
这里有个关键参数:检查的超时时间。设得太短,网络稍微抖一下就误判失败;设得太长,协商时间被拖长。常见做法是采用指数退避,第一次等 500ms,没响应就等 1s,再没响应等 2s,最多重试几次。整个 ICE 协商还有一个总超时,通常是 10 到 30 秒,超过就整体失败。
我在排查时经常关注一个指标:首个成功候选对的出现时间。如果这个时间很短(几百毫秒),说明网络环境好、候选质量高;如果接近总超时,说明大部分候选都失败了,最后靠 relay 兜底。这个指标比"最终是否连通"更能反映网络质量。
还有一个容易被忽略的点:检查的并发度。如果串行检查,100 个候选对逐个试,时间根本不够。所以实现里通常会并发发起多个检查,但并发数也不能太高,否则会给网络和对方造成压力。常见的并发窗口是 5 到 10 个。这个值的调优需要根据实际网络情况来,没有万能数字。
4.3 提名机制:谁来决定用哪条路径
连通性检查成功后,并不意味着这条路径就会被使用。ICE 还有一个提名(nomination)过程:由控制方(通常是发起方)从所有成功的候选对里选一个"最优"的,提名它作为正式的数据通道。
提名分两种模式:常规提名和激进提名。常规提名是等所有检查都做完,从成功的候选对里选优先级最高的;激进提名是一旦有候选对成功,立刻提名它,不等其他检查完成。激进提名的好处是快,坏处是可能选到一条不是最优的路径。常规提名更稳,但慢。
实际项目里怎么选?我的经验是:对延迟敏感的场景(比如实时通话)用激进提名,对质量敏感的场景(比如文件传输)用常规提名。有些实现还支持"先激进提名保证连通,再后台继续检查,发现更优路径就切换"的混合策略,这个比较复杂,但效果最好。
提名之后,双方会用选定的候选对建立数据通道,后续的媒体流就走这条路径。如果这条路径后来失效了(比如网络切换),ICE 会重新进入检查流程,这个过程叫ICE restart。restart 时 foundation 和 priority 会重新计算,所以前面讲的这些属性又会被重新用到。
5. 从抓包看 candidate 属性的真实表现
5.1 一次成功协商的完整候选交换过程
光讲理论不够直观,我拿一次实际的抓包记录来走一遍。场景是两个客户端在同一局域网内,通过信令服务器交换 SDP。
第一步,A 采集本地候选,生成三条:一条 host(192.168.1.100:54321),一条 srflx(203.0.113.45:12345),一条 relay(198.51.100.7:3478)。这三条通过信令发给 B。
第二步,B 也采集三条:host(192.168.1.101:54322),srflx(203.0.113.46:12346),relay(198.51.100.8:3479)。发给 A。
第三步,双方各自做配对。A 这边产生 3×3=9 个候选对,按优先级排序后,最前面的是 host-host 对(192.168.1.100 对 192.168.1.101),因为双方都是 host,优先级最高。
第四步,A 向 B 的 host 候选发 STUN 检查。因为是同一局域网,请求直接到达,B 回响应。这个候选对标记为 succeeded。
第五步,B 也向 A 的 host 候选发检查,同样成功。双方都确认 host-host 路径可用。
第六步,提名阶段,A 作为控制方,提名 host-host 对。数据通道建立,后续媒体流走这条路径。
整个过程从开始到连通,大概 200 毫秒。抓包里能看到的关键信息是:检查请求的源地址、目标地址、以及响应里的 XOR-MAPPED-ADDRESS。如果响应里的映射地址和请求的目标地址一致,说明路径是直的,没有额外的 NAT 映射。
5.2 协商失败时的典型日志特征
再看一个失败的例子。场景是 A 在公司内网(对称 NAT),B 在家庭网络(锥形 NAT)。
A 采集到的候选:host(10.0.0.5:5000),srflx(198.51.100.20:6000),relay(198.51.100.7:3478)。注意 A 是对称 NAT,所以它的 srflx 端口 6000 只在向 STUN 服务器通信时有效,向 B 发检查时 NAT 会分配一个新端口,导致 B 收到的源端口不是 6000。
B 采集到的候选:host(192.168.0.10:7000),srflx(203.0.113.50:8000),relay(198.51.100.8:3479)。
配对后,A 先试 host-host,失败(跨网段)。再试 host-srflx,A 的 host 是内网地址,B 根本路由不到,失败。再试 srflx-srflx,A 发出去的检查包源端口被 NAT 改成了别的,B 回的响应到不了 A,失败。最后试 relay-relay,双方都连到各自的 TURN 服务器,TURN 服务器之间转发,成功。
日志里的典型特征是:前几个候选对的检查都是timeout,最后一个 relay 对是succeeded。整体协商时间可能到 5 秒以上。如果 TURN 服务器配置有问题,relay 也失败,那整个 ICE 就彻底失败了,日志里全是 timeout,没有任何 succeeded。
5.3 用属性字段快速定位问题层级
拿到一份 candidate 列表和检查日志,怎么快速判断问题出在哪?我总结了一个排查顺序:
先看候选类型是否齐全。如果只有 host 没有 srflx,说明 STUN 采集失败,可能是 STUN 服务器地址配错、端口被封、或者网络策略拦截了 UDP。如果连 relay 都没有,说明 TURN 也没配上,那跨网段基本没戏。
再看priority 是否合理。如果 relay 的 priority 比 srflx 还高,那肯定是计算逻辑写错了,会导致优先走中继,浪费带宽还增加延迟。正常情况下 host > srflx > prflx > relay。
再看foundation 是否重复异常。如果所有候选的 foundation 都一样,说明生成逻辑有问题,冗余消除会失效,配对数量暴涨。如果 foundation 频繁变化,说明网络接口不稳定。
最后看检查日志的失败模式。全是 timeout 说明网络层不通;部分 succeeded 但提名失败说明提名逻辑有问题;succeeded 后很快 failed 说明路径不稳定,可能是 NAT 映射超时。
这套顺序能覆盖大部分常见问题,比盲目翻日志高效得多。
6. 那些年我在 candidate 上踩过的坑
6.1 优先级计算里的整数溢出
前面给的 priority 公式里,(2^24) * type_preference这一项,如果 type_preference 取 126,结果是 2113929216,接近 32 位有符号整数的上限 2147483647。如果实现里用的是有符号整数,再加上 local_preference 的部分,很容易溢出变成负数。负数在排序时会被排到最后,导致本该优先的 host 候选反而最后才被尝试。
这个坑我在两个不同项目里都遇到过。一次是 C++ 实现里用了int而不是uint32_t,另一次是 JavaScript 里位运算默认按 32 位有符号处理。修复方法很简单,把所有涉及 priority 的变量都改成无符号类型,或者在计算时用更大的数据类型再截断。但发现这个问题的过程很痛苦,因为日志里打印出来的 priority 看起来"就是个数字",不特意去算根本看不出溢出。
提示:如果你在排查"host 候选明明存在却不被优先尝试"的问题,第一件事就是检查 priority 的计算类型,确认没有溢出。
6.2 多网卡环境下的候选爆炸
开发机通常有多个网络接口:有线、无线、虚拟网卡、Docker 网桥、回环。每个接口都可能生成 host 候选,再加上 srflx 和 relay,候选数量轻松上双。如果双方都是这种环境,候选对数量就是几十乘以几十,上千个组合。虽然剪枝能砍掉一部分,但剩下的检查量依然很大,协商时间被拖到十几秒。
解决办法是在采集阶段做接口过滤。具体策略是:排除回环接口、排除明显是虚拟网桥的接口(比如 172.17.x.x、169.254.x.x)、排除没有默认路由的接口。有些实现还支持配置白名单,只采集指定接口。这个过滤逻辑看起来简单,但能显著减少候选数量,是优化协商速度最有效的手段之一。
我在一个项目里做过对比:过滤前平均候选数是 12,过滤后降到 4,协商时间从平均 3.2 秒降到 1.1 秒。效果非常明显。
6.3 srflx 采集的时序陷阱
srflx 候选的采集依赖 STUN 请求的响应。如果采集逻辑是"发请求后立即返回,不等响应",那 srflx 候选就会缺失。如果采集逻辑是"等所有 STUN 响应都回来再继续",那在网络差的时候会卡很久。
正确的做法是设置一个合理的等待窗口,比如 500ms,在这个窗口内收到的 STUN 响应都用来生成 srflx 候选,超时的就放弃。这样既不会漏掉正常的响应,也不会被慢响应拖死。有些实现还会对同一个 STUN 服务器重试,但重试次数不宜过多,否则窗口会被拉长。
还有一个细节:STUN 请求应该并发发往多个服务器。如果只用一个服务器,那个服务器挂了或者被限流,srflx 就采集不到。用两到三个不同地址的服务器,能提高采集成功率。但也不能太多,否则请求量太大,反而容易被判定为异常流量。
6.4 relay 候选的按需分配
relay 候选的获取需要向 TURN 服务器申请,这个过程比 STUN 慢,而且会占用服务器资源。如果每次协商都无条件申请 relay,服务器压力会很大。所以很多实现采用按需分配策略:先只采集 host 和 srflx,如果配对检查失败,再回头申请 relay。
这个策略的优点是省资源,缺点是增加了协商时间——因为要等前面的检查都失败了才去申请 relay,整体时间变长。折中方案是设置一个阈值,比如前 3 个候选对都失败了就立即申请 relay,不等全部检查完。这个阈值需要根据实际网络情况调,没有标准答案。
我在一个用户量比较大的项目里见过另一种做法:根据历史数据预测。如果某个用户之前的协商记录里 relay 使用率很高,就提前申请;如果一直是直连成功,就延后申请。这种数据驱动的策略效果不错,但实现复杂度高,适合有足够数据积累的场景。
7. 让 candidate 属性真正为你所用的几个实操建议
7.1 建立一份自己的候选检查清单
排查 ICE 问题时,最怕的是没有章法,东看一点西看一点。我建议你整理一份自己的检查清单,每次按顺序过一遍。我的清单大概是这样:
- 候选类型是否齐全(host / srflx / relay 至少各有一条)
- priority 数值是否在合理范围(host 接近 2.1e9,relay 接近 4e7)
- foundation 是否有异常重复或频繁变化
- 候选数量是否过多(超过 10 条就要考虑过滤)
- 检查日志里首个 succeeded 的时间
- 最终提名的是哪条候选对,是否符合预期
- 是否有 prflx 候选出现,出现频率如何
这份清单看起来简单,但能覆盖 80% 以上的常见问题。每次排查都走一遍,慢慢就形成肌肉记忆了。
7.2 在日志里把关键属性打全
很多项目的日志只打印候选的 IP 和端口,这是不够的。排查时你需要看到完整的属性行,包括 foundation、priority、type、raddr。我的做法是在采集和配对的关键节点,把候选的完整字符串打出来,并且加上时间戳和阶段标记。这样事后分析时,能清楚地看到每个阶段候选的变化。
日志级别也要注意。正常运行时可以只打摘要,但一旦进入排查模式,就要能动态调整到详细级别。有些实现支持通过配置或接口动态开启详细日志,这个能力在线上排查时非常有用。
7.3 用模拟工具验证优先级逻辑
优先级计算和配对排序的逻辑,光靠看代码很难验证对不对。我习惯写一个小工具,输入一组候选,输出排序后的候选对列表,然后手工核对前几个是否符合预期。这个工具不需要多复杂,几百行代码就能搞定,但能帮你快速发现计算错误。
更进一步,可以用模拟网络环境来测试不同 NAT 类型下的表现。比如用软件模拟对称 NAT、锥形 NAT、全锥 NAT,观察候选采集和配对的结果是否符合预期。这种测试在项目早期做,能避免很多上线后才发现的问题。
7.4 关注候选属性随时间的动态变化
candidate 不是一成不变的。网络切换、NAT 映射超时、接口状态变化,都会导致候选列表变化。如果实现里没有处理这些变化,就会出现"协商时好好的,用着用着断了"的情况。
我的经验是:在连接建立后,仍然要定期检查候选的有效性。比如每隔一段时间发一个保活包,如果连续几次没响应,就触发 ICE restart。restart 时会重新采集候选,重新配对,相当于把整个流程走一遍。这个过程对用户应该是无感的,但底层要做不少工作。
还有一个细节:restart 时 foundation 会重新生成,如果 foundation 变化太频繁,会导致冗余消除失效。所以有些实现会在 restart 时尽量保持 foundation 的稳定性,只更新必要的字段。这个优化对协商速度有正面影响。
8. 关于 candidate 属性,我最后想说的几点体会
搞 ICE 这些年,我最大的体会是:candidate 属性是 ICE 的"体检报告"。一份完整的候选列表,加上检查日志,基本能还原出整个协商过程的全貌。问题往往不在那些复杂的状态机逻辑里,而在某个字段的取值、某个计算的类型、某个采集的时序上。
另一个体会是:不要迷信默认值。不同实现、不同版本对 priority 的计算、foundation 的生成、候选的过滤策略都有差异。默认配置在大多数场景下能用,但在特定网络环境下可能就不行。真正靠谱的做法是理解每个字段的含义,然后根据实际场景去调。
最后,ICE 的调试很依赖工具和日志。花时间把日志打全、把工具做好,比反复读标准文档更有效。标准告诉你"应该怎样",但实际网络会告诉你"实际怎样",两者之间的差距,就是经验的价值所在。
如果你正在被某个 ICE 连接问题困扰,不妨先把双方的 candidate 完整列表打出来,逐字段过一遍。很多时候,答案就藏在那几行看起来平平无奇的属性里。