07-P2P 直连与节点池调度

进入架构进阶部分。这一篇讲两件事,它们分别回答「怎么更便宜、更快」和「怎么把资源边界管住」:

  • **P2P 直连**:让媒体绕过边缘节点,从推流端直接到观众------理论上延迟最低、且不占边缘出口带宽的「最后一公里捷径」。

  • **节点池调度**:在「角色 + 区域」之外再加一维资源隔离边界,决定一个请求**能看见哪些节点**。


第一部分:P2P 直连

1. 预测与实测:标签只是预筛选,ICE 检查才决定能不能连上

「Full cone / restricted / symmetric NAT」这套经典分类,在现代 ICE 实践里被认为是**过度简化**。更准确的描述是分别观察:

  • 地址/端口的 **mapping behavior**(映射行为);

  • 入站包的 **filtering behavior**(过滤行为);

再结合是否支持 hairpinning、映射寿命、IPv6、UDP 是否可用、网络是否切换综合判断。CGNAT 只说明地址转换发生在运营商网络,**不等于必然无法打洞**。

但现实中的实现往往还在用「经典类型标签做硬性预筛选」这个简化版本,比如:只要任意一方报了 symmetric 或 CGNAT,就直接拒绝,连 ICE 都不尝试。

这是不是一个错误?**不是。** 对称型 NAT 和 CGNAT 组合的打洞成功率本来就低,提前拒绝能省掉一次几乎注定失败的信令和 ICE 开销,是合理的工程取舍。但不应该把它包装成「系统已经在用 mapping/filtering 行为做精细判断」------**准确的说法是:理想的 ICE/NAT 工程实践基于行为观测分级处理,而简单实现仍在用标签做硬性预筛选。**

关键要分清两个概念:

```

预筛选通过("值得尝试") ≠ 已经证明这两端能连上

真正决定直连成不成的,永远是客户端那一次 ICE candidate-pair 连通性检查

```

预筛选只负责把明显没希望的组合提前挡在门外省开销,**不负责预测「这一次具体会不会成功」**。

2. 诚实的边界:网络世界没有数学意义上的 100%

必须说清楚:**NAT 预筛选不可能在互联网环境下提供数学意义上的 100% 成功保证。** 即使通过了全部预筛选规则,真正尝试连接时仍可能因为防火墙策略、网络切换、地址映射恰好过期而失败。

所以「ICE 可能失败」必须配一条兜底路径。这里有一个值得强调的设计演进:**从「并行竞速」改成「顺序回退」。**

  • 早期一些实现让直连和边缘**并行起跑**,谁先出帧用谁,用一个窗口限制等待时间。这会让首帧时间不可预测、建连逻辑复杂。

  • 更清晰的模型是**顺序回退**:客户端先尝试直连;失败、超时或名额已满,就直接改用**随决策一起下发、早已拿在手里的边缘地址**。不是两条链路抢首帧,是一条主路径 + 一个随时能用的备用地址。

3. TURN 与 Edge:这是产品取舍,不是技术优劣

TURN 是 ICE 的标准 relay(中继)来源,它的价值是提高受限网络、防火墙或 UDP 不可用环境下的连接成功率,代价是中继带宽、部署运维以及可能增加的路径时延。

如果产品**已经有成熟的边缘媒体路径**,完全可以选择「直连失败后回退边缘」,而不部署 TURN。这是成本、协议一致性、覆盖率和运维复杂度之间的取舍。但要诚实:这个选择的代价是**对称 NAT / CGNAT 用户完全没有「走中继也能直连」的退路,只能走边缘**。如果将来要覆盖这批用户,TURN 部署会是绕不开的一步。

4. 名额分配:为什么要用「原子租约」

P2P 直连会占用推流端自己的上行带宽,所以不能无限制地让所有观众都跟同一个推流端直连。一个常见做法是限制「每个推流端同一时间最多服务 N 路直连」,第 N+1 个观众必须走边缘。

这个上限保护两件事:推流端上行不会被「一拖多」拖垮,以及已建立的几路直连不会因新连接加入而质量下降。

但这里藏着一个并发系统里的经典问题:如果多个观众几乎同时请求「判断能不能跟这个推流端连」,光靠「查询当前有几路,小于 N 就同意」会出现竞态------两个请求都查到「当前是 N−1 路」,都判断「还有名额」,结果同时获批,实际建立了 N+1 路。

