跳转至

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

在 YouTube 打开

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

0. 本集目标

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

  1. 说清"弱网"到底是什么,以及它沿着"上行推流"和"下行播放"两条链路分别会造成什么连锁影响——弱网不是单一指标,而是一组多维劣化。
  2. 说清 ERTMP / SRT / WHIP 三种推流协议在弱网下的表现差异:它们各自的丢包恢复机制、延迟代价和适用边界,而不是背"TCP 不好、UDP 好"这种粗线条结论。
  3. 理解 PPCDN 对抗弱网的分层设计:检测、降级、恢复、重连、优先级保护各解决什么问题,以及哪些是生产事实、哪些还是已知差距。

1. 弱网的定义和对直播的影响

1.1 什么算"弱网":一组多维劣化,不是单一指标

"弱网"不是一个可以一句话定义清楚的状态,工程上通常由几种同时发生或交替出现的劣化共同刻画:

  • 丢包:分组在网络中被丢弃或延迟到无法按时使用;既有随机丢包,也有突发成簇的丢包。
  • 抖动(jitter):分组到达间隔不均匀,即使平均速率够、到达时刻仍可能忽快忽慢。
  • 带宽不足:可持续吞吐低于编码目标码率,发送队列被迫积压。
  • 时延与乱序:端到端 RTT 升高,分组乱序到达,重排需要缓存。

这几种劣化还要再分两个维度看,否则讨论会失焦:

  • 上行与下行不对称:推流上行(编码器 → 源站)和播放下行(源站 → 观众)的网络条件、受影响对象完全不同,不能用同一组阈值。
  • 瞬时尖峰与持续劣化:一次几秒的抖动和持续几分钟的劣化,触发的是完全不同的应对动作(扛过去 vs 主动降级)。

因此,没有一个放之四海而皆准的"弱网阈值"。采样窗口、升降级阈值、连续命中次数等都应是按协议、方向、内容档位和终端能力配置的产品参数,而不是协议常数,也不能假定不同协议共用一组值。

1.2 弱网对直播的连锁影响

弱网的影响沿两条链路传导,且会逐级放大:

  • 上行(推流)链路:上行带宽不足或丢包 → 发送队列积压 → 编码器被迫下调码率、发生卡顿 → 丢包重传补不回来 → 关键帧/同播层丢失 → 观众侧画面花屏、马赛克甚至推流中断。
  • 下行(播放)链路:观众侧带宽/丢包恶化 → 起播变慢、接收缓冲被耗尽 → 播放卡顿、ABR 降到更低档 → 为等待恢复,播放时延被拉长、漂移。

对互动直播而言,"时延漂移"尤其敏感:画面本身也许还在播,但延迟从几十毫秒漂到几百毫秒,互动体验已经破坏——这正是低延迟场景比普通点播更怕弱网的原因。

1.3 为什么"丢包就降级"不够

一句"丢包就降级"听起来简单,落地时却必须先回答几个问题:

  • 这个 counter 统计的是序列号缺口、重传请求、重传包,还是超过恢复期限后的媒体损失?不同协议的 counter 口径并不相同,不能硬套成同一个指标。
  • 这个丢包后来是否被重传/FEC 补回来并赶上了解码期限?只看原始丢包率,会在链路实际健康时误降。
  • 这次恶化作用在上行发布(影响整路流、所有观众)还是下行 ABR(只影响某一个观众会话)?

2. ERTMP / SRT / WHIP 的弱网表现

2.1 RTMP / ERTMP:TCP 的确定性代价

