提示

返回博客列表

用户说"卡顿",服务器一切正常:播放质量监测与 QoE 指标

有段时间我们陆续收到用户反馈:"视频看着看着就卡住了。"

我查了所有能查的服务器指标:

  • CPU:20%,正常;
  • 带宽:峰值 60%,没跑满;
  • CDN 状态:全部健康;
  • 源站:响应正常。

所有服务端指标都正常,但用户就是卡。 这说明问题不在我们看得见的地方——它在"从服务器到用户屏幕"这条链路的某个环节上,而我们对这条链路没有任何数据。

后来我们在播放器里加了埋点,采集真实的播放体验数据(QoE),几天后就定位到了问题:某个省份的联通用户,访问被调度到了一个负载偏高的边缘节点,卡顿率是其他地区的 6 倍。

这篇写这套指标体系怎么建。

TL;DR:QoS(服务质量)≠ QoE(体验质量)——服务器 CPU/带宽正常,不代表用户不卡。五个核心 QoE 指标:首帧时间(TTFF)、卡顿率(卡顿时长 ÷ 播放时长)、卡顿次数、码率切换次数、播放失败率。采集方式:监听 HTML5 video 的事件(waiting/playing 算卡顿,loadeddata 算首帧),批量上报(sendBeacon + 采样)。定位问题的关键是分维度下钻:按地区 / 运营商 / 设备 / 清晰度 / 时间段交叉分析。

目录

一、QoS 和 QoE 是两回事

QoS(Service Quality) QoE(Experience Quality)
视角 服务商 用户
指标 CPU、带宽、响应时间、错误率、CDN 命中率 首帧时间、卡顿率、完播率
在哪测 服务器、CDN 客户端播放器
能否反映体验 不能直接反映 直接反映

为什么服务端全绿用户还卡:

用户的播放体验链路:
CDN 边缘节点 → 用户宽带(最后一公里)→ 路由器/WiFi → 设备解码 → 渲染

服务端能看到:CDN 边缘节点的健康状态(第一环)
服务端看不到:用户的宽带、WiFi、设备性能(后面几环)

最常见的几种"服务端正常但用户卡":

  1. 用户的本地网络(WiFi 干扰、宽带拥塞);
  2. 特定运营商/地区的互联互通问题;
  3. 设备解码能力不足(老手机播高码率);
  4. CDN 调度把用户分到了不合适的节点(DNS 调度不准);
  5. 播放器缓冲策略太激进。

这些只有在客户端才能测到。

二、五个核心指标的定义

1. 首帧时间(TTFF / Startup Time)

定义:从用户点击播放到画面出现第一帧的时间。

目标:< 2 秒(业界常用:< 2s 优秀,2~5s 可接受,> 5s 会明显流失用户)。

注意:不是"到 canplay",而是到画面真的出来(loadeddata 或者首帧渲染事件)。

2. 卡顿(Rebuffer / Stall)

定义:播放过程中画面停止(缓冲)的事件。

两个维度:

指标 定义 说明
卡顿次数 一次播放中的 stall 次数 敏感(一次也算)
卡顿时长 每次 stall 持续的秒数
卡顿率 卡顿总时长 ÷ 播放总时长 最常用

目标:卡顿率 < 1%(优质),< 3% 可接受。

"卡顿"和"低画质"是两回事:卡顿是"停下来转圈",画质差是"糊但流畅"。用户通常更讨厌卡顿。

3. 码率切换次数(ABR Switch)

ABR 播放时,档位切换过于频繁说明:

  • 网络波动大;
  • 或者 ABR 策略太激进。

目标:一次播放(10 分钟内)切换 < 5 次。频繁切换会影响观感(画面清晰度变来变去)。

4. 播放失败率

定义:尝试播放但最终失败(错误)的比例。

失败原因分类:

  • 网络错误(加载失败);
  • 解码错误(格式不支持);
  • 4xx/5xx(资源不存在/服务错误);
  • 超时。

目标:< 0.5%。

5. 完播率 / 平均观看时长

定义:用户实际观看时长 ÷ 视频总时长。

