跳转至

EP13:P2P 直连:比 CDN 更快的最后一公里

上集回顾 EP12《低延迟直播测评方法与当前基线》,Module 2 结束
下集预告 EP14《节点池管理:把资源边界放进调度器》

0. 本集目标

看完这一集,观众应该能做到两件事:

  1. 理解理想的 NAT/ICE 工程模型(mapping/filtering 行为 + connectivity checks)和 PPCDN 当前实现(经典类型标签硬性预筛选)之间的差距,并说清楚这个差距是合理的工程取舍,不是错误。
  2. 理解 TURN、Edge 回退和直连名额都是产品取舍——PPCDN 选择了"自建 STUN + Edge 顺序回退"而不是 TURN 中继,这个选择的代价是 symmetric NAT/CGNAT 用户完全没有退路。

1. 开场钩子(逐字稿要点)

Module 3 从这一集开始。当前 P2P 真实跑通的端到端延迟约 70ms,但这个结果不能简化成某个“NAT 类型标签”带来的保证。真正建立连接的是 ICE:收集候选、交换候选、检查候选对并选出可用路径;预测规则只能优化是否值得尝试,不能替代实际检查。


2. 预判与实测:NAT 类型标签做硬性预筛选,ICE 检查决定直连能否真正建立

2.1 更好的理论模型,和 PPCDN 当前实现之间的差距

“Full cone / restricted / symmetric NAT”这套经典四元分类,在现代 ICE 实践里被认为是过度简化:更准确的描述方式是分别观察地址/端口的 mapping behavior 与入站包的 filtering behavior,再结合是否支持 hairpinning、映射寿命、IPv6、UDP 是否可用以及网络是否切换综合判断。CGNAT 只说明地址转换发生在运营商网络,不等于必然无法打洞;endpoint-dependent mapping 会降低成功率,但理论上不该仅凭标签一刀切判死。

这是行业里更先进的做法,但要对读者诚实:PPCDN 当前的服务端判定(ppcenter/internal/p2p/eligibility.go 的 EvaluateDirectEligibility)还没有做到这一步。它消费的是 ppobs/pplayer 探测后上报的单次经典标签——public/cone/restricted/symmetric/cgnat 五选一(解析逻辑见 nat_probe_v1.go 的 parseProbeNATType,该函数的注释也直言"浏览器探测本来就只能分出 public 和 restricted,连 full-cone 和 port-restricted 都分不清")。判定逻辑里唯一体现"行为"的字段是布尔值 MappingStable,但它本身就是"NAT 类型是否落在 {public, cone, restricted} 这三个好标签里"的直接映射,不是一次独立的多端口/多目的地行为探测。更重要的是:只要任意一方报了 symmetric 或 cgnat,EvaluateDirectEligibility 会直接返回拒绝(EligibilityReason 为 symmetric_nat/cgnat),根本不会走到后面的 UDP、地址族、能力、冷却检查,也绝不会有机会尝试 ICE——这正是上一段说的"仅凭标签判死"。

这不代表现在的做法是错的:对称型 NAT 和 CGNAT 组合的打洞成功率本来就低,提前拒绝能省掉一次几乎注定失败的信令和 ICE 开销,是合理的工程取舍。但不能把这个取舍包装成"系统已经在用 mapping/filtering 行为做精细判断"——准确的说法是:理想的 ICE/NAT 工程实践基于行为观测分级处理,PPCDN 当前的实现是基于经典类型标签的硬性预筛选,是一个还在演进中的简化版本。课程里常提的"历史成功率""网络是否刚切换"这类更精细的信号,目前也没有接入这个判定:nat_observation_store.go 只保存每个 participant 最近一次探测、到期即清理,不做成功率统计;P2P 命中率现在只在事后的 GET /admin/p2p-penetration 报表里能看到,是分析用途,没有回灌进实时判定逻辑。

2.2 两级关卡:服务端预筛选在前,ICE 连通性检查在后