RTMP 通常建立在持久 TCP 连接上,消息被切成 chunk 在多个 chunk stream 上复用。它的弱网表现由 TCP 语义决定:

  • 队头阻塞:任何一个 TCP 分节丢失,后面已经到达的数据都必须排队等它重传完成,等待时间会随丢包累积。这是 RTMP 在移动/弱网场景下的硬伤。
  • ERTMP 多轨只补 ABR,不解决延迟:Enhanced RTMP 允许一条 RTMP 连接传多 track 视频(OBS Enhanced Broadcasting 的多档画质),但底层仍是 TCP。多轨的总码率是各档之和,全部挤在同一条 TCP 连接上,上行反而更容易拥塞、延迟更差。
  • 定位:RTMP/ERTMP 是"兼容存量推流设备、给它们 ABR"的能力,不承诺低延迟。想要低延迟 + ABR,应当走 SRT/WHIP。这不影响 ERTMP 在成本侧的价值:ppobs 通过 WHIP、SRT、ERTMP 三种协议都能同播 HEVC + H264 多轨,省云端转码费这一点三者相同(见 EP20),差别只在入库时延与弱网表现。

2.2 SRT:用延迟换鲁棒

SRT 建立在 UDP 之上,面向不稳定网络中的低时延可靠传输。理解它的弱网表现要把三个机制分开:

  • ARQ(自动重传请求):接收端根据序列号发现缺包并发 NAK,发送端在数据仍有时效时重传——解决"怎么恢复丢包"。
  • TSBPD(基于时间的分组交付):依据发送时间戳和协商的 latency 安排分组何时交付,吸收网络抖动、恢复发送时序——解决"何时交付"。
  • TLPKTDROP(过期包丢弃):启用并满足条件时,放弃已来不及按计划交付的数据——解决"何时停止补救",让收发两端跳过过期包。

latency 给重传和抖动吸收提供时间预算:调大通常提高恢复机会、增加交付等待;调小则相反。但它是工程预算,不是整个 SRT 会话的严格数学时延上界——握手、网络排队、应用缓冲、断线重连都在它之外。SRT 还可配置基于 AES 的 payload 加密,但它保护的是传输链路,不自动等同于跨所有中间节点的端到端加密。

2.3 WHIP:WebRTC/RTP 的动态反馈

WHIP 不是全新协议,而是给 WebRTC 补了一套标准化的推流签约流程(HTTP 完成 SDP offer/answer)。媒体仍走 WebRTC 栈:ICE 打通路径、DTLS-SRTP 加密媒体、RTP 承载媒体。它的弱网恢复靠:

  • NACK(选择性重传)与可选 FEC(前向纠错) 处理丢包。
  • jitter buffer(接收/重排缓冲) 吸收乱序、决定等待时长。
  • 拥塞控制(如 GCC) 动态调整发送码率。

WHIP 没有 SRT 的 TSBPD,接收端维护的是按包数或字节数计的缓冲;包缓存数量不等于时间窗口——同样 512 个包,在不同码率、包长、帧率下覆盖的时间完全不同。WHIP 已正式发布为 RFC 9725(2025)。

一个现实:WHIP 走 UDP/ICE 路径,跨国或质量不稳的网络上会出现间歇性 ICE 心跳超时断连,OBS 侧表现为 Disconnected 后自动重连(实测曾出现约每小时 2 次、重连后十余秒再次断开的情况)。这通常是 UDP 路径波动导致的网络质量问题,不是代码 bug——它也是 SRT 与 WHIP 在本项目里并存、按场景取舍的直接原因之一。

2.4 三种协议的弱网表现对比

维度 RTMP / ERTMP SRT WHIP
传输层 TCP UDP + ARQ UDP + RTP(NACK / FEC / 拥塞控制)
丢包处理 TCP 必须重传并按序交付,可能队头阻塞 ARQ 恢复;TSBPD 定时交付;过期包丢弃可跳过迟到数据 NACK/FEC 依协商与实现;jitter buffer + 解码截止决定是否等待
延迟边界 无严格数学上界,弱网下会累积 latency 是工程预算,非无条件上界 动态缓冲 + 拥塞控制,无无条件上界
典型弱网表现 队头阻塞、重连慢、延迟漂移;多轨更易拥塞 以延迟换鲁棒,恢复靠 NAK,配置得当较稳 UDP 路径波动会间歇性断连;靠 NACK/FEC/jitter buffer 恢复
加密 RTMP 本身无加密;RTMPS 用 TLS 可配置 AES payload 加密 强制 DTLS-SRTP 链路加密
定位 存量兼容入口,不承诺低延迟 弱网上行的现代选择 主流实时推流/播放,低延迟优先