这是业务指标,但它是最真实的体验反映——卡顿多的视频,完播率一定低。

三、播放器里怎么采集

原生 HTML5 video

class QoEMonitor {
    constructor(video, sessionId) {
        this.video = video;
        this.sessionId = sessionId;
        this.events = [];
        this.stallStart = null;
        this.playStart = null;
        this.firstFrame = false;
        this.switchCount = 0;
        this.lastLevel = null;
        this._bind();
    }

    _bind() {
        const v = this.video;

        // 首帧
        v.addEventListener('loadeddata', () => {
            if (!this.firstFrame) {
                this.firstFrame = true;
                const ttff = (performance.now() - this.playStart) / 1000;
                this._log('ttff', {ttff});
            }
        });

        // 卡顿开始
        v.addEventListener('waiting', () => {
            if (this.stallStart === null) {
                this.stallStart = performance.now();
                this._log('stall_start', {at: v.currentTime});
            }
        });

        // 卡顿结束
        v.addEventListener('playing', () => {
            if (this.stallStart !== null) {
                const dur = (performance.now() - this.stallStart) / 1000;
                this._log('stall_end', {duration: dur, at: v.currentTime});
                this.stallStart = null;
            }
        });

        // 错误
        v.addEventListener('error', () => {
            this._log('error', {
                code: v.error && v.error.code,
                message: v.error && v.error.message,
            });
        });

        // 结束
        v.addEventListener('ended', () => this._report());

        // 定期上报
        setInterval(() => this._report(), 30000);
    }

    play() {
        this.playStart = performance.now();
        this.video.play();
    }

    _log(type, data) {
        this.events.push({type, ts: Date.now(), ...data});
    }

    _report() {
        if (!this.events.length) return;
        const payload = {
            session: this.sessionId,
            video: this.video.currentSrc,
            events: this.events,
            // 上下文
            ua: navigator.userAgent,
            // 注意:不要采集能识别个人的信息
        };
        // 用 sendBeacon:页面关闭也能发出去
        navigator.sendBeacon('/api/qoe', new Blob([JSON.stringify(payload)],
            {type: 'application/json'}));
        this.events = [];
    }
}

hls.js

如果用 hls.js,它能提供更多信息(码率切换、分片加载耗时、错误详情):

const hls = new Hls();
hls.on(Hls.Events.LEVEL_SWITCHED, (evt, data) => {
    monitor._log('level_switch', {level: data.level, bitrate: hls.levels[data.level].bitrate});
});
hls.on(Hls.Events.FRAG_LOADED, (evt, data) => {
    // 每个分片的加载耗时(能反映 CDN 速度)
    monitor._log('frag', {duration: data.frag.duration, stats: data.stats});
});
hls.on(Hls.Events.ERROR, (evt, data) => {
    monitor._log('hls_error', {type: data.type, details: data.details, fatal: data.fatal});
});

FRAG_LOADED 的 stats 特别有用——它有每个分片的下载耗时和字节数,可以算出用户的实际下载速度,直接定位"是用户网速慢还是 CDN 慢"。

四、上报设计

采样:不要全量上报(量大、成本高)。一般采样 1%~10% 的用户,或者按会话采样(每个会话只上报一次汇总)。

字段设计:

{
  "session": "uuid",
  "ts": 1710144000,
  "video_id": "v123",
  "quality": "720p",
  "ttff": 1.8,
  "stalls": [
    {"at": 12.4, "duration": 2.1},
    {"at": 130.7, "duration": 0.8}
  ],
  "stall_ratio": 0.007,
  "switch_count": 3,
  "watched": 245.3,
  "duration": 320.0,
  "errors": [],
  "context": {
    "ua": "...",
    "network": "wifi",
    "device": "mobile"
  }
}

隐私注意:

  • 不要采集能识别个人的信息(用户 ID 可以用哈希后的假名,IP 只保留到"地区/运营商"级别);
  • 页面 URL 可能含敏感参数(比如 token)——只取 video_id,不取完整 URL;
  • 在隐私政策里说明。

