跳转至

EP08:视频埋点:如何量出端到端延迟

在 YouTube 打开

上集回顾 EP07《如何增强直播弱网抗性》
下集预告 EP09《时延稳定性,比平均延迟更重要》

0. 本集目标

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

  1. 理解"端到端延迟"这个数字为什么不能简单地用"现在时间减去时间戳"算出来,以及浏览器测延迟这件事到底难在哪。
  2. 知道除了延迟之外,播放体验还需要另外三个埋点指标:视频加载时间、连接成功率、卡顿率——以及它们分别是怎么上报和统计出来的。

1. 问题:这道减法题,在浏览器里做不对

1.1 时间戳怎么打进画面里

ppobs 给每一帧视频都打点(不是只在关键帧才打),H.264 封装成一条 user_data_unregistered SEI、HEVC 封装成 Suffix SEI、AV1 封装成 metadata OBU,payload 固定是 16 字节 UUID("OBS-ABSTS-SEI-v1" 的 ASCII 编码,抓包时能直接肉眼识别)加 8 字节大端时间戳,一共 24 字节——只有时间戳,没有显式的帧编号。原因是这段数据物理嵌在这一帧自己的码流里随帧一起传输,播放端解出 SEI 的那一刻天然就知道"这个时间戳属于我正在解的这一帧",不需要额外的 ID 去做关联。

这个时间戳打的是哪个时刻,也值得说清楚:不是编码器拿到原始画面的那一刻——ppobs 内部其实有一个更接近采集时刻的 pkt_time,但打点逻辑收到这个参数后直接弃用——而是编码完成、即将通过推流发送出去的那一刻。也就是说编码器本身的处理耗时,会被悄悄算进"端到端延迟"这个数字里,而不是被排除在外。这不是哪里做错了,是这套测量口径天然的边界:它量的是"编码产出到渲染显示",不是严格意义上的"镜头前到屏幕上",引用这个数字时值得说清楚。

时间戳只走这一条码流内嵌的路径,不再有任何协议专属的旁路通道:早期版本里 WHIP 推流曾额外广播同一时间戳到一个 "obs-timestamp" WebRTC DataChannel,但 DataChannel 是 WebRTC/WHIP 独有的能力,RTMP/ERTMP、SRT 推流都没有对应的旁路可用,这条旁路已经被移除。服务端(不管接的是 RTMP、SRT 还是 WHIP)统一从各自协议自己的 NAL/OBU 解析流程里原地扫描这份 SEI/OBU,三种摄入协议共用同一套时间戳获取逻辑,不需要为每种协议单独维护一条旁路。

1.2 播放端的困境:没有 NTP,只有一个不知道偏多少的本地时钟

延迟样本应在目标帧实际提交显示的回调处生成,而不是在网络收包或解码完成时生成。概念上是 render_time(frame_id) - capture_time(frame_id)。浏览器通常只能使用操作系统墙钟和单调时钟;推流端与播放端即使都做了校时,仍存在 offset、漂移、校时精度和网络不对称误差。

ppobs 这一侧的"做了校时"其实是两层,优先用更准的那层,退无可退才用另一层:

  • 优先级高:和播放端对齐同一个 ppcenter 锚点。ppobs 推流时会另开一条 /ws/play WebSocket 连 ppcenter,跑的是下一节要讲的同一套四时间戳探测——协议和播放端一模一样({"type":"TIME_SYNC","t1":...} → {"type":"TIME_SYNC_ACK","t1":...,"t2":...,"t3":...}),每轮 6 次探测、间隔 150ms、取 RTT 最小的一次,每 10 分钟重新校准一次;只要这份 offset 还新鲜(20 分钟以内),就优先拿它来修正本地时钟。
  • 兜底:公网 NTP。只有从未连上过 ppcenter,或者连过但 offset 已经超过 20 分钟没刷新,才退回公网 NTP:启动时起一个后台线程轮询 time.cloudflare.com、time.windows.com、pool.ntp.org,每个服务器最多采 4 次样、取 RTT 最小的一次,RTT 一旦小于 30ms 就提前结束采样,此后每 30 分钟重新校准一次。只要曾经同步成功过一次,哪怕之后所有服务器都连不上,也会继续沿用上一次校出的 offset,而不是退回裸的系统时钟——只有从来没同步成功过(比如网络屏蔽了 UDP 123 端口)才会退回未经校准的本机墙钟。

