跳转至

EP07:如何增强直播弱网抗性

上集回顾 EP06《推拉流安全:签名、Token 与防盗链实战》
下集预告 EP08《视频埋点:如何量出端到端延迟》

0. 本集目标

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

  1. 说清楚 SRT / RTP 原生统计分别代表什么,以及产品如何把多种信号组合成降级条件——不能把不同协议的 counter 硬套成同一个"不可恢复丢包率"。
  2. 理解"弱网抗性"是一套分层的工程设计,检测和降级只是其中两层。
  3. 理解两个容易被忽略、但对真实弱网体验影响很大的机制:P2P 和主推流之间的抢占保护,以及"重连"这个动作本身怎么设计才不会在网络恢复的一瞬间把系统压垮。

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

"丢包了就降级"听起来简单,落地时却必须先回答:这个 counter 统计的是序列号缺口、重传请求、重传包,还是超过恢复期限后的媒体损失?SRT 和 RTP 的原生统计口径并不相同,产品需要分别解释,再把丢包、RTT、发送队列和可用带宽组合成可配置的动作条件。


2. 第一层:先解释协议 counter,再定义产品指标

2.1 SRT 与 RTP 原生统计不能简单统一成 ULR

SRT 库通常提供丢包、重传、丢弃、NAK、发送缓冲等统计,但各字段是发送端还是接收端视角、是包事件还是唯一包、是否包含重复重传,要以所用 SRT 版本的 API 定义为准。不能用"累计丢包减累计重传"推导未恢复包:一次丢失可能触发多次重传,counter 也未必属于同一统计域。

RTP/RTCP 的 cumulative packets lost、fraction lost 主要根据 RTP 序列号期望数与实收数计算。它们能反映接收缺口,但不会天然告诉应用"缺口后来是否经 RTX/FEC 恢复并赶上了解码期限"。NACK 次数、RTX 收包数、解码前丢弃和卡顿又是不同层次的信号。

因此产品层应保留协议原生指标名称与单位,并另行定义业务指标,例如"超过恢复/播放期限仍缺失的媒体单元比例"。只有接收端能够把原包身份、RTX/FEC 恢复结果和播放期限关联起来时,才能计算这种派生指标;不要把任意原始 counter 直接改名为 ULR。

2.2 阈值与采样:它们是产品参数,不是协议常数

采样窗口、降级阈值、恢复阈值、连续命中次数和冷却时间都应是按协议、方向、内容档位和终端能力配置的产品参数,不是 SRT 或 RTP 标准规定的常数,也不应假定两种协议共用一组值。上线值需要由压测和真实网络数据校准,并随版本记录。

设计上通常采用滚动窗口、连续命中与迟滞:恶化阈值高于恢复阈值,降级可以快,恢复应更谨慎。这样能过滤单次尖峰,避免网络在临界点附近时反复升降档。培训示意图只写参数名,不把某组实验值讲成通用阈值。

2.3 先区分上行发布降级与下行播放 ABR

  • 上行发布降级发生在编码器到 Origin 的链路。发送端可降低目标码率、帧率或分辨率,暂停高层,必要时重新协商;具体动作是否无缝、是否需要关键帧或重建会话,取决于编码器和协议实现,不能统一承诺"零代价"或固定中断时长。
  • 下行播放 ABR发生在 Origin/Edge/P2P 到某个观众的链路。播放器根据该观众的 goodput、RTT、丢包、接收/播放缓冲和解码状态选择已有层,不应因为一个观众的弱网去改变全局发布质量。

两条控制环可以共享观测体系,但作用对象、阈值和影响范围不同。上行异常影响所有观众,动作应更保守;下行 ABR 是每个会话独立决策,通常优先切到较低的已有层。


3. 第二层:Simulcast 多档画质,检测结果落在哪一层上

EP05 讲过 Simulcast 的分层机制。这里需要补上控制域:发布端可以根据整体上行预算调整它发送的层;播放端则按会话选择已经发布的层。切换应选在接收端可独立解码的切换点,并结合关键帧请求、依赖关系和缓冲状态执行。检测模块输出的是带置信度的网络状态,策略模块再决定作用于哪条链路、哪一层以及何时切换。


4. 第三层:主推流优先,P2P 是第一个被牺牲的

