EP13:P2P 直连:比 CDN 更快的最后一公里¶
| 上集回顾 | EP12《低延迟直播测评方法与当前基线》,Module 2 结束 |
|---|---|
| 下集预告 | EP14《节点池管理:把资源边界放进调度器》 |
0. 本集目标¶
看完这一集,观众应该能做到四件事:
- 理解为什么要在直播能力之外给 PPCDN 接一条 P2P 直连通道——1v1 iSlot 这类“一人一独享流”的场景,是这条能力最直接的受益者,不是锦上添花。
- 理解“STUN 穿透率颇高”这句话背后的真实机制:不是“什么样的 NAT 都能穿透”,而是“预筛选只放兼容的 NAT 组合进 ICE 阶段”,所以回看真正发起过的穿透尝试,成功是常态;也理解为什么这个架构选择不部署 TURN。
- 说清楚一次 P2P 直连具体怎么建立——ppcenter 身兼一个极简 STUN server 和信令中继两个角色,ppobs 与 pplayer 各自探测、各自发起 ICE,媒体最终完全绕开服务端。
- 理解 PPCDN 怎么让直连时延“看得见”——码流内嵌 SEI 时间戳 + 播放端对时,而不是只凭 WebRTC 的 RTT 去估。
1. 为何要集成 P2P 直连:1v1 业务场景是最直接的受益者¶
1.1 1v1 iSlot:“一人一独享流”,直播的规模经济在这里失效¶
真人视讯桌台、在线竞拍一对一看播、互动课堂一对一辅导、连麦陪玩——这类 1v1 iSlot(一对一互动视频)业务的共同特征是:一个用户独占一份实时视频,不是“N 个观众共享同一路直播”。这和 EP01 讲过的“互动窗口以秒计、画面就是业务状态本身”是同一类问题,但多了一层直播没有的资源压力:
| 维度 | 直播(1 推 N 看) | 1v1 iSlot(1 推 1 看 × 房间数) |
|---|---|---|
| 拓扑 | 一个推流端,Edge 把同一份编码分发给 N 个观众 | 每个房间是独立的一条推流-观看对,房间数 = 并发用户数 |
| 出口带宽随规模的变化 | 观众数增长,边际成本被同一份编码摊薄 | 没有摊薄效应:带宽随房间数线性增长,是毛利的直接杀手 |
| 延迟容忍度 | 多数场景可容忍一定缓冲(点播/泛娱乐直播) | 互动窗口以秒计,画面即业务状态本身,容错极低 |
| 媒体方向 | 单向(推流端 → 观众) | 仍是单向(推流端 → 这一个观众),不是双向视频通话 |
一句话:1v1 iSlot 不是“小一号的直播”,而是把实时性和成本同时拉到极致的场景——直播靠“一份编码喂多个观众”摊薄成本,1v1 没有这个摊薄效应,每接入一个用户就是一条独立的实时流。
1.2 P2P 直连把“独享流”的出口带宽做到零,顺带还更快¶
一旦直连成功,画面从推流端直接到对端,完全不经过 Edge——出口带宽不是省了一部分,是真的归零。同时 P2P 直连天然是最短路径,在同口径实测下(见直播时延性能测试报告):
| 承载方式 | 实测端到端时延 | 对 1v1 的意义 |
|---|---|---|
| P2P 直连 | 约 70ms | 出口带宽归零,且是三者中时延最低的路径 |
| WHIP 入库 + Edge 分发 | 约 108ms | P2P 不通时的稳定兜底,体验仍然在线 |
| SRT 入库 + Edge 分发 | 约 386ms | 用重传窗口换弱网上行鲁棒性 |
对一对一互动场景而言,“直连”恰好对上了这个拓扑本身最自然的承载方式——不需要先绕到服务器再绕回来。这也是为什么 1v1 iSlot 不是把 P2P 当成可选的性能优化,而是把它当成成本结构本身:直连命中的每一路,边缘出口带宽直接归零;出口带宽账单可以随 P2P 命中率显著下降,这在“房间数 = 并发用户数”的 1v1 模型下尤其明显。要注意两个边界:一是 HEVC 观众只走 Edge、不参与 P2P——同一个观众要么用 HEVC 省 Edge 下行码率,要么用 H264 走 P2P 让这一路 Edge 带宽归零,两份收益不叠加在同一个观众身上;二是每个推流端默认最多 3 路直连,所以房间越大,P2P 能分走的比例越小(EP20 把这个比例算成了公式)。P2P 同样占用推流端的上行,与 HEVC + H264 同播共用同一份上行预算,所以主推流永远优先、P2P 先让出。
1.3 边界:这仍是单向 P2P 的 N=1 特例,不是双向视频通话¶
必须说清楚一个容易混淆的边界:本集讲的 P2P 直连,拓扑始终是“1 个推流端(ppobs)+ 最多 3 个观看端(pplayer)各自直连”,媒体方向单向。1v1 iSlot 只是把“观看端数”降到了 1,直接复用这套已经实现的能力,不需要新的协商模型。
如果业务需要的是两端互为推流端和观看端的真正双向视频通话(比如游戏内玩家互看、客服一对一连线),那是另一条独立、仍在规划阶段的路线——PPCDN 当前的媒体面(mmx)每条 PeerConnection 还只能单收或单发,没有 sendrecv 能力,双向通话需要新的编排与客户端能力,目前连 MVP 都建议先走中继而不是 P2P。这不在本集范围内,不要把“已实现的单向 P2P 直连”和“规划中的双向通话”混为一谈。
2. P2P 穿透情况如何:STUN 穿透率颇高,不需要 TURN¶
2.1 “穿透率高”的真正原因:兼容的才会被放进 ICE 阶段,不兼容的从未尝试¶
实测发生的 P2P STUN 穿透,基本都是成功的。但这句话的准确含义不是“什么样的 NAT 都能穿透”,而是:到达“真正发起 ICE”这一步的连接,本来就是预筛选认为兼容的 NAT 组合——是先筛选再尝试,不是“随便两端都去试,然后发现大多数能成”。
服务端的 EvaluateDirectEligibility(ppcenter/internal/p2p/eligibility.go)在真正发起 ICE 之前,已经依次做了一轮硬性过滤:探测新鲜且身份/流匹配、mapping 稳定(非 symmetric/cgnat)、双方 UDP 可用、有公共地址族、ICE/H264/Opus 能力兼容、不在失败冷却期、至少一方有已验证的公网 UDP 端点。任何一条不满足,直接返回 edge-only,连 STUN binding、ICE candidate 都不会去发——也就是说,这次尝试根本不会被计入“穿透成功/失败”的统计里,因为它从一开始就没有发生。
这个前提在真实环境验收方案里写得很直白:对称 NAT / CGNAT 组合永远判定不合格,直接走 Edge,验收测试从没打算验证“这些组合能不能靠某种方式连上”,只验证“该拒绝的场景是否真的被干净拒绝、且用户完全无感”。
一个必须说清楚的边界:浏览器端的 NAT 类型探测本身粒度有限(只能分出 public/restricted 两档,分不清 symmetric),预筛选不是数学意义上 100% 精确——真实环境里出现过预筛选判定“可以试”但 ICE 仍然失败的个案(连续约 40 秒拿不到一对成功的 candidate pair),根因是那个用户其实是 symmetric NAT,只是浏览器探测没能分辨出来。这类个案确实存在,不代表“穿透率高”的结论是假的:多个独立网络环境下的实测确认,兼容 NAT 组合之间的直连能够成功建立,这是预筛选之后真正发起 ICE 的连接的常态表现,不是极少数幸运样本。
2.2 为什么选择不部署 TURN¶
TURN 是 ICE 标准的 relay candidate 来源,价值是提高受限网络、防火墙或对称 NAT/CGNAT 环境下的连接成功率,代价是中继带宽、部署运维和可能增加的路径时延。PPCDN 目前只自建了一个 STUN 服务器,全仓库没有 TURN 的实现或配置项——这不是漏掉了,是算过账之后的选择:
| 维度 | TURN 中继 | STUN-only(PPCDN 当前选择) |
|---|---|---|
| 连通性覆盖 | 能覆盖对称 NAT/CGNAT 等更多组合 | 只覆盖预筛选判定兼容的 NAT 组合 |
| 对“省 Edge 出口带宽”这个目标的收益 | 几乎没有——媒体仍经一台服务器中转,中继带宽约等于 Edge 本来就要转发的量 | 兼容组合之间的直连带宽确实归零 |
| 不兼容组合的归宿 | 走 TURN 中继 | 直接走 Edge——反正都是服务器转发,不必再多运维一套 coturn |
| 主要代价 | 额外的中继带宽、coturn 运维、一个新的信任边界 | 对称 NAT/CGNAT 用户没有“直连”这个选项 |
关键在于中间那一行:P2P 的产品价值是让推流端 → 观众这条媒体路径绕开 Edge,省下 CDN 的出口带宽。如果给对称 NAT/CGNAT 用户配一套 TURN,路径会变成“推流端 → TURN → 观众”——TURN 由平台自己运维,这份中继带宽基本等于 Edge 本来就要转发的量,对这批用户而言,“接 TURN”相对于“直接走 Edge”并没有带宽节省收益,只是把“Edge”换了个名字叫“TURN”,还多一套基础设施、多一层信任边界要维护。既然不兼容的组合反正要走服务器转发,不如直接用已经工程化、已经在跑的 Edge 分发。
代价必须明说:对称 NAT/CGNAT 用户完全没有“直连”这条退路,只能走 Edge——这是认清产品目标之后主动做的取舍,不是遗漏,也不代表这些用户的体验会变差(Edge 本来就是一条完整可用的兜底路径,见 EP07)。
3. P2P 具体的穿透流程:ppcenter 就是一个 STUN server,ppobs 和 pplayer 进行 p2p connect¶
3.1 ppcenter 身兼两个角色:STUN server + 信令中继¶
ppcenter/internal/stun/server.go 是一个极简的自建 STUN 服务器:纯 UDP,只认 STUN Binding Request,回一个带 XOR-mapped 地址的 Binding Success Response,默认监听 :3478——没有实现 TURN 的 Allocate / Permission / ChannelBind 等任何消息类型。它是教科书意义上最小的 STUN server,不是“功能受限的 TURN”。
另一个角色是信令中继:/v1/p2p/signal 这条 WS 只转发 Offer / Answer / ICE candidate 并校验参与方身份(ppcenter/internal/ws/p2p_signal.go 的 validateInbound),不接触媒体数据本身——呼应 EP02 讲过的“控制面不碰媒体数据”这条最基础的设计原则。
3.2 一次 P2P 直连,从探测到建连的完整步骤¶
- ppobs / pplayer 各自向 ppcenter 自建的 STUN 服务器(以及 Google 公共 STUN 兜底)发 UDP Binding Request,拿到自己的服务器反射(srflx)公网地址。
- 把这次探测结果
POST /v1/nat/probe上报给 ppcenter,换回一个 5 分钟内有效的probeId(现已持久化写入 MySQL,进程重启不会丢失)。 - ppobs 推流的同时打开
/v1/p2p/signal的 WS,在协调器里登记为这条流的 publisher。 - pplayer 发起
POST /v1/play/requests(带着自己刚拿到的probeId)。 - ppcenter 跑
EvaluateDirectEligibility硬性预筛选(见 §2.1);全部通过后,用一把全局互斥锁做“查询名额 + 占用名额”的原子操作(AllocateEligible,maxPublisherSessions默认 3),分配成功才继续。 - 返回
mode=p2p-connect,同时带着这次信令要用的 token 和一个已经签好的 Edge 兜底地址——不需要客户端二次请求。 - 双方各自打开
/v1/p2p/signal,经 ppcenter 转发 Offer / Answer / ICE candidate。 - 双方各自拿着步骤 1 里探测到的 srflx 候选,发起真正的 ICE connectivity check——这一步才是“穿透”真正发生的地方。
- 成功:媒体(DTLS-SRTP 加密)直接在 ppobs ↔ pplayer 之间传输,完全不经过 ppcenter 或任何 mmx 节点;失败或超时:改用步骤 6 里已经拿在手上的 Edge 地址,顺序回退,不是并行竞速。
4. PPCDN 对 P2P 的额外支持:SEI 传递时间戳,把直连时延变成看得见的数字¶
4.1 时延数字从哪来:码流内嵌时间戳,不是估出来的¶
ppobs 启动时对公网 NTP 服务器(time.cloudflare.com、time.windows.com、pool.ntp.org)做一次简化 SNTP 对时,取 RTT 最小的样本,之后每 30 分钟重新校准一次。每一帧视频(不只是关键帧)都会打点:固定 16 字节 UUID("OBS-ABSTS-SEI-v1")+ 8 字节大端毫秒时间戳,封装成 H.264 的 SEI NAL(或 H.265 Suffix SEI / AV1 metadata OBU),紧跟在这一帧的编码数据之后。
这份时间戳走的是码流本身,不是旁路协议——WHIP、SRT、ERTMP 任何推流方式都统一生效;P2P 直连和 Edge 转发路径本来就是端到端的原始 RTP/NAL 透传(不解码、不转码),时间戳原样穿过,不需要为 P2P 单独协商一条通道。
4.2 播放端怎么把时间戳变成“P2P Delay”这一行数字¶
pplayer 用 WebCodecs 的 Insertable Streams(RTCRtpReceiver.createEncodedStreams())在不打断正常解码的前提下,从收到的编码帧里原地扫描这段 SEI,取出打点时的时间戳。播放端是浏览器,拿不到真正的 NTP,所以会先对 ppcenter 做一次应用层时钟 offset 校准(与 NTP 同款的四时间戳算法:TIME_SYNC / TIME_SYNC_ACK),用校准后的本地时间减去 SEI 时间戳,才是这一帧可信的端到端时延。
播放面板上的“P2P Delay”这一行,遵循一个明确的优先级:有新鲜、数值合理的 SEI 实测值时,直接显示“XX ms (SEI)”;暂时没有(刚连上的冷启动窗口,或浏览器不支持 Insertable Streams)就退回“RTT/2 + 抖动缓冲”的估算值,并明确标成“~XX ms (est.)”——两者绝不会被混在一起,避免把粗略估算误当成实测。这套机制对 P2P 和 Edge 两条路径同样生效,目前课程里反复引用的实测基线 70ms,正是从这一行“P2P Delay (SEI)”读出来的,不是理论推算。
一个诚实的边界:这条可视化链路本身也在持续演进——P2P 路径的 SEI 读取曾经因为个别 Chrome 版本在挂上 Insertable Streams 之后会卡死解码,被临时默认关闭过一段时间(只能靠未对外文档化的调试开关手动打开);后来靠一个专门的探测器(连续 3 秒解码帧数恒为 0 就判定卡死、自动回退 Edge)根治了这个问题,才恢复默认开启。这提醒我们:把时延变得“看得见”本身也是一项需要持续验收的功能,不是一次性做完就不会退化。
5. 框图¶
5.1 直播 vs 1v1 iSlot:规模经济在哪里失效¶
flowchart LR
subgraph L["直播:1 推 N 看"]
direction LR
P1["推流端"] --> E1["Edge 分发"] --> V1["观众 1…N<br/>复用同一份编码,边际成本被摊薄"]
end
subgraph R["1v1 iSlot:1 推 1 看 × 房间数"]
direction LR
P2["房间 1 推流端"] --> E2["各自一路独立转发"] --> V2["房间 1 的那一个观众"]
P3["房间 2 推流端"] --> E3["各自一路独立转发"] --> V3["房间 2 的那一个观众"]
end
5.2 预筛选在前、ICE 在后:穿透率为什么高¶
flowchart TD
A["播放请求到达(带 NAT 探测结果)"] --> B{"EvaluateDirectEligibility 预筛选<br/>mapping 稳定?UDP 可用?地址族匹配?能力兼容?…"}
B -->|任意一条不满足| C["edge-only<br/>(从未发起 STUN/ICE,不计入“穿透尝试”)"]
B -->|全部满足| D["进入 ICE:真正发起 STUN Binding + connectivity check"]
D -->|成功(常态)| E["P2P 直连建立"]
D -->|失败/超时(少数,如未识别的 symmetric NAT)| F["顺序回退到已拿在手的 Edge 地址"]
5.3 一次 P2P 直连的完整时序¶
sequenceDiagram
participant O as ppobs(推流端)
participant S as ppcenter(STUN :3478 + 信令中继)
participant Pl as pplayer(观看端)
O->>S: UDP STUN Binding Request
S-->>O: Binding Success(srflx 地址)
O->>S: POST /v1/nat/probe + WS /v1/p2p/signal(登记为 publisher)
Pl->>S: UDP STUN Binding Request
S-->>Pl: Binding Success(srflx 地址)
Pl->>S: POST /v1/nat/probe
Pl->>S: POST /v1/play/requests(带 probeId)
S->>S: EvaluateDirectEligibility 预筛选 + 原子分配名额(上限 3)
S-->>Pl: mode=p2p-connect(信令 token + 已签好的 Edge 兜底地址)
Pl->>S: WS /v1/p2p/signal:Offer
S->>O: 转发 Offer
O->>S: Answer / ICE candidate
S->>Pl: 转发 Answer / ICE candidate
Note over O,Pl: 双方各自用 srflx 候选发起 ICE connectivity check
O->>Pl: 成功 → 媒体直连(DTLS-SRTP,不经 ppcenter / mmx)
5.4 SEI 时延测量链路¶
flowchart LR
A["ppobs:NTP 对时<br/>取 RTT 最小样本,30 分钟重校准"] --> B["每帧编码后<br/>SEI/OBU 内嵌<br/>UUID + 时间戳(ms)"]
B --> C["推流(WHIP/SRT/ERTMP)<br/>服务端原样透传,不解码"]
C --> D["P2P 直连 或 Edge WHEP<br/>码流原样到达 pplayer"]
D --> E["pplayer:Insertable Streams<br/>原地扫描 SEI,取出 timestamp"]
F["pplayer 先对 ppcenter 做一次<br/>TIME_SYNC 时钟 offset 校准"] --> G["corrected_now − timestamp<br/>= 端到端时延"]
E --> G
G --> H["面板显示<br/>“P2P Delay: XX ms (SEI)”"]
6. 结尾与下集预告(逐字稿要点)¶
这一集回答了四个问题:为什么要给直播加一条 P2P 直连——1v1 iSlot 这类“一人一独享流”的场景,没有直播靠一份编码摊薄成本的规模效应,P2P 直连能让命中的那一路出口带宽直接归零,还恰好是时延最低的路径(实测约 70ms),但它仍是单向 P2P 的 N=1 特例,不是双向视频通话。P2P 的穿透表现为什么看起来很高——不是所有 NAT 都能穿透,是服务端的硬性预筛选只放兼容的 NAT 组合进 ICE 阶段,真正发起穿透尝试的连接本来就是筛过的,这也是为什么选择不部署 TURN:对称 NAT/CGNAT 用户接 TURN 相对于直接走 Edge 并没有带宽节省收益。一次直连具体怎么建立——ppcenter 身兼一个只认 Binding 消息的极简 STUN server,和一条只转发不碰媒体的信令中继,ppobs 和 pplayer 各自探测、各自发起 ICE,成功后媒体完全绕开服务端。最后,PPCDN 怎么让这一切看得见——码流里逐帧内嵌 SEI 时间戳,播放端对 ppcenter 做一次时钟校准后相减,得到的才是这一集反复引用的那个“70ms”。
下一集,我们讲节点池管理:为什么“角色 + 区域”两维不够用,怎么按用户、租户或 app 建立资源边界,以及主池没有可用候选时为什么默认不回退。