实际直播场景里,10 分钟的刷新节奏比 20 分钟的"过期"窗口密,只要推流端能连上 ppcenter,真正生效的几乎总是第一层——也就是说推流端和播放端并不是"各自猜一个 UTC 再比较",而是两端各自独立地去够同一个锚点(ppcenter),公网 NTP 更多是开播瞬间或 ppcenter 一时连不上时的兜底。浏览器做不到查公网 NTP 这一层(拿不到 UDP 123),但浏览器能做、也确实在做的,正是和 ppobs 同款的"连 ppcenter 四时间戳探测",下一节详细讲。

为什么是 10 分钟,不是更短:探测本身不新开连接(复用播放端/推流端已经打开的 /ws/play,ppcenter 那端还有一个独立的 30 秒服务端 ping 维持这条连接不被判定为空闲断开),单次成本很低;真正的约束是精度——消费级设备时钟在没有外部校时时典型漂移率约 20~100ppm,按最坏的 100ppm 算,10 分钟的最坏累积漂移约 60ms,相对这套系统 70~386ms 的延迟基线仍是小头。这个值早先是 45 秒,后来考虑到大规模并发下 ppcenter 要承受的聚合探测消息量,往上调整到了 10 分钟——是在"精度"和"服务端聚合开销"之间按当前项目体量选的一个点,不是精确推导出的唯一正确值。

这个偏移会直接搞坏计算结果:如果播放端本地时钟落后于 ppobs 的时钟,而真实延迟又足够小,直接相减就会算出负数——延迟不可能是负的,这说明减法本身用错了基准。这是这一集要解决的核心问题。


2. 解法:应用层"NTP 风格" offset 校准

2.1 四时间戳算法

不需要实现真正的 NTP 协议,只需要借用 NTP 内部那套"四个时间戳算出偏移"的算法,跑在已有的应用层连接上:

T1 = 播放端发起请求时的本地时间
T2 = 服务端收到请求时的时间
T3 = 服务端回复时的时间
T4 = 播放端收到回复时的本地时间

offset = ((T2 − T1) + (T3 − T4)) / 2   ≈ 服务端时钟 − 播放端时钟
rtt    = (T4 − T1) − (T3 − T2)          往返网络耗时,已扣除服务端处理耗时

一个容易踩的坑:offset 的符号是"服务端时钟减去播放端时钟",所以修正本地时间时是加上 offset,不是减去——这个正负号很容易搞反,正确的做法是先用一组具体数字验证过再上线(比如设定"本地时钟比服务端快 100ms"这个已知场景,验证算出来的 offset 应该是 −100,修正后才能刚好抵消这 100ms)。校准之后,延迟计算变成:

corrected_now  = 本地时间读数 + offset
one_way_delay  = corrected_now − 嵌入的时间戳

2.2 往返的对象为什么选 ppcenter,不选 ppobs

容易想到的第一直觉是"播放端应该对齐推流端",但这个假设在结构上站不住:P2P 名额每个推流端最多 3 个(EP02 讲过),绝大多数走 Edge 的观众根本没有连到推流端,没法跟它做这个往返。

真正要估计的是采集端与播放端时钟域之间的偏差。以经过校时的服务端作为 UTC 近似基准是一种工程实现,但服务端和推流端都不是无误差的 UTC:两者各自的校时误差会叠加到结果中,四时间戳算法还假设上下行时延近似对称。因此应把最小 RTT 样本、offset 年龄和估计不确定度随延迟一起记录,而不是把校准结果当成真值。高精度场景可让采集端和播放端分别对同一受控时间源校准并监测漂移——这不是一句纸面建议:1.2 节提到的 ppobs 对 ppcenter 的校准就是这么做的,推流端和播放端各自独立地对准同一个 ppcenter 锚点,而不是播放端单方面去猜一个 UTC、推流端自己心里又另揣着一个 UTC。