通过预筛选之后,流程才轮到真正的 ICE:收集 host、server-reflexive(必要时 relay)candidate,交换凭据与候选,组成 candidate pairs,再通过带认证的 STUN connectivity checks 验证双向可达并提名路径。但预筛选和 ICE 检查不是互相印证的两套独立证据,而是严格的两级关卡:EvaluateDirectEligibility 只用双方最近一次探测快照做一次性硬性过滤——探测新鲜且身份、streamPath 都匹配;mapping 稳定(非 symmetric/cgnat);双方 UDP 可用;有公共地址族;ICE/H.264/Opus 能力兼容;不在失败冷却期;至少一方有服务端验证过的、可被对端访问的公网 UDP endpoint。全部满足才把 p2p-connect 决策下发给客户端,客户端才会真正启动 ICE;任何一条不满足,客户端拿到的直接是 edge-only,连 ICE 都不会尝试。

所以"资格判定通过"(eligibility.Eligible == true)这个状态,准确含义是"满足全部预筛选规则,值得让客户端去真跑一次 ICE",而不是"已经证明这两端能连上"。真正决定直连成不成的,始终是客户端那一次 ICE candidate-pair connectivity check——预筛选只负责把明显没希望的组合提前挡在门外省开销,不负责预测"这一次具体会不会成功"。

(设计文档里提到的 DIRECT_ELIGIBLE"验证矩阵",在当前实现里并不是一张按 NAT 组合预先打分的表——真实代码是一串顺序执行的条件判断,命中任何一条就拒绝;两者说的是同一个设计意图,但落地方式更朴素,课程里不再使用 DIRECT_ELIGIBLE 这个在代码里从未真正出现过的名字。)

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

这一点必须说清楚:NAT 预筛选不可能在互联网环境下提供数学意义上的 100% 成功保证。即使通过了 §2.2 的全部预筛选规则,真正尝试连接时仍可能因为防火墙策略、网络切换、地址映射恰好在这个时间点过期而失败——预筛选只是一个"减少无效尝试"的准入条件,不能当成"这次一定能连上"的保证。

这也是为什么"ICE 可能失败"必须配一条兜底路径,不是可选项——但兜底的具体机制在 2026-10-04 做了一次调整,值得讲清楚:现在不是"P2P 和 Edge 两条路径并行起跑、谁先出帧用谁"的竞速模型,而是"客户端先尝试 P2P,失败或超时直接改用早已拿到手的 Edge 地址"的顺序模型。PPCDN 的播放决策接口按解码能力分流——支持 HEVC 的客户端直接走 Edge,从不尝试 P2P;H264 客户端若通过了 §2.2 的预筛选,会拿到 p2p-connect 决策,其中已经同时带着一个签过名的 Edge WHEP 兜底地址,不需要二次请求(见 ppcenter/internal/apis/player_v1.go,服务端代码注释明确写着"the client follows the decision - no client-side race")。没通过预筛选、P2P 建连失败或推流端名额已满,客户端都直接改用这个随决策一起下发的 Edge 地址——不是两条链路抢首帧,是一条主路径 + 一个随时能用的备用地址。这取代了更早版本"P2P/Edge 500ms 并行竞速"的设计,如果你看到的资料还在讲并行竞速,说的是这次调整之前的状态。


3. TURN、Edge 与直连:不同产品路径的取舍

TURN 是 ICE 的标准 relay candidate 来源,不是“伪装成 P2P 成功”。它的价值是提高受限网络、防火墙或 UDP 不可用环境下的连接成功率,并提供统一的会话连通语义;代价是中继带宽、部署运维以及可能增加的路径时延。

如果产品已有成熟 Edge 媒体路径,可以选择直连失败后回退 Edge,而不部署 TURN。这是成本、协议一致性、覆盖率和运维复杂度之间的产品取舍,不说明 TURN 技术低劣,也不能由此把 CGNAT 或 endpoint-dependent mapping 用户提前拒绝。更稳妥的策略是让 ICE checks 在受控预算内尝试可用 host/srflx 候选,失败后快速选择 Edge;若业务更重视连接成功率或通用 WebRTC 互通,再提供 TURN。

PPCDN 目前就是选了前一种:只自建了一个 STUN 服务器(ppcenter/internal/stun/server.go,默认监听 :3478,地址随 p2p-connect 决策一起下发给客户端),全仓库没有 TURN relay 的实现或配置项。直连失败时唯一的兜底是上面说的 Edge WHEP fallback,不是中继媒体——这意味着 symmetric NAT、CGNAT 这类当前被预筛选直接拒绝的组合,完全没有"走中继也能直连成功"这条退路,只能走 Edge。这是一个诚实的现状,不是"CGNAT 用户体验更差也无所谓"的结论——如果将来要覆盖这批用户,TURN 部署会是绕不开的一步。


