背景
不久前公司刚上线一个直播APP,需要关注性能相关的指标。 需求就是构建一个准实时的直播视频平台性能监控数据大盘。
这个大盘的目标用户主要是 运维工程师 (SRE)、产品经理和开发工程师。它通过实时计算客户端上报的性能日志,提供一个全局的、秒级更新的 用户体验 (QoE) 和服务质量 (QoS) 视图。
1. 具体工作流程
- 数据采集:客户端(App或网页播放器)在用户观看直播时,将关键性能事件(如开始播放、首帧渲染、播放中卡顿等)上报到 Kafka。
- 实时处理:Flink 作业消费这些日志,进行实时清洗和解析。
- 指标聚合:通过一个 5分钟的滑动窗口,每5秒计算一次,聚合出核心性能指标。
- 数据存储:将聚合结果写入 Doris
- 数据可视化:最后,他们那边数据可视化工具会连接到 Doris,将这些指标以图表的形式展现在监控大盘上。
2. 核心指标分析及关注原因
下表详细解释了代码中计算的核心指标及其业务价值。
| 指标 (Metric) | 计算逻辑 | 业务含义 | 为什么重要? |
|---|---|---|---|
play_total_uv | 触发 performance_live_play_event 事件的去重用户数 | 总播放用户数 | 这是衡量平台活跃度的基础。所有后续的“率”指标都以此为分母,它定义了分析问题的范围。如果这个指标本身暴跌,说明是更严重的问题(如入口失效、大面积服务不可用)。 |
first_render_fail_uv | 触发 first_render_frame_event 事件且 code 不为 '0' 的去重用户数 | 首帧失败用户数 | 有多少用户点击播放后,连视频的第一个画面都没能成功看到。这是用户体验的“第一道门槛”。 |
success_rate | (1 - 首帧失败用户数 / 总播放用户数) * 100 | 播放成功率 | 这是衡量直播可用性的 核心黄金指标。如果用户连播放都打不开,后续的一切体验都无从谈起。成功率的任何微小下降都值得警惕,因为它直接影响到用户流失。 |
vfreeze_uv | 触发 quality_event 事件且 vfreeze_count > 0 的去重用户数 | 发生视频卡顿的用户数 | 在一次完整的观看过程中,有多少用户经历了至少一次的视频画面静止(即“卡了”)。 |
vfreeze_rate | (卡顿用户数 / 总播放用户数) * 100 | 卡顿率 | 这是衡量直播流畅度的 核心黄金指标。卡顿是用户观看直播时最不能忍受的体验问题之一。高卡顿率会严重影响用户满意度和留存率,是性能优化的重点关注对象。 |
total_gbps | vbitrate (视频码率) + abitrate (音频码率) 的总和 | 总消耗带宽 | 这个指标反映了实时的带宽成本和用户的清晰度体验。运维可以根据它进行容量规划和成本控制;产品和开发则可以分析码率策略是否合理,是否在清晰度和流畅度之间取得了最佳平衡。 |
3. 技术实现
该监控大盘的核心是一个 Flink SQL 流处理作业。它直接消费 Kafka 中的原始日志,通过一系列的转换和聚合,最终将高价值的性能指标写入 Doris 数据库。以下是关键技术点的解析:
3.1 Flink SQL 性能调优
为了确保监控的准实时性(5秒更新)和计算效率,我们在 Flink 作业中实施了多项关键的性能优化,这些优化主要针对高基数(大量独立用户)场景下的聚合计算:
-
开启 Mini-Batch 和 Local-Global 聚合
-
配置:
table.exec.mini-batch.enabled: truetable.exec.mini-batch.allow-latency: 5stable.optimizer.agg-phase-strategy: TWO_PHASE
-
原理与价值: 我们开启了微批处理(Mini-Batch),让 Flink 缓存5秒的数据再进行处理。这极大地减少了对状态后端的访问次数。同时,我们启用了
TWO_PHASE聚合策略,即“Local-Global 聚合”。Flink 会先在各个 TaskManager 上对本地数据进行一次预聚合,然后才将预聚合的结果发送到下游进行全局聚合。这两个参数的组合,可以极大地减少网络 shuffle 的数据量和状态的读写次数,显著提升聚合性能。
-
-
COUNT DISTINCT 性能优化
- 配置:
table.optimizer.distinct-agg.split.enabled: true - 原理与价值: 在我们的场景中,几乎所有核心指标(如
play_total_uv,vfreeze_uv)都涉及到对用户ID的去重计数(COUNT(DISTINCT uid))。这是一个非常消耗资源的算子,极易在数据倾斜时产生性能瓶颈。通过启用此参数,Flink 会将COUNT DISTINCT操作拆分为两个阶段:首先,将数据根据uid进行 shuffle,并在第一层聚合中去除重复值;然后,在第二层聚合中进行简单的COUNT计数。这种方式将压力分摊到多个节点,有效避免了单点瓶颈,是处理大规模去重计数的关键优化。需要注意的事,参数需要依赖 mini-batch 参数的开启。
- 配置:
给大家看下优化开启前后 flink DAG 的变化
- 开启前
- 开启后
其实这里的优化思路和离线处理 count(distinct)的思路是一样的,只不过我们不需要去更改 flink sql 解决 count(distinct),所以 flink 的这个参数还是很方便的,而且开启后,我把任务的资源分配给到最小依然无压力
3.2 SQL 核心逻辑
-
数据源定义 (Source) :
- 创建一个 Kafka 表
client_log,直接消费原始日志流。 - 使用
WATERMARK定义事件时间,为后续的窗口计算提供时间基础。
CREATE TABLE client_log ( raw_log STRING, ts TIMESTAMP(3) METADATA FROM 'timestamp', WATERMARK FOR ts AS ts ) WITH ( 'connector' = 'kafka', 'topic' = 'your-business-log-topic', -- 已脱敏 'properties.bootstrap.servers' = 'kafka-server-1:9092,kafka-server-2:9092', -- 已脱敏 'properties.group.id' = 'your-flink-group-id', -- 已脱敏 'scan.startup.mode' = 'latest-offset', 'format' = 'raw' ) - 创建一个 Kafka 表
-
数据清洗 (ETL) :
- 通过一个临时的
etl视图对数据进行预处理。 - 首先过滤出与性能统计相关的三类事件 (
performance_live_play_event,first_render_frame_event,quality_event)。 - 使用
get_json_object函数从复杂的 JSON 日志体中提取出uid,event,code,vfreeze_count等关键字段,并将它们转换为正确的类型。这一步将非结构化的数据转化为了干净、扁平的结构化数据,为后续聚合做好了准备。
SELECT get_json_object(data,'$.uid') AS uid, get_json_object(data,'$.event') AS event, CAST(get_json_object(get_json_object(data,'$.extend_long'), '$.code') AS STRING) AS code, CAST(get_json_object(get_json_object(data,'$.extend_long'), '$.vfreeze_count') AS BIGINT) AS vfreeze_count, CAST(get_json_object(get_json_object(data,'$.extend_long'), '$.vbitrate') AS BIGINT) AS vbitrate, CAST(get_json_object(get_json_object(data,'$.extend_long'), '$.abitrate') AS BIGINT) AS abitrate, ts FROM ( SELECT SPLIT(raw_log, '|')[5] AS data, ts FROM client_log -- 过滤出平台日志,并指定事件名 WHERE SPLIT(raw_log, '|')[1] = 'platformLog_your-app-client_performance-log' -- 已脱敏 AND JSON_VALUE(SPLIT(raw_log, '|')[5], '$.event') IN ( 'performance_live_player_first_render_frame_event', 'performance_live_play_event', 'performance_live_player_quality_event' ) ) t - 通过一个临时的
-
指标聚合 (Aggregation) :
- 这是整个流程的核心。我们使用
HOP滚动窗口函数,定义了一个窗口长度为5分钟,滑动步长为5秒的滑动窗口,完美匹配了业务需求。 - 在
GROUP BY子句中,通过COUNT(DISTINCT CASE WHEN ...)的标准 SQL 技巧,在一个查询内高效地计算出所有需要的_uv指标。 success_rate和vfreeze_rate等比率指标也在这里同步计算完成。
SELECT -- 计算播放总UV count(distinct CASE WHEN event = 'performance_live_play_event' THEN uid ELSE NULL END) AS play_total_uv, -- 计算首帧失败UV count(distinct CASE WHEN event = 'performance_live_player_first_render_frame_event' AND coalesce(code, '0') <> '0' THEN uid ELSE NULL END) AS first_render_fail_uv, -- 计算卡顿UV count(distinct CASE WHEN event = 'performance_live_player_quality_event' AND vfreeze_count > 0 THEN uid ELSE NULL END) AS vfreeze_uv, -- 计算播放成功率和卡顿率 ROUND(...) AS success_rate, ROUND(...) AS vfreeze_rate, -- 计算总带宽 SUM(coalesce(vbitrate, 0) + coalesce(abitrate, 0)) AS total_gbps, -- 定义5分钟滑动窗口,每5秒计算一次 HOP_START(ts, INTERVAL '5' SECOND, INTERVAL '5' MINUTES) AS window_start, HOP_END(ts, INTERVAL '5' SECOND, INTERVAL '5' MINUTES) AS window_end FROM etl GROUP BY HOP(ts, INTERVAL '5' SECOND, INTERVAL '5' MINUTES) - 这是整个流程的核心。我们使用
-
数据宿 (Sink) :
- 将最终计算出的指标结果写入到 Doris 表
jaco_performance_live_metrics中。 - 通过配置
sink.buffer-flush.interval='5s',确保每5秒钟就有新的数据被刷新到 Doris,从而保证了监控大盘的准实时性。
CREATE TEMPORARY TABLE doris_output (...) WITH ( 'connector' = 'doris', 'fenodes' = 'doris-fe-node1:8031,doris-fe-node2:8031', -- 已脱敏 'username' = 'your_username', -- 已脱敏 'password' = 'your_password', -- 已脱敏 'table.identifier' = 'your_db.your_table', -- 已脱敏 'sink.enable.batch-mode'='true', 'sink.buffer-flush.interval'='5s' ); -- 将聚合结果插入Doris INSERT INTO doris_output SELECT * FROM dws_total - 将最终计算出的指标结果写入到 Doris 表
总结
总而言之,这段 Flink 代码的商业价值在于它构建了一个数据驱动的决策系统:
- 对运维/SRE: 它提供了一个灵敏的“战场雷达”。
success_rate的下降或vfreeze_rate的飙升都会在5秒内反映到大盘上,触发告警,使团队能在故障影响扩大前迅速响应,将问题扼杀在摇篮里。 - 对产品经理: 它量化了抽象的用户体验。新功能或播放器优化策略上线后,是好是坏不再凭感觉,而是通过
success_rate和vfreeze_rate的具体升降来客观衡量,为产品迭代提供了坚实的数据依据。 - 对开发工程师: 它提供了精准的问题诊断线索。
first_render_fail_uv增多,客户端同学可以立即聚焦排查首帧逻辑;vfreeze_rate升高,则清晰地指向了流媒体传输、CDN 或后端服务等潜在瓶颈。
这个系统将海量、原始的客户端日志,通过实时的流式计算,提炼成了能够直接指导运维、驱动产品迭代、并为研发提供线索的高价值业务洞察。
更多 Flink / Spark / Doris / Paimon 实战文章,请关注公众号 「数据慢想」