2.3 怎么采样才可靠

  • 连接建立后立即执行一轮采样,一轮探测固定 6 次(不是一个区间),取 RTT 最小的一次的 offset——RTT 越小,说明这次往返受排队和抖动干扰越小,这是标准 NTP/SNTP 客户端的选样方法,比只测一次可靠得多。
  • 首次校准完成前不上报任何延迟指标,避免用一个还不可信的 offset 产生脏数据——这和这门课前面反复出现的"没有可信数据就不产生输出"是同一条原则。
  • 定期重新校准(当前取值 10 分钟),因为设备时钟会随时间漂移,长时间直播场景下一次性校准会逐渐失准。

3. 时间戳本身怎么到达播放端:两条连接各管一段

容易搞混的一点是:"校准偏移"和"传递时间戳"走的是两条完全独立的连接,各自负责不同的事。

  • 支持浏览器 Insertable Streams 能力的播放端,可以直接从码流里解析出 SEI/OBU 时间戳,不需要额外通道。
  • 跨浏览器兜底:一些浏览器(比如不支持 Insertable Streams 的 iOS Safari)读不了码流内嵌的时间戳。这种情况下,播放端连的那个接收节点(说通俗点就是"它正在拉流的那个节点"——挂在 Edge 上就是 Edge,直连 Origin 就是 Origin)会从码流里原地扫描出同一份 SEI/OBU 时间戳(不管推流用的是 RTMP、SRT 还是 WHIP,解析逻辑统一),把它重新包装成一条消息,通过播放端本来就已经打开的那条 /ws/control ABR 控制 WebSocket 广播出去——这条连接原本是播放端为了做画质分层切换(EP05 讲过的 ABR 层选择)才建立的,现在多承担一项转发时间戳的职责,而不是另外新开一条 DataChannel。少建一条连接、复用已有通道,是这里选定实现方式的直接理由。 注意一个容易写错的地方:pplayer 这条 WebSocket 连的是它正在拉流的那个节点,不是 Origin——WS 地址直接复用 WHEP 地址的主机(pplayer/pplayer.mjs 的 MMXControlClient)。所以"谁广播给播放端"取决于观众实际连的是哪个节点,不能一律写成"Origin 广播给播放端";Origin 只能广播给它自己直连的播放端。

那么挂在 Edge 上的观众怎么拿到时间戳?Origin→Edge 这一跳靠的是一条显式中继:Edge 从 Origin 拉流时,除了内部的 WHIP 拉流会话,还会额外拨一条到 Origin 的 /ws/control?session_id=<这次拉流会话> 的连接,专门订阅 Origin 广播出来的 OBS_TIMESTAMP 消息,收到后调用 Edge 自己的 RelayObsTimestamp,再转发给挂在 Edge 上的播放端(ppmmx/internal/staticsources/webrtc/obs_timestamp_relay.go、internal/servers/webrtc/obs_timestamp_broadcast.go)。也就是说,跨浏览器兜底这条消息实际走了两跳:Origin →(Edge 订阅的/ws/control)→ Edge →(播放端的/ws/control)→ pplayer,而不是 Origin 直接推给 pplayer。(Edge 从 Origin 拉流本身也是一次 WHIP 摄入会话,理论上也能自己重新扫描媒体流里的 SEI——那对应的是路径 A 的兜底;跨浏览器这条走的是上面的显式中继。)受限于缺乏真实的多节点联调环境,目前验证到"每个独立环节按设计工作、彼此优雅降级",还没有跑通过一次完整的真实多节点端到端联调。


4. 残留噪声怎么处理:无效样本不能进入正式 SLA