上报时机:

  • 定期(30 秒);
  • 播放结束;
  • 页面关闭(sendBeacon 或 visibilitychange);
  • 发生错误时立即上报。

五、服务端能看到什么

即使不做客户端埋点,服务端日志也能提供一些信息:

log_format cdn '$remote_addr [$time_local] "$request" $status $body_bytes_sent '
               '$request_time $upstream_response_time "$http_range"';

能算出:

指标 说明
分片请求的响应时间 CDN/源站的速度
4xx/5xx 比例 资源错误
Range 请求的比例 拖动行为
每个 IP 的请求量 异常刷量

但服务端看不到:

  • 用户的播放是否真的卡了(下载慢 ≠ 卡顿,因为有缓冲);
  • 首帧时间(这是客户端行为);
  • 解码错误(设备能力问题);
  • 用户最后的体验。

所以两者要结合:服务端日志用于"服务健康",客户端埋点用于"用户体验"。

六、怎么定位问题:分维度下钻

这是埋点数据最大的价值。

有了数据之后,把卡顿率按维度拆开看:

维度 怎么看
地区(省/市) 某个地区明显高 → CDN 节点/调度问题
运营商 某个运营商高 → 互联互通问题
设备(机型/系统版本) 某个机型高 → 解码能力/兼容性问题
清晰度 高码率档位高 → ABR 策略太激进
时间段 晚高峰高 → 带宽容量问题
CDN 节点 某个节点高 → 节点故障/过载

我们当时的发现:

整体卡顿率:0.8%
按省份拆:
  广东 0.6%  北京 0.5%  上海 0.7%
  **某省 4.9%**   ← 异常
按运营商再拆(该省内):
  电信 0.9%  移动 1.1%
  **联通 9.2%**   ← 锁定

结论:该省联通用户被调度到了一个负载高的节点
处理:联系 CDN 厂商调整调度策略 → 一周后降到 1.2%

没有埋点数据,这个问题可能永远发现不了——因为从服务端看,那个节点是"健康"的(它还活着,只是慢)。

工具:不需要复杂的 BI,一个简单的 SQL 聚合 + 透视表就能做下钻。我们用的是:

SELECT province, isp,
       COUNT(*) AS sessions,
       AVG(stall_ratio) AS avg_stall,
       AVG(ttff) AS avg_ttff,
       SUM(CASE WHEN error IS NOT NULL THEN 1 ELSE 0 END)::float / COUNT(*) AS err_rate
FROM qoe_events
WHERE ts >= NOW() - INTERVAL '24 hours'
GROUP BY province, isp
HAVING COUNT(*) > 50
ORDER BY avg_stall DESC;

每天跑一次,看 top 10 异常组合。

七、告警设计

指标 阈值 动作
整体卡顿率 > 3%(15 分钟窗口) 告警
首帧时间 P95 > 5 秒 告警
播放失败率 > 2% P1(立刻查)
某个地区的卡顿率 > 整体 3 倍 告警(地区性问题)
上报量骤降 < 昨天的 50% 告警(可能是埋点挂了)

最后一条很重要:监控你的监控系统。如果上报量突然下降,很可能不是"用户变少了",而是埋点代码出了 bug 或者上报接口挂了。

八、用数据反哺 ABR 策略

采集到的数据可以用来优化播放策略:

1. 按网络状况调整初始缓冲

如果数据显示"首帧 > 3 秒的用户,跳出率明显更高",可以降低起播档位(用低码率快速起播,再切高码率)。

2. 按卡顿率调整 ABR 的保守程度

ABR 算法通常有个"保守系数"(决定切换档位时留多少余量)。数据显示卡顿率高 → 调保守一点(宁可画质低一点也不要卡)。

3. 按设备能力限制最高档

数据显示某类设备在高码率下卡顿率飙升 → 给这类设备限制最高档位(即使带宽够)。

这些优化都需要数据支撑——凭感觉调 ABR 参数很容易顾此失彼。

九、实测:埋点前后的变化