3. PPCDN 对抗弱网的设计

3.1 总览:分层设计 + 分方向控制

PPCDN 的弱网抗性不是某一个开关,而是一套分层工程:检测 → 降级 → 恢复 → 重连 → 优先级保护。其中最容易被混为一谈的是两个作用域不同的控制环:

  • 上行发布降级:发生在编码器到 Origin 的链路。发送端可降低目标码率、帧率或分辨率,暂停高层,必要时重新协商。上行异常影响所有观众,动作应更保守。
  • 下行播放 ABR:发生在 Origin/Edge/P2P 到某个观众的链路。播放器根据该观众的 goodput、RTT、丢包、缓冲与解码状态选择已有层,不应因为一个观众弱网去改变全局发布质量。

两条控制环可以共享观测体系,但作用对象、阈值和影响范围不同——这一点是后面所有具体动作的前提。

3.2 统一信号:不可恢复丢包率(ULR)

延续 §1.3 的结论——原始 counter 不能直接改名叫 ULR,只有接收端能把"原包身份 + 重传结果 + 播放期限"关联起来,才算得出 ULR。ppmmx 在两种协议上各自实现了这层关联:

  • SRT:ULR = (累计丢包 - 累计重传成功数) / 期望收到的包数,即 PktRecvLoss - PktRecvRetrans(internal/servers/srt/conn.go 的 unrecoverableAccumulator)。
  • WHIP(RTP):mmx 的 WebRTC 接收端在一个固定大小的重排/重传缓冲区(WebRTCInboundRTPBufferSize,默认 512 个包)内吸收乱序和 NACK 重传;迟到但落在窗口内的重传包会被补回、不计入丢包,只有真正超出窗口、补不回来的包才计入 RTPPacketsLost。因此 RTPPacketsLost / (RTPPacketsLost + RTPPacketsReceived) 本身就已经是 WHIP 侧的 ULR(internal/servers/webrtc/session.go 的 runDegradeSampling),与 SRT 的算法语义等价。

两路信号各自每 degradeSampleSec(默认 6 秒)采样一次,喂给同一个按 path 维护状态的降级状态机。统一的是决策信号和动作规范,不是原始计数器——采样区间、分母、重传去重和计数器重置语义都必须先对齐。

3.3 两段降级阶梯:先压码率,扛不住才砍层

降级不是"一超阈值就重连",而是分两段、层层加码(设计见 docs/design/publish-degrade-protocol.zh-CN.md):

  1. 码率阶段(零中断):ULR 一旦超过 degradeRaisePct(默认 0.8%),立即把编码器码率降一档(100%→80%→60%),obs_encoder_update 实时生效,不中断推流。
  2. 分辨率阶段(~1s 中断):码率已经降到地板(60%)之后,如果 ULR 还超过一个更高的门槛 degradeRestartPct(默认 1.6%),才会砍掉最高分辨率的同播层——这一步需要重启推流才能让新层数生效,造成约 1 秒中断,并且每次砍层时顺带调整接收缓冲(见 §3.4)。

也就是说"超过门槛才重连"里的门槛,不是触发降级的 0.8%,而是单独更高的 1.6%:丢包中等程度时只压码率、不断连;只有丢包严重到码率已经压不住,才值得付重连的代价。每次状态转移后进入 degradeObservationSec(默认 60s)冷却;恢复方向(ULR 回落到 degradeLowerPct,默认 0.3%,并持续整个冷却期)则反向逐级加回——先回码率、再加层,从低分辨率层开始加回。degradeLowerPct < degradeRaisePct 的滞回/死区强制要求,正是为了避免网络在临界点附近反复升降档。