负延迟在物理上无效,说明时钟校准、帧匹配或采样时机至少有一项不可靠。它不能钳制成 0 后混入正式 SLA,也不能以有符号原值参与正式百分位,否则会人为改善分布。正确做法是保留到独立的数据质量流,标记原因并触发重校准;正式 SLA 只接收满足帧匹配成功、offset 未过期且不确定度在预算内的非负样本,同时披露无效样本率。持续负值需要告警,偶发负值也要计入测量质量,而不是业务延迟。


5. 互补而非替代:先区分 ICE RTT 与 RTCP RTT

WebRTC 中至少有两类常被统称为 RTT 的量,不能混用。RTCIceCandidatePairStats.currentRoundTripTime 来自当前候选对的 STUN connectivity check,描述 ICE 传输路径;RTCRemoteInboundRtpStreamStats.roundTripTime 等 RTP 统计基于 RTCP 报文,描述对应 SSRC 的媒体控制反馈。字段可用性取决于浏览器和会话状态。

指标 覆盖范围
SEI/OBU 时间戳单程延迟(本集讲的方案) 推流端采集到播放端渲染的完整链路,含编解码、抖动缓冲、排队
ICE candidate-pair RTT 当前 ICE 候选对上的 STUN 往返,不含编解码/播放缓冲
RTCP RTT 对应 RTP/SSRC 的控制报文往返,不等同于 ICE 检查 RTT,也不含完整编解码链路

两类信号应该一起采:currentRoundTripTime 是免费获得的直连质量信号,但只有本集讲的时间戳方案,才能拿到覆盖完整链路的延迟数据。


6. 延迟之外:三个同样重要的播放体验埋点

只知道"延迟是多少"还不够——一个播放请求半天连不上、连上了但要等很久才出画面、画面出来了但一路卡顿,这三种糟糕体验都不会被"端到端延迟"这一个数字捕捉到。系统里另外维护了三条独立的埋点,各自对应一个问题。

6.1 首帧时间:从播放意图到首个实际渲染帧

首帧概念上应该统一起止点:起点为播放器接受有效播放请求的时刻,终点为首个非占位视频帧实际提交渲染的时刻。仅收到 RTP 包、完整帧或完成解码都不等于用户已经看到画面。上报还应带上是否包含鉴权、信令和自动播放等待;若业务另有"连接后首帧",必须作为不同指标命名。

一个容易被忽略的精度细节:起止时间戳理论上应该用单调时钟(不受系统时间校正影响),但 pplayer 当前两处都是直接 Date.now()(墙钟)——同一份代码在别处(比如 NAT 探测计时)用的是 performance.now(),首帧这里却没有。如果用户设备在这几百毫秒窗口内发生系统时钟跳变,理论上会让这次测量出现一个小的、不可预期的偏差。这是实现层面一处细小但真实的口径瑕疵。

这个字段在两处上报里都会用到:一次性的连接结果上报里带着它(衡量"这次连接质量如何"),播放会话开始时的上报里也带着它。但目前服务端对这个字段只算了均值/最小值/最大值,还没有做 P50/P95/P99 分位数统计——真正的分位数计算逻辑在 ppcenter 里是有的,但接的是本集主角 SEI/OBU 单程延迟这个指标,不是这里讲的首帧耗时字段;把首帧耗时也接入分位数统计,是一项还没做的后续工作。

两条路径目前的实现精度并不一样:P2P 路径用的是浏览器 video.requestVideoFrameCallback——这是浏览器在一帧真正被提交合成显示时才触发的回调,和上面"首个非占位视频帧实际提交渲染"的定义严格对应;Edge 路径目前是在 WebRTC getStats 的轮询循环里判断 fps > 0 来近似"已经在解码",轮询间隔是 1 秒,粒度比 P2P 路径粗。这不是文档和代码不一致,而是浏览器能力差异下的工程取舍——requestVideoFrameCallback 更精确,但当前只用在了 P2P 这一条腿上。

6.2 连接成功率:一次性上报,不只是"成功/失败"这一个布尔值