推流端的上行带宽是有限的,一旦同时承担"发给源站的主推流"和"发给多个观众的 P2P 直连",弱网下这两者会互相争抢带宽。这里的设计原则很直接:主推流的画质永远是第一优先级,P2P 是随时可以被牺牲的附加项。具体落地成三条硬约束:

  • 独立的发送队列和带宽预算:主推流(Origin WHIP)和所有 P2P 输出各自维护自己的发送队列、拥塞控制状态和 pacing,物理上隔离,P2P 的发送逻辑不会挤占主推流的调度时机。
  • 绝对优先级,不是"公平分享":主推流的队列和带宽预算始终排在 P2P 前面——不是"大家平分带宽",是"主推流先吃饱,P2P 才能用剩下的"。
  • 主动监测 + 主动摘除:推流端持续监测本地上行状况,一旦达到拥塞阈值,立即终止一个或多个 P2P 连接,不等待任何中心指令——网络恢复后也不会自动把 P2P 抢回来,必须重新向调度中心申请名额。

为什么"不等中心指令"这一点很重要:如果要先上报给控制面、等控制面下发"请断开 P2P"的指令,这个来回本身在弱网下就是不确定的延迟。摘除 P2P 这个动作被设计成推流端本地就能立即决策、立即执行,不依赖任何网络往返——这一点在代码里也能验证:实际摘除是推流端直接调用本地的信令发送(SendClose),不是先上报再等回包。

当前实现:判定信号只接了 RTT 一路

架构设计文档里设想的监测信号包括 RTT、丢包率、发送队列长度和可用码率(docs/design/ppcdn-architecture-design.zh-CN.md §6.4),但 ppobs 当前落地的判定逻辑(plugins/obs-webrtc/uplink-qos-policy.cpp)只接入了其中一路:上行 RTT 超过阈值即判定拥塞,默认阈值 maxRttMs=300ms,由 WHIPOutput::CheckUplinkQos 每 2 秒检查一次;一次默认摘除最新建立的 1 个 P2P 连接(maxPeersToDropAtOnce=1,可配置为摘除全部),之后进入 10 秒冷却期(cooldownMs=10000)才会重新评估。丢包率、发送队列长度、可用码率这几路信号还没有接进判定条件——这是设计意图和当前实现之间的一个已知差距,不应该被包装成"四路信号综合判断"已经全部落地。


5. 第四层:重连本身也要设计,否则恢复的那一刻就是第二次故障

网络闪断之后,客户端会尝试重连——但"怎么重连"这件事本身如果设计不好,反而会在网络刚恢复的瞬间制造新的问题。

5.1 退避策略:控制连接已经做了抖动,媒体重连目前还是定长间隔

"要不要做退避 + 抖动"背后的通用原理是:指数退避解决"别一直无脑立刻重试"——避免在网络还没真正恢复时反复打满已经不稳定的链路;抖动解决另一个问题——如果同一时刻断开的成千上万个客户端都严格按同样的曲线在同样的时间点重试,网络刚恢复的那一刻所有请求会同时砸过来,这叫"惊群效应"(thundering herd),恢复的瞬间反而制造一次流量尖峰。加一点随机抖动,能把这些重试请求在时间上错开。

但这两条原理在当前代码里只有一部分真正落地,不宜笼统地说"全部重连都做了带抖动的指数退避":

  • 节点↔控制面的控制连接(ppmmx 连 ppcenter 的心跳/控制 WebSocket,ppmmx/internal/mmxcontrol/client.go)确实实现了完整的有界指数退避 + 抖动:初始等待 1 秒,每次失败后翻倍,封顶 30 秒,并在当前退避时长的基础上叠加一个最多到其一半大小的随机抖动。
  • 媒体链路的重连目前都是定长间隔,没有指数增长、也没有抖动:Edge 从 Origin 拉流失败后固定等 5 秒重试(ppmmx/internal/staticsources/handler.go 的 retryPause,这是所有静态源类型共用的同一个常量);播放器侧的 WHEP 读取器固定等 2 秒(pplayer/ppplayer.mjs 的 MediaMTXWebRTCReader.retryPause),ABR 控制 WebSocket 固定等 3 秒(同文件 reconnectDelay)。

这不代表"惊群"风险不存在——如果一个 Edge 节点大规模掉线,连在它上面的所有播放器确实会在差不多同一时刻尝试重连——只是这部分重连目前还没有接入抖动,是一个已知的、可以继续优化的点。下面的框图(6.3)展示的是"带抖动的指数退避"这套通用机制本身,当前只在控制连接上完整生效。

5.2 节点自己扛:控制面不在线也能重连

Edge 节点会缓存当前流对应的源站地址模板和凭据。如果 Edge 到 Origin 之间网络闪断,即使这时候控制面(ppcenter)本身也不可用,Edge 也不需要等控制面恢复——重连时只是本地重新解析一次已缓存的 source 配置(staticsources 包的 resolveSource,纯字符串替换,不发任何请求),再用缓存的凭据直接连源站,因此控制面在不在线不影响这次重连。按上一节的结论,当前这个重连循环走的是固定 5 秒间隔,不是指数退避。只有在重连持续失败、确认源站真的故障,或者上层判定需要终止时,才会真正终止并上报。