stateDiagram-v2
    state "正常" as Normal
    state "码率降档(100%→80%→60%,实时生效)" as RateDrop
    state "砍最高分辨率层 + 重启推流(约 1s 中断)" as CutLayer
    state "冷却观测(60s)" as Cooldown
    [*] --> Normal
    Normal --> RateDrop: ULR > 0.8%(degradeRaisePct)
    RateDrop --> RateDrop: 仍 > 0.8%,继续降档直到 60% 地板
    RateDrop --> CutLayer: 已到 60% 地板且 ULR > 1.6%(degradeRestartPct)
    CutLayer --> Cooldown: 状态转移后
    RateDrop --> Cooldown: 状态转移后
    Cooldown --> Normal: ULR < 0.3%(degradeLowerPct)并持续整个冷却期,反向逐级加回
    Cooldown --> RateDrop: 冷却期内仍超阈值

3.4 门槛重连时,顺带把接收延迟抬高一步

触发分辨率阶段(即将重启推流)的同一个事件,会把该 path 的接收窗口同步调大一步——"抬延迟"必须靠重连才能生效,这是协议本身的限制,不是设计上的疏漏:

  • SRT:接收延迟(TSBPD latency)是握手时协商、连接生命周期内不可变的参数(gosrt 的 Conn 接口没有运行时 setter),新值只能靠"断开连接→推流端重新握手"生效。这里的重连因此身兼两件事:既让推流端感知新的层数,也顺带把抬高后的延迟带上。延迟按 degradeRaiseLatencyStep(默认 200ms)加、degradeLowerLatencyStep(默认 100ms)减,clamp 在 [degradeLatencyMin, degradeLatencyMax](默认 300ms ~ 3000ms)。
  • WHIP:mmx 的 WebRTC 接收端用的重排/重传缓冲区和 SRT 接收延迟是同一类限制——创建时定长,生命周期内不可变。但 WHIP 没有固定码率,没法把毫秒步长可靠换算成包数,所以单独维护一个按 path 调节的包数:命中分辨率阶段就把该 path 的缓冲区翻倍,命中恢复事件就减半,上下界 webrtcBufferMin/webrtcBufferMax(默认 64~4096 包),且强制是 2 的幂(gortsplib 环形缓冲区用 & (长度-1) 取索引)。WHIP 不需要额外主动断连——分辨率阶段本身已经让 OBS 重启推流,新缓冲区大小搭这班车自然生效。

一个意外发现:webrtcInboundRTPBufferSize 本来就没真正接上过

排查这套自适应机制时顺带挖出一个更底层的问题:ppmmx 配置里 webrtcInboundRTPBufferSize 的默认值(512 包)压根没有传到 WHIP 推流实际创建的 rtpreceiver.Receiver 上——构造 PeerConnection 的代码漏填了这个字段,导致每一路 WHIP 推流一直悄悄跑在 gortsplib 自己的内置默认值(64 包)上,只有配置值的 1/8。它和"缓冲区按路径翻倍/减半"是两个独立问题,已一并修复。

3.5 主推流优先,P2P 是第一个被牺牲的

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

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

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

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

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

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

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

这两条原理在当前代码里的落地程度按链路拆开看:

  • 节点↔控制面的控制连接(ppmmx 连 ppcenter 的心跳/控制 WebSocket,ppmmx/internal/mmxcontrol/client.go)实现了完整的有界指数退避 + 抖动:初始 1 秒,每次失败后翻倍,封顶 30 秒,并叠加最多到其一半大小的随机抖动。
  • pplayer 侧的两条媒体重连已经补上抖动,但没有做指数退避:WHEP 读取器(ppplayer.mjs 的 MediaMTXWebRTCReader.retryPause,基准 2 秒)和 ABR 控制 WebSocket(同文件 MMXControlClient.reconnectDelay,基准 3 秒)都改成了 jitteredDelay(base)——实际延迟是 [base, 2×base) 区间内的随机值。这两条正是"惊群"场景的主角;抖动能错峰,但指数退避还没做。
  • Edge 从 Origin 拉流失败的重连仍是定长间隔:固定等 5 秒重试(ppmmx/internal/staticsources/handler.go 的 retryPause)。这条链路的"惊群"风险还在,是个已知的、可继续优化的点。

