有段时间我们陆续收到用户反馈:"视频看着看着就卡住了。"
我查了所有能查的服务器指标:
- CPU:20%,正常;
- 带宽:峰值 60%,没跑满;
- CDN 状态:全部健康;
- 源站:响应正常。
所有服务端指标都正常,但用户就是卡。 这说明问题不在我们看得见的地方——它在"从服务器到用户屏幕"这条链路的某个环节上,而我们对这条链路没有任何数据。
后来我们在播放器里加了埋点,采集真实的播放体验数据(QoE),几天后就定位到了问题:某个省份的联通用户,访问被调度到了一个负载偏高的边缘节点,卡顿率是其他地区的 6 倍。
这篇写这套指标体系怎么建。
TL;DR:QoS(服务质量)≠ QoE(体验质量)——服务器 CPU/带宽正常,不代表用户不卡。五个核心 QoE 指标:首帧时间(TTFF)、卡顿率(卡顿时长 ÷ 播放时长)、卡顿次数、码率切换次数、播放失败率。采集方式:监听 HTML5 video 的事件(
waiting/playing算卡顿,loadeddata算首帧),批量上报(sendBeacon+ 采样)。定位问题的关键是分维度下钻:按地区 / 运营商 / 设备 / 清晰度 / 时间段交叉分析。
目录
- 一、QoS 和 QoE 是两回事
- 二、五个核心指标的定义
- 三、播放器里怎么采集
- 四、上报设计
- 五、服务端能看到什么
- 六、怎么定位问题:分维度下钻
- 七、告警设计
- 八、用数据反哺 ABR 策略
- 九、实测:埋点前后的变化
- 十、坑清单
一、QoS 和 QoE 是两回事
| QoS(Service Quality) | QoE(Experience Quality) | |
|---|---|---|
| 视角 | 服务商 | 用户 |
| 指标 | CPU、带宽、响应时间、错误率、CDN 命中率 | 首帧时间、卡顿率、完播率 |
| 在哪测 | 服务器、CDN | 客户端播放器 |
| 能否反映体验 | 不能直接反映 | 直接反映 |
为什么服务端全绿用户还卡:
用户的播放体验链路:
CDN 边缘节点 → 用户宽带(最后一公里)→ 路由器/WiFi → 设备解码 → 渲染
服务端能看到:CDN 边缘节点的健康状态(第一环)
服务端看不到:用户的宽带、WiFi、设备性能(后面几环)
最常见的几种"服务端正常但用户卡":
- 用户的本地网络(WiFi 干扰、宽带拥塞);
- 特定运营商/地区的互联互通问题;
- 设备解码能力不足(老手机播高码率);
- CDN 调度把用户分到了不合适的节点(DNS 调度不准);
- 播放器缓冲策略太激进。
这些只有在客户端才能测到。
二、五个核心指标的定义
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 天,换来的是持续的问题发现能力。
十、坑清单
- 只看服务端指标 → 看不到用户的真实体验。必须客户端埋点。
- 首帧时间用
canplay事件 → 不准(浏览器认为"能播"和"画面出来"有差距)。用loadeddata或首帧渲染。 - 卡顿检测只看
waiting不看结束 → 卡顿时长算不出来。要配对waiting+playing。 - 拖动进度条被当成卡顿 →
seeking后会有waiting,要区分。拖动产生的等待不算 rebuffer。 - 全量上报 → 数据量大、成本高。采样 1%~10%。
- 上报含敏感信息(完整 URL、用户 ID)→ 隐私问题。只报必要字段。
- 用
XMLHttpRequest同步上报 → 阻塞页面。用sendBeacon或fetch(keepalive)。 - 页面关闭时数据丢失 → 用
sendBeacon(浏览器保证发出)或监听visibilitychange。 - 不做分维度下钻 → 平均值掩盖了局部问题。按地区/运营商/设备拆。
- 不监控上报量 → 埋点挂了不知道。加上报量告警。
- 样本量太小就下结论 → 某个组合只有 3 条数据就说"这个地区有问题"。设最小样本阈值(比如 50)。
- 不区分"卡顿"和"慢" → 下载慢但有缓冲,用户不卡。以播放器的 stall 为准。
- 埋点代码影响播放性能 → 上报逻辑要轻(批量、异步、不阻塞主线程)。
- 数据只采集不分析 → 建了报表没人看。定期(每周)过一次数据。
- 把 QoE 数据当成考核指标乱用 → 它是"发现问题"的工具,不是"评价团队"的工具。
最后说说这次之后我对"监控"这件事的理解变化。
以前我认为监控是"看服务器有没有挂"。这个理解太窄了——服务器没挂,不代表服务是好的。
现在我的理解是:监控要覆盖"用户视角的体验"。因为:
- 用户不关心你的 CPU 是多少,他只关心"视频卡不卡";
- 服务端指标正常而用户体验差,这种情况非常常见(占了问题的绝大多数);
- 只有用户体验数据能告诉你"该优化哪里"。
成本对比也很说明问题:
- 服务端监控:很容易(现成工具一大堆),但它发现不了体验问题;
- 客户端 QoE 埋点:多花几天开发,但它能直接定位"哪个地区、哪个运营商、哪款设备有问题"。
还有一点体会:数据的价值在于"下钻"而不在于"平均值"。
整体卡顿率 0.8%,看起来很好。但拆开之后发现某个地区是 4.9%——这个地区的用户正在经历糟糕的体验,而平均值把它完全掩盖了。
所以我现在的习惯是:任何聚合指标,都要至少按 3 个维度拆开看(地区、设备、时间段)。平均值只用来看趋势,不用来判断好坏。
"平均"是最容易骗人的统计值——这句话在做质量监控的时候,我体会得特别深。