播放端在每次播放决策落定后(选定了走 P2P 还是走 Edge),会发一条一次性的"连接结果"上报,核心字段是:

  • path:这次实际用的是 p2p 还是 edge。
  • isOK:这次连接有没有成功。
  • reason:如果失败了,具体原因是什么——比如 ICE 检查未选出可用直连候选对、P2P 名额已满,或网络/权限错误;即使连接成功,也可带上候选类型和路径选择结果,方便按网络条件分组统计。不要只凭"对称 NAT"标签判定失败。

为什么要单独区分"没有直播在播"这种失败:如果观众上报"连接失败"时,这个流其实已经下线了(比如主播刚好在这一秒关闭了直播),这次失败并不能说明系统本身的连接能力有问题——服务端会检查这个流当前是否真的在线,如果不在线,这条失败记录只计入"播放尝试总数"(用来算 P2P 命中率之类的统计),但不计入"连接失败"的明细,不会拉低连接成功率——避免主播下播这个正常事件被误统计成一次服务故障。

6.3 卡顿率:先统一事件和分母

卡顿和连接成功不是同一类问题。建议把卡顿定义为:首帧之后、用户期望播放且页面处于纳入统计状态时,渲染帧推进中断超过产品阈值;一次事件持续到渲染稳定恢复。主动暂停、后台挂起、seek、切流和播放结束不计入卡顿。阈值是产品参数,需随平台与版本记录——当前 pplayer 侧实现的默认值是 2000ms(lag-tracker.mjs 的 DEFAULT_LAG_THRESHOLD_MS),即 <video> 的 waiting 事件持续超过 2 秒才计一次卡顿,短于这个阈值的小幅等待(比如一次 ABR 切层引起的短暂 waiting)不计入。心跳和收尾携带两个累计字段:

  • lagCount:从开始播放到现在,一共卡顿了多少次(播放端自己按一个阈值判定"这次算不算一次卡顿")。
  • lagDurationMs:这些卡顿累计花掉了多少毫秒。

时间卡顿率按"卡顿累计时长 ÷ 有效观看时长"聚合;也可同时报告每分钟卡顿次数。有效观看时长排除起播前、主动暂停和后台挂起。分子、分母和事件合并规则必须固定,否则不同播放器的数据不可比。

一个容易被忽略的实现细节:播放结束上报要用浏览器的 sendBeacon 发出去,因为页面关闭/刷新的那一刻,普通的异步请求可能来不及发完就被终止了,sendBeacon 是浏览器专门为"页面即将卸载时还要发一条请求"设计的 API。但 sendBeacon 不能自定义请求头,这意味着鉴权信息不能像其它接口那样放在 Authorization 头里,只能放进请求体——所以这里复用的是 EP06 讲过的 txTime/txSecret 短期播放令牌,直接作为请求体字段传过去,播放端全程不需要持有 appSecret。

6.4 三个埋点分开设计,而不是塞进一个大事件

视频加载时间、连接成功率、卡顿率,看起来都是"播放体验"的一部分,但没有被塞进同一个万能上报接口里,原因是它们的发生时机和生命周期完全不同:连接成功与否是播放刚建立那一刻的一次性判定,加载时间是从请求到首帧这段一次性的过程,卡顿是播放全程持续累积的状态。按各自的生命周期设计成"一次性结果上报"和"会话心跳"两套机制,比硬塞进一个字段更多、语义更混杂的大事件更容易维护,也让服务端可以分别按"连接质量""起播速度""播放流畅度"三个独立维度聚合统计,不用先拆解一个大事件才能算清楚每个维度的数字。

埋点 生命周期 上报方式 聚合维度
首帧时间 从请求到首帧的一次性过程 连接结果上报、会话 start 上报 起播速度
连接成功率 播放刚建立那一刻的一次性判定 一次性结果上报 连接质量
卡顿率 播放全程持续累积的状态 会话 heartbeat + 结束 stop 上报 播放流畅度

7. 框图

7.1 时间戳(TS)传递示意图