**解决办法是把「检查名额是否够 + 占用一个名额」合并成一个不可分割的原子操作(atomic lease)**:请求进来时直接尝试原子性地扣减一个名额,扣减成功才继续,失败就直接判定不满足资格、返回边缘。这样不管多少请求同时涌进来,最终拿到名额的最多只有 N 个,不会超发。

> 这类「资源租约」的设计模式不是 P2P 专属。任何「有限资源、高并发申请」的场景都会遇到同样的问题,用的是同一类解法。

5. 信令走控制面,媒体走直连

直连建立前,Offer、Answer、ICE candidate 这些信令消息都要经过控制面转发。但控制面在这里做的只是**转发和身份校验**,不参与媒体数据本身(呼应架构篇的「控制面不碰媒体」)。校验的核心是确认信令双方的**身份和会话确实匹配**,防止有人把信令注入到别的直播流或别的用户的会话里。

信令通过之后,媒体数据走推流端和播放端之间的直连通道,完全不经过控制面。

6. 诚实的现状

一次真实的 P2P 直连跑通约 70ms,是特定实验条件下的端到端观测,不是理论值,也不是所有网络的承诺。连接可靠性近期有改善,但**「在测过的这些网络组合下能连上」不等于完成了覆盖各类 NAT 组合、CGNAT、多运营商、IPv4/IPv6、UDP 受限和网络切换的系统性验收**。设计完成、单次连通验证、大规模系统性验收,是三个不同阶段。


第二部分:节点池调度

7. 问题:为什么「角色 + 区域」两维不够用

很多系统的节点筛选只有两个维度:**角色**(Origin 还是 Edge)和**区域**(region/city)。同一角色的所有在线节点被当成一个大资源池,不区分这批机器归属谁、给谁用。

下面这些场景就不够用了:

  • **大客户/高优先级应用需要独占一批节点**,或同一租户的多个应用需要共享一批保留资源,避免被其它租户挤占;

  • **不同硬件规格/编码能力的节点要分开调度**,比如高规格节点只服务特定套餐;

  • **新版本或刚上线的节点需要先放进「灰度池」验证**,不能立刻进入生产候选;

  • **运维希望按团队或合同边界拆分节点的管理权限和可见范围**。

共同点是:需要在「角色 + 区域」之上再加一维**节点池**,作为资源的隔离边界。这一维不是给人分三六九等的「标签」,而是一条**「谁能被看见」的硬约束**。

8. 机制:池是第一层过滤,池内只按空闲度择优

一次调度请求的处理顺序:

```

调度请求(role, appId)

│

▼

解析请求所属的池

(app 显式绑定优先;否则按租户/用户默认池;都没有 → 共享池)

│

▼

按池过滤:只保留属于该池的可用节点

(不属于该池的节点,从这里开始就不在候选里)

│

▼

池内排序:剩余容量最大优先

│

▼

返回最优节点;主池无可用候选 → 进入回退判定

```

关键点是:**池过滤发生在最前面**。池内的排序再怎么变,都改不了「别的池的节点压根不在候选里」这个事实------这就是隔离的落点。

节点池的归属通常是**部署时静态确定**的属性,和区域一样,不是每次调度都变的动态数据。

9. 池归属由谁决定:从「节点自报」到「控制面权威分配」

这里有一段值得讲的演进。早期设计是**节点自报**:节点在部署配置里写一个池 ID,注册时上报给控制面。

但当系统允许自建节点用较低信任度的凭证注册之后,这套做法必须收紧:**如果允许节点自己上报「我属于哪个池」,一个自建节点理论上可以谎称自己属于别人的池,绕开隔离边界。** 所以池归属收回到控制面权威记录------节点创建时由控制面写入归属,节点对此没有发言权,也改不了。

这是一个很好的安全设计例子:**隔离承诺要成立,就必须把「声明归属」的能力从不可信的一方手里拿走。**

10. 回退语义:fail-closed,不静默突破隔离

这是整套机制里最需要讲清楚的一条。假设某个应用绑定了池,但池内**没有可用候选**,怎么办?(注意「没有可用候选」包括无节点、节点离线或不健康、处于排空状态、没有节点满足准入条件等,不能只用「节点数量为零」判断。)

两种做法语义完全不同:

  • **静默回退到共享池**:调度「永远不失败」,但隔离承诺被悄悄破坏了------业务方以为自己在用独占资源,实际已经跑到公共池上,成本核算和隔离预期全部错位。

  • **显式配置 + 显式告警**:回退是一个需要**主动打开**的开关,并且**默认关闭**。

推荐后者,默认 fail-closed:

```

主池内没有可用候选

│

▼

允许回退 ?

┌────┴─────┐

false true

│ │

▼ ▼

调度失败 在共享池重新计算候选

  • 池耗尽告警 ├─ 有候选:回退成功 + 记录一条可区分的回退事件

└─ 无候选:调度失败

```

设计哲学很简单:**隔离是一条承诺,静默回退就等于承诺失效。** 所以它必须由业务方显式打开,而且无论哪种结果都要留下痕迹------要么失败被告警,要么回退被记录,绝不能「看起来一切正常」。

11. 两个「看不见」的用法:移出调度与灰度池

池机制里还藏着两个很实用的边界用法,靠的是同一个原理------**只要一个池没有任何应用解析到它,池里的节点就不会被任何调度选中**:

  • **`unassigned`(移出调度)**:一个专属的哨兵池。任何应用都不会解析到它,所以被置为该池的节点等于被干净地「拔出调度」,但进程还活着、连接还在,可以随时再搬回来。这就是后台「移除节点」按钮背后的动作。

  • **灰度池**:把新节点注册到一个暂时没有任何应用绑定的池里,它就自然地「隐身」在生产调度之外------不参与候选,但心跳、容量上报、健康检查都照常。等验证通过,再搬进真正的生产池,不需要为灰度单独发明一套流程。

12. 诚实的边界

  • **池约束的是「控制面把请求分给谁」**,不是天然的物理隔离。共享池内部仍可能由多个应用共用节点,网络和进程边界不会自动隔离。

  • **它通常只覆盖 Origin/Edge 两个角色。** 录像、跨区域桥接等角色如果走独立调度路径,就不在这套隔离机制的管辖范围内。

  • **「设计能力」和「产品暴露的能力」是两回事。** 设计里可以支持任意命名池、池优先级、节点多对多等灵活形态,但产品往往把常见诉求收敛成「一人一池 + 可选的 app 显式绑定」这样更简单、更保守的形态。


小结

  • **P2P 直连**:标签预筛选只负责「值得尝试」,ICE 检查才决定能不能连上;不承诺数学意义的 100%;用顺序回退而不是并行竞速;有成熟边缘路径时可不部署 TURN,但要接受 CGNAT 用户没有中继退路。

  • **名额分配**:用原子租约防止并发超发。

  • **信令走控制面、媒体走直连**:控制面只转发和校验身份。

  • **节点池**:在角色/区域之上加一维隔离边界,先按池过滤、池内按空闲度择优。

  • **归属权威化**:池归属由控制面决定,节点不能自报,防止绕开隔离。

  • **回退 fail-closed**:默认不回退,显式打开且留下痕迹,绝不静默突破隔离。

下一篇,我们把镜头拉远:**横向扩容和低延迟到底是什么关系**,以及当流量不是缓慢上涨、而是突然爆发时该怎么办。


**系列导航**

- 上一篇:06 · 可观测性(06-可观测性-端到端延迟稳定性与画质.md)

- 下一篇:08 · 横向扩容与突发高并发(08-横向扩容与突发高并发.md)

- 返回:系列导览(README.md)

相关推荐
草根大哥2 小时前
06-可观测性:端到端延迟、稳定性与画质
webrtc·srt·whip·自建cdn·ppcdn
大文说跨境2 小时前
用 Python 自动化检测浏览器环境指纹:WebRTC 泄露、Canvas 哈希与时区一致性校验
python·自动化·webrtc
EasyDSS1 天前
基于WebRTC的视频会议系统:私有化视频会议平台EasyDSS视频会议到底能干什么
音视频·webrtc·easydss
福大大架构师每日一题4 天前
RustDesk 1.5.0发布:WebRTC、HDR 色调映射、剪贴板同步与百余项修复全面汇总
webrtc
OnlineProxy5 天前
企业级代理服务器架构:合规性、NDA 诚信与灰色 P2P 网络风险防范
网络·架构·p2p
wdfk_prog6 天前
Wi-Fi Direct 源码分析(11):P2P-GROUP-STARTED 之后——Group Interface、IP 配置与真实数据通路
运维·服务器·网络协议·tcp/ip·asp.net·p2p·wifi-direct
wdfk_prog7 天前
Wi-Fi Direct 源码分析(09):从 P2P_CONNECT 到 GO Negotiation 完成
运维·服务器·ubuntu·golang·asp.net·p2p·wifi-direct
福大大架构师每日一题9 天前
webrtc-rs/webrtc v0.21.0发布:API 可扩展、异步集成、确定性时间、ICE 重启与 SCTP 全面修复
webrtc