4. 名额分配:原子租约,防止超发

4.1 每个推流端最多服务 3 路直连

P2P 直连会占用推流端自己的上行带宽(EP07 讲过,主推流优先,P2P 用的是富余带宽),所以不能无限制地让所有观众都尝试和同一个推流端直连——每个推流端同一时间最多服务 3 路 P2P 直连,第 4 个观众必须走 Edge。这个上限保护的是两件事:推流端自己的上行带宽不会被"一拖多"拖垮,以及已经建立的几路直连不会因为新连接的加入而质量下降。

这个"3"不是写死的魔法数字,是平台默认值(ppcenter/internal/apis/play_settings.go 的 defaultMaxP2PSessions = 3):superadmin 可以在 (0, 50] 范围内调整(maxP2PSessionsCeiling = 50),改完立即对 p2p.Coordinator 生效、不需要重启,而且 /v1/publish/requests 会把当前生效值透传给 ppobs,推流端自己也不再硬编码 3——这个细节本身就是前面"名额分配是产品取舍"这条原则的一个具体例子。

4.2 为什么要用"原子租约",不是简单地"数一数当前有几个"

这里有一个并发系统里的经典问题:如果多个观众几乎同时请求"帮我判断能不能跟这个推流端连 P2P",光是"查询当前有几路连接,如果小于 3 就同意"这种做法,在并发场景下会出现竞态——两个请求都查到"当前是 2 路",都判断"还有名额",结果同时获批,实际建立了 4 路,超过了上限。

解决办法是把"检查名额是否够 + 占用一个名额"合并成一个不可分割的原子操作(atomic lease):请求进来时,直接尝试原子性地"扣减"一个名额,扣减成功才允许继续走 P2P 流程,扣减失败(说明名额已经被别的请求拿走)就直接判定不满足资格,返回 Edge。这样不管多少个请求同时涌进来,最终能拿到名额的最多只有 3 个,不会出现超发。这类"资源租约"的设计模式不是 P2P 专属,任何"有限资源、高并发申请"的场景都会遇到同样的问题,用的是同一类解法。

PPCDN 里这个"原子"落地成一把全局互斥锁:ppcenter/internal/p2p/coordinator.go 的 Coordinator 用 c.mu.Lock() 包住"查询资格 + allocateLocked 分配名额"这一整段逻辑(AllocateEligible 函数),不是 CPU 级别的无锁原子指令,但效果等价——检查和占用之间不会被另一个请求插队。锁的粒度是"同一个 coordinator 实例"而不是"同一个推流端",但因为这段逻辑本身只做内存比较和 map 操作、没有网络 I/O,临界区极短,不会成为高并发下的瓶颈。


5. 信令怎么走:控制面只转发和校验,不碰媒体

P2P 连接建立前,Offer、Answer、ICE candidate 这几类信令消息,都要经过服务端转发——但服务端在这里做的只是转发和身份校验,不参与媒体数据本身(呼应 EP02 讲过的"控制面不碰媒体数据"这条最基础的设计原则)。校验的核心是确认信令消息的双方身份和会话确实匹配,防止有人把信令注入到别的直播流或别的用户的会话里——这类校验是防止跨流、跨用户信令伪造的最后一道关卡,媒体数据本身在信令通过之后走的是推流端和播放端之间的直连通道,完全不经过服务端。代码位置:ppcenter/internal/ws/p2p_signal.go,身份/会话校验逻辑在 validateInbound。


6. 诚实的现状:连通已经验证过,系统性验收仍是空白

P2P 直连约 70ms 是 2026-09-29 一次真实链路测试(见时延测试报告)在特定实验条件下跑出来的端到端结果,不是理论值,也不是所有网络的承诺。