flowchart TD
    A["ppobs(已对齐 ppcenter 锚点,连不上时兜底公网 NTP)<br/>每帧把绝对时间戳打进码流:H.264/HEVC 用 SEI,AV1 用 metadata OBU<br/>RTMP/ERTMP、SRT、WHIP 一视同仁,都打进同一份码流"] --> B["Origin(不解码、不转码,原样透传;<br/>按自己的 NAL/OBU 解析流程原地扫描出这份 SEI/OBU)"]
    B --> C["路径 A:播放端支持 Insertable Streams<br/>播放端自己从码流解析 SEI/OBU,无需服务端转发"]
    B --> D["路径 B:跨浏览器兜底(iOS Safari 等读不了 SEI)<br/>Origin 把刚解析出的时间戳包成 OBS_TIMESTAMP 消息"]
    D --> E["广播给 Origin 直连的播放端(它们的 /ws/control)"]
    D --> F["Origin → Edge 中继<br/>Edge 拉流时额外拨一条 /ws/control?session_id=&lt;拉流会话&gt; 订阅该广播"]
    F --> G["Edge 收到后调用 RelayObsTimestamp"]
    G --> H["广播给挂在 Edge 上的播放端(它们的 /ws/control)"]
    H --> I["pplayer 在它本就为分层切换打开的 /ws/control 上收到这条 OBS_TIMESTAMP"]

关键点:pplayer 的那条 WebSocket 连的是"它正在拉流的节点"(挂 Edge 时是 Edge,直连 Origin 时才是 Origin),不是 Origin;所以跨浏览器兜底这条消息实际走两跳 Origin → Edge → 播放端,而不是 Origin 直接推给 pplayer。

7.2 offset 校准:四个时间戳换一个可信偏移

sequenceDiagram
    participant P as 播放端
    participant S as 服务端(ppcenter)
    P->>S: T1:发起校准请求
    S->>S: T2:收到请求
    S->>P: T3:发出回复
    P->>P: T4:收到回复
    Note over P,S: offset = ((T2 − T1) + (T3 − T4)) / 2(服务端时钟 − 播放端时钟)<br/>rtt = (T4 − T1) − (T3 − T2)(往返网络耗时,已扣除服务端处理耗时)<br/>重复 6 次,取 rtt 最小的那一次的 offset 作为本轮校准结果

ppobs 推流端跑的是完全同一套探测(把图里的"播放端"换成"推流端"即可),每 10 分钟一轮,这也是 1.2 节说的优先级更高的那层校准——两端各自独立对齐同一个 ppcenter,而不是只有播放端在做这件事。

7.3 一次播放的完整埋点生命周期

flowchart TD
    A["观众点击播放"] --> B["播放决策落定(选定 P2P 还是 Edge)"]
    B --> C["一次性上报「连接结果」:path、isOK、reason、costTime<br/>(衡量:这次连接成不成功,失败原因是什么)"]
    C --> D["收到第一帧画面"]
    D --> E["会话 start 上报:costTime、path、qualityLevel<br/>(衡量:从请求到首帧,起播花了多久)"]
    E --> F["持续播放中"]
    F --> G["每隔一段时间 heartbeat:累计 lagCount / lagDurationMs、p2pDelayMs<br/>(衡量:卡了几次、卡了多久、P2P 链路实时延迟)"]
    G --> H["播放结束 / 页面关闭"]
    H --> I["stop 上报(用 sendBeacon 保证发得出去):endReason、最终 lagCount / lagDurationMs<br/>(鉴权信息放进请求体,因为 sendBeacon 不能自定义请求头)"]

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

这一集先讲清楚了端到端延迟这道减法题为什么在浏览器里容易出错,解法是把往返对象选对(ppcenter 而不是推流端),再复用已有连接完成 offset 校准——而且推流端和播放端其实是各自独立地对准同一个 ppcenter 锚点,不是播放端单方面去猜。但延迟只是播放体验的一个维度——视频加载时间衡量起播快不快,连接成功率衡量连不连得上,卡顿率衡量连上之后稳不稳。四个埋点合在一起,才是"播放体验"这四个字背后完整的数据支撑。

下一集,我们讲为什么"平均延迟"这个指标本身还不够——时延稳定性,往往比平均值更重要。