埋点前:

  • 用户反馈"卡" → 我们查服务端 → 一切正常 → 回复"我们这边正常,可能是您的网络问题" → 用户不满意;
  • 平均解决时间:无法解决(问题一直存在)。

埋点后 3 个月:

发现的问题 处理 效果
某省联通节点调度不佳 调整 CDN 调度 该地区卡顿率 4.9% → 1.2%
某款老安卓机型播 720p 卡 给该机型限制最高 480p 卡顿率 6.1% → 0.9%
起播默认 1080p 导致首帧慢 改为起播 480p 再切 首帧 P95 从 4.2s → 1.6s
晚高峰某 CDN 节点过载 增加备用节点 晚高峰卡顿率 3.4% → 1.1%

整体卡顿率从 2.1% 降到 0.7%,用户关于"卡顿"的反馈基本消失了。

成本:埋点开发 2 天,数据管道 1 天,报表 1 天。总共 4 天,换来的是持续的问题发现能力。

十、坑清单

  1. 只看服务端指标 → 看不到用户的真实体验。必须客户端埋点。
  2. 首帧时间用 canplay 事件 → 不准(浏览器认为"能播"和"画面出来"有差距)。用 loadeddata 或首帧渲染。
  3. 卡顿检测只看 waiting 不看结束 → 卡顿时长算不出来。要配对 waiting + playing。
  4. 拖动进度条被当成卡顿 → seeking 后会有 waiting,要区分。拖动产生的等待不算 rebuffer。
  5. 全量上报 → 数据量大、成本高。采样 1%~10%。
  6. 上报含敏感信息(完整 URL、用户 ID)→ 隐私问题。只报必要字段。
  7. 用 XMLHttpRequest 同步上报 → 阻塞页面。用 sendBeacon 或 fetch(keepalive)。
  8. 页面关闭时数据丢失 → 用 sendBeacon(浏览器保证发出)或监听 visibilitychange。
  9. 不做分维度下钻 → 平均值掩盖了局部问题。按地区/运营商/设备拆。
  10. 不监控上报量 → 埋点挂了不知道。加上报量告警。
  11. 样本量太小就下结论 → 某个组合只有 3 条数据就说"这个地区有问题"。设最小样本阈值(比如 50)。
  12. 不区分"卡顿"和"慢" → 下载慢但有缓冲,用户不卡。以播放器的 stall 为准。
  13. 埋点代码影响播放性能 → 上报逻辑要轻(批量、异步、不阻塞主线程)。
  14. 数据只采集不分析 → 建了报表没人看。定期(每周)过一次数据。
  15. 把 QoE 数据当成考核指标乱用 → 它是"发现问题"的工具,不是"评价团队"的工具。

最后说说这次之后我对"监控"这件事的理解变化。

以前我认为监控是"看服务器有没有挂"。这个理解太窄了——服务器没挂,不代表服务是好的。

现在我的理解是:监控要覆盖"用户视角的体验"。因为:

  • 用户不关心你的 CPU 是多少,他只关心"视频卡不卡";
  • 服务端指标正常而用户体验差,这种情况非常常见(占了问题的绝大多数);
  • 只有用户体验数据能告诉你"该优化哪里"。

成本对比也很说明问题:

  • 服务端监控:很容易(现成工具一大堆),但它发现不了体验问题;
  • 客户端 QoE 埋点:多花几天开发,但它能直接定位"哪个地区、哪个运营商、哪款设备有问题"。

还有一点体会:数据的价值在于"下钻"而不在于"平均值"。

整体卡顿率 0.8%,看起来很好。但拆开之后发现某个地区是 4.9%——这个地区的用户正在经历糟糕的体验,而平均值把它完全掩盖了。

所以我现在的习惯是:任何聚合指标,都要至少按 3 个维度拆开看(地区、设备、时间段)。平均值只用来看趋势,不用来判断好坏。

"平均"是最容易骗人的统计值——这句话在做质量监控的时候,我体会得特别深。

想亲手试试?用 VidDown 一键解析下载

粘贴视频链接即可解析,多平台支持、网页端即用;下载桌面客户端解锁海外平台本地解析,开通会员更享不限次下载。

顶部