这意味着"控制面短暂故障"和"媒体链路中断"是两件可以完全解耦的事:只要缓存的凭据还没过期、源站还活着,控制面这段时间在不在线,观众体感上可能完全无感。

5.3 诚实的边界:这不是"无缝切换"

如果源站真的故障了(不是网络闪断,是节点本身挂了),推流端需要重新发布到一个新的源站,之后的播放请求才能用上新链路——当前阶段不承诺跨源站的无缝切换,这段切换会有可感知的中断。这一点值得说清楚,避免夸大成"什么故障都能无感恢复"。


6. 框图

6.1 分协议观测与分方向控制

SRT 原生统计 ──┐
               ├──▶ 按协议解释 + 滚动窗口/连续命中/迟滞(均为产品参数)
RTP/RTCP/RTX ──┘                         │
                                        ▼
                              RTT / 队列 / goodput / 缓冲等联合判断
                                        │
                         ┌──────────────┴──────────────┐
                         ▼                             ▼
                 上行发布控制                    下行会话 ABR
            调码率/帧率/发布层              为单个观众选择已有层
            影响整路流,策略更保守            不改变其他观众的发布质量

6.2 拥塞检测 → 主动摘除 P2P → 恢复后重新申请

推流端每 2 秒检查一次:本地上行 RTT(当前唯一接入的信号,设计目标还包括丢包率/队列长度/可用码率)
        │
        ▼
   RTT ≥ 阈值(默认 300ms)?
        │
   ┌────┴────┐
   否         是
   │          │
 继续         ▼
 正常     立即终止 1 个或多个 P2P 连接(本地决策,不等中心指令)
 服务          │
               ▼
        主推流带宽预算恢复保障
               │
               ▼
        上报中心:P2P 名额已释放;进入 10 秒冷却期
               │
               ▼
        网络恢复后,不自动抢占 —— 必须重新向调度中心申请新的 P2P 名额

6.3 抖动退避:从"惊群"到错峰恢复(当前只在节点↔控制面的连接上完整生效,见 §5.1)

【无抖动,统一退避曲线】
故障发生
   │
   ▼ (所有客户端用同一套退避时间)
第 1 次重试:全部客户端同时在 t=1s 发起
第 2 次重试:全部客户端同时在 t=2s 发起
   │
   ▼
网络刚恢复的瞬间,所有客户端在同一时刻集中重试 → 流量尖峰,可能二次打垮服务

【指数退避 + 随机抖动】
故障发生
   │
   ▼ (每个客户端在退避区间内加一个随机偏移)
第 1 次重试:客户端分散在 t=0.8s ~ 1.2s 之间发起
第 2 次重试:客户端分散在 t=1.6s ~ 2.4s 之间发起
   │
   ▼
重试请求在时间上被打散 → 恢复过程平滑,没有集中冲击

6.4 Edge 自治重连:控制面不在线时怎么办

这条路径已确认实现(docs/ppcdn-development-progress.zh-CN.md §3.5,状态"已完成",新增接口 POST /internal/mmx/v1/origin/resolve)——不是只停在架构设计文档的 MUST 需求列表里。

Edge ←→ Origin 网络闪断
        │
        ▼
Edge 向 ppcenter 动态解析 Origin 地址 + 短期媒体凭据,解析结果缓存在内存
ppcenter 不可用时 → 复用未过期缓存继续重连(凭据过期后禁止继续使用)
        │
   ┌────┴────┐
  连接成功      持续失败 / Origin 确认故障 / 缓存已过期
   │              │
   ▼              ▼
观众侧基本无感   终止 reader,向上报告
(固定 5 秒
 间隔重试,
 节点控制凭据与
 Origin 媒体凭据
 相互独立)

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

这一集先划清了统计口径:SRT 与 RTP 的原生 counter 不能靠一条减法统一成 ULR;采样窗口和升降级阈值都是需要实测校准、按版本管理的产品参数。控制上还要区分影响整路流的上行发布降级和每个观众独立的下行 ABR。再加上主推流对 P2P 的抢占保护(当前只接了 RTT 一路判定信号)、节点控制连接的抖动退避与节点自治重连(媒体链路目前是定长间隔重试),构成了当前版本的弱网抗性设计——也刻意留出了哪些地方还是设计意图、哪些地方已经是代码事实。

下一集,我们换个角度——不讲怎么应对弱网,讲怎么把"到底有多少延迟"这件事量出来:视频埋点和端到端延迟的测量方法。