跳转至

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

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

0. 本集目标

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

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

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

当前实测基线是:P2P 真实跑通约 70ms;走 Edge 分发的入库链路实测 WHIP 约 108ms、SRT 约 386ms(差异来自入库协议的接收窗口,不是编码格式)。这些数字怎么测出来?推流端打时间戳还不够,播放端必须知道这个时间戳属于哪一帧、那一帧何时真正渲染,并处理两端时钟误差。光有延迟也不足以说明体验,还需要首帧、连接成功率和卡顿口径补齐。


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

2.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 去做关联。

WHIP 的 "obs-timestamp" DataChannel 走的是完全独立的旁路通道,到达播放端时已经和视频帧分离,所以这条消息必须自带身份信息才能对上号:{"frame_no": <RTP 序列号>, "timestamp": <毫秒>, "rid": "<simulcast 层号>"}。frame_no 不是一个全局递增的"第几帧"计数器,而是这一帧第一个 RTP 包在该 simulcast 层自己序列号空间里的 sequence number;rid 用来区分具体是哪一层——不同层各自独立计数,跨层时单看 frame_no 并不唯一。两条通路的共同难点都是乱序、丢失和过期,只是 SEI 靠"嵌在帧里"天然免疫身份错配,DataChannel 靠这组显式字段解决同一个问题。

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

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

ppobs 这一侧的"做了校时"具体是:启动时起一个后台线程轮询公网 NTP 服务器(time.cloudflare.com、time.windows.com、pool.ntp.org),每个服务器最多采 4 次样、取 RTT 最小的一次,RTT 一旦小于 30ms 就提前结束采样;此后每 30 分钟重新校准一次。只要曾经同步成功过一次,哪怕之后所有服务器都连不上,也会继续沿用上一次校出的 offset,而不是退回裸的系统时钟——只有从来没同步成功过(比如网络屏蔽了 UDP 123 端口)才会退回未经校准的本机墙钟。浏览器做不到这一层(拿不到 UDP 123),这正是下面要解决的不对称。

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


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

3.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 − 嵌入的时间戳

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

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

真正要估计的是采集端与播放端时钟域之间的偏差。以经过校时的服务端作为 UTC 近似基准是一种工程实现,但服务端和推流端都不是无误差的 UTC:两者各自的校时误差会叠加到结果中,四时间戳算法还假设上下行时延近似对称。因此应把最小 RTT 样本、offset 年龄和估计不确定度随延迟一起记录,而不是把校准结果当成真值。高精度场景可让采集端和播放端分别对同一受控时间源校准并监测漂移。

3.3 怎么采样才可靠

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

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

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

  • 支持浏览器 Insertable Streams 能力的播放端,可以直接从码流里解析出 SEI/OBU 时间戳,不需要额外通道。
  • 跨浏览器兜底:一些浏览器(比如不支持 Insertable Streams 的 iOS Safari)读不了码流内嵌的时间戳。这种情况下,Origin 节点会解析推流端那条 "obs-timestamp" DataChannel,把时间戳重新包装成一条消息,通过播放端本来就已经打开的一条 WebSocket 控制连接广播出去——这条连接原本是播放端为了做画质分层切换(EP05 讲过的 ABR 层选择)才建立的,现在多承担一项转发时间戳的职责,而不是另外新开一条 DataChannel。少建一条连接、复用已有通道,是这里选定实现方式的直接理由。

这套机制目前已经补齐到 Origin→Edge 这一段的转发(观众连在 Edge 上也能收到时间戳广播),但受限于缺乏真实的多节点联调环境,目前验证到"每个独立环节按设计工作、彼此优雅降级",还没有跑通过一次完整的真实多节点端到端联调——这一点值得诚实地留在这里,而不是包装成"已经全链路验证过"。


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

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


6. 互补而非替代:先区分 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 是免费获得的直连质量信号,但只有本集讲的时间戳方案,才能拿到覆盖完整链路的延迟数据。


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

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

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

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

这个字段在两处上报里都会用到:一次性的连接结果上报里带着它(衡量"这次连接质量如何"),播放会话开始时的上报里也带着它(用于统计首帧耗时的 P50/P95/P99)。

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

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

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

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

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

7.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。

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

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


8. 框图

8.1 时间戳从打点到播放端的完整路径

ppobs(NTP 校时后拿到准确 UTC)
   │  每帧打点绝对时间戳(H.264/HEVC 用 SEI,AV1 用 metadata OBU)
   │  WHIP 推流额外广播同一时间戳到 "obs-timestamp" DataChannel
   ▼
Origin(不解码、不转码,原样透传)
   │
   ├─ 路径 A:支持 Insertable Streams 的浏览器 ──▶ 直接从码流解析 SEI/OBU
   │
   └─ 路径 B:跨浏览器兜底
         Origin 解析 "obs-timestamp" DataChannel
                │
                ▼
         包装成消息,广播到播放端已打开的 ABR 控制 WebSocket
                │
                ▼
         播放端在处理画质分层切换的同一条连接上,收到时间戳

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

播放端                                    服务端(ppcenter)
  │  T1:发起校准请求 ───────────────────────▶ │  T2:收到请求
  │                                          │
  │ ◀─────────────────────────────────────  │  T3:发出回复
  │  T4:收到回复                             │

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

重复 5~8 次,取 rtt 最小的那一次的 offset 作为本轮校准结果

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

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

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

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

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