连接本身的可靠性最近有了实质进展:此前一段时间"P2P 几乎每次 fallback 到 Edge"的问题,经过一轮排查修复(STUN 配置补全、信令身份匹配、STUN 服务器地址族选择等,部署记录里 2026-09-22 前后几条都是这轮修复),P2P 直连已经在多个真实网络环境下测试成功,不再是当时那种"结构性全失败"的状态。但这只回答了"在测过的这些网络组合下能连上",不等于完成了覆盖各类 mapping/filtering 行为、CGNAT、多运营商、IPv4/IPv6、UDP 受限和网络切换的系统性验收——docs/test/p2p-real-environment-acceptance-plan.zh-CN.md 设计的 7 个验收场景里,多数仍标注为"探索性"而非已跑完的硬性验收,需要记录 ICE 各 candidate type 的成功率、建连耗时、回退率与端到端延迟分布,而不是一两次成功连接。设计完成、单次连通验证、大规模系统性验收,是三个不同阶段,目前走到了第二阶段。


7. 框图

7.1 P2P 资格判定与连接流程

播放请求到达(携带 NAT 探测结果)
        │
        ▼
支持 HEVC?──是──▶ 直接返回 edge-only(从不尝试 P2P)
        │否
        ▼
服务端预筛选(EvaluateDirectEligibility,§2.2 全部硬性条件)通过?
        │
   ┌────┴────┐
   否         是
   │          ▼
   │    推流端还有空闲 P2P 名额(< maxP2PSessions)?
   │          │
   │     ┌────┴────┐
   │     否         是
   ▼     ▼          ▼
返回 edge-only(已带签名 Edge 地址)      返回 p2p-connect
(reason=各预筛选拒绝原因 / at_capacity)  (同时带着 Edge 兜底地址,不用二次请求)
                                             │
                                             ▼
                              客户端启动 ICE:收集/交换候选 + connectivity check
                                             │
                                        ┌────┴────┐
                                      失败/超时      成功
                                        │             │
                                        ▼             ▼
                              改用已拿到的 Edge 地址   走 P2P 直连媒体
                             (顺序回退,不是并行竞速)

7.2 P2P 名额的原子租约

多个播放请求几乎同时到达(都想跟同一个推流端建 P2P)
        │
        ▼
   逐个尝试原子操作:"检查名额 < 3" + "占用一个名额" 合并成一步
        │
   ┌────┴────┐
  扣减成功    扣减失败(名额已被占满)
   │              │
   ▼              ▼
继续走 P2P     直接判定不满足资格,返回 Edge
流程
   │
   ▼
最多同时存在 3 路租约,不会超发

7.3 信令转发:控制面校验身份,不碰媒体

推流端                     控制面(ppcenter)                     播放端
  │  Offer ──────────────────▶  校验:这条信令属于
  │                              哪个 session?双方身份
  │                              是否匹配这个直播流?
  │                                    │
  │                              ┌─────┴─────┐
  │                             通过        不通过
  │                              │            │
  │  ◀───────────────────────  转发         拒绝,记录异常
  │                              │
  │  Answer / ICE candidate  ──▶ 同样的身份校验后转发 ──▶

   信令通过后,媒体数据直接走推流端 ↔ 播放端的直连通道,
   不再经过控制面

8. 结尾与下集预告(逐字稿要点)

这一集把 P2P 直连拆到了 ICE 层:理想的 NAT 工程实践应该分别描述 mapping/filtering 行为,而不是依赖经典的四元标签;但 PPCDN 当前的服务端预筛选还停留在标签层——命中 symmetric/cgnat 标签就直接拒绝、连 ICE 都不会尝试,这是一个诚实的差距,不是已经做到的精细判断。通过预筛选之后,真正能不能连上仍然由本次 ICE connectivity check 决定,预筛选只负责省掉没希望的尝试。TURN 是标准中继能力,PPCDN 目前只自建了 STUN、没有部署 TURN,选择用 Edge 回退而不是中继;回退机制本身也刚从"P2P/Edge 并行竞速"改成了"P2P 优先尝试、失败就顺序切到已经拿在手里的 Edge 地址"。当前约 70ms 是一次真实链路的观测基线,连接可靠性近期有改善,但覆盖各类 NAT 组合、记录 ICE candidate 成功率分布的系统性验收仍是空白。

下一集,我们讲节点池管理:为什么"角色 + 区域"两维不够用,怎么按用户、租户或 app 建立资源边界,以及主池没有可用候选时为什么默认不回退。