节点自己扛:Edge 会缓存当前流对应的源站地址模板和凭据。如果 Edge 到 Origin 之间网络闪断,即使控制面(ppcenter)本身不可用,Edge 也只需本地重新解析已缓存的 source 配置再用缓存凭据直接连源站——控制面在不在线不影响这次重连。这意味着"控制面短暂故障"和"媒体链路中断"可以完全解耦。

已知边界:如果源站真的故障了(不是网络闪断,而是节点本身挂了),推流端需要重新发布到一个新的源站,这段切换会有可感知的中断——当前阶段不承诺跨源站的无缝切换,避免夸大成"什么故障都能无感恢复"。


4. 框图

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

flowchart TD
    A["SRT 原生统计"] --> C["按协议解释 + 滚动窗口/连续命中/迟滞<br/>(均为产品参数)"]
    B["RTP/RTCP/RTX"] --> C
    C --> D["RTT / 队列 / goodput / 缓冲等联合判断"]
    D --> E["上行发布控制<br/>调码率/帧率/发布层<br/>影响整路流,策略更保守"]
    D --> F["下行会话 ABR<br/>为单个观众选择已有层<br/>不改变其他观众的发布质量"]

4.2 不可恢复丢包率 → 两段降级 → 门槛重连时顺带抬延迟

flowchart TD
    A["SRT:累计丢包 − 累计重传成功 = 不可恢复丢包"] --> C["每 6 秒采样一次<br/>ULR = 不可恢复丢包 / 期望收到的包数"]
    B["WHIP:RTPPacketsLost<br/>(重排缓冲区吸收不了的部分)"] --> C
    C --> D{"ULR > 0.8%(degradeRaisePct)?"}
    D -->|否| E["不动作"]
    D -->|是| F["码率降一档<br/>(100%→80%→60%,实时生效,不中断)"]
    F --> G{"码率已到 60% 地板,且<br/>ULR > 1.6%(degradeRestartPct)?"}
    G -->|否| H["停在码率 60%(不重连)"]
    G -->|是| I["砍最高分辨率层 + 重启推流(约 1s 中断)"]
    I --> J["SRT:接收延迟 += 200ms(随这次重新握手生效)"]
    I --> K["WHIP:缓冲区包数 ×2(随下次会话生效)"]

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

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

4.4 抖动退避:从"惊群"到错峰恢复

策略 重试时间 结果
无抖动,统一退避曲线 所有客户端用同一套退避时间 网络刚恢复的瞬间集中重试 → 流量尖峰,可能二次打垮服务
指数退避 + 随机抖动 每个客户端在退避区间内加随机偏移:第 1 次分散在 t=0.8s~1.2s,第 2 次分散在 t=1.6s~2.4s 重试请求在时间上被打散,恢复过程平滑

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

这一集先把"弱网"讲成一个可操作的定义:它是丢包、抖动、带宽、时延与乱序的多维组合,还分上行/下行、瞬时/持续——没有统一阈值,参数必须按协议、方向、档位和终端校准。然后横向对比了三种推流协议:RTMP/ERTMP 受限于 TCP 队头阻塞,多轨只补 ABR、不解决延迟;SRT 用 ARQ + TSBPD + 过期包丢弃,以延迟换鲁棒;WHIP 用 NACK/FEC、jitter buffer 和拥塞控制动态恢复,但要在质量不稳的 UDP 路径上接受间歇性重连。最后落到 PPCDN 的分层设计:SRT 与 WHIP 统一到不可恢复丢包率(ULR)这一个决策信号,先压码率、扛不住才砍层重连,门槛重连时顺带抬高接收延迟(SRT 调 TSBPD、WHIP 翻倍缓冲区,并顺带修掉一个配置从未真正生效的 bug);上行侧主推流永远优先、P2P 随时可被本地即时摘除;重连本身用指数退避 + 抖动对抗惊群,Edge 还能在控制面不在线时自治重连。哪些是生产事实、哪些还是已知差距,这一集也刻意留清楚了。

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