面试题:请问,某视频点播网站,希望实时展现热门视频,比如近8小时点击量最大的前100个视频。如果由你来开发这个功能,你怎么做?
看到这样的一个问题,估计同学们会说,很简单呀,用Redis的SortedSet搞定啊,Score计数,Key是视频ID,不就OK了吗?
回答到这一步,只能说你回答的不错,下一位。too young too simple呀兄弟。
要听清楚题目,有8小时动态时间窗口,计数会过期的。还有,一个视频播放网站的量有这么小吗,一个Redis就搞定了?我告诉你,视频数据起码是百万级别了,用户几个亿,播放量你至少得估计个1000/s吧。
这么看下来,还觉得是一个Redis能解决得了的吗?
数据量太大,肯定要使用分布式了。关于数据的接收、存储本章我们先不考虑,我们只分析如何统计某个时间段内的播放量排行。
我们需要一张表来记录每条视频的点击记录(包含时间,用户等信息),我们要统计的就是某个时间段的点击总次数。
如果只有一张表,而且数据也存储在数据库中,那这个sql就简单了,语句如下:当天播放量前100视频排行榜
SELECT
( e.createTime ) AS HOUR,
ROUND( sum( e.score ), 1 ) AS sum,
id
FROM
t_vedio_play e
WHERE
to_days( e.playTime ) = to_days( now( ) )
order by score desc limit 100
视频量太大,一张表放不下所有的记录,就需要用到分治算法,先将表哈希拆分成1024张子表。每张表里有一个字段score,表示这个视频的点击数(其实就是1,我们记录的是每一个时间点的每一次点击行为,后面再对时间进行分段抽象)。
如果是多个子表,你得在每个子表上都进行一次TopN查询,然后聚合结果再做一次TopN查询。子表查询可以多线程并行,提高聚合效率。下面是伪代码:
candidates = []
for k in range(1024):
# 每个表都取topn
rows = select id, score from vedio_${k} order by score desc limit 100
# 聚合结果
candidates.extend(rows)
# 根据score倒排
candidates = sorted(candidates, key=lambda t: t[1], reverse=True)
# 再取topn
candidates[:100]
上面这个算法思想,实际上就是分而治之,我们通过把一个大问题拆分成多个小问题并行处理以提高效率。
8小时的滑动窗口,意味着新的数据源源不断的进来,旧的数据时时刻刻在淘汰。严格来说,精准的8小时滑动窗口要求每条数据要严格的过期,差了1秒都不行,到点了就立即被淘汰。
精准的代价是我们要为每条点击记录都设置过期时间,过期时间本身也是需要存储的,而且过期策略还需要定时扫描时间堆来确认哪些记录过期了。量大的时候这些都是不容小嘘的负担。
但是在业务上来讲,排行版没有必要做到如此的精准,偏差个几分钟这都不是事。
业务上的折中给服务的资源优化带来了机遇。我们对时间片进行了切分,一分钟一个槽来进行计数。下面是伪代码:
class HitSlot {
long timestamp; # earlies timestamp
map[int]int hits; # post_id => hits
void onHit(int postId, int hits) {
this.hits[postId] += hits;
}
}
class WindowSlots {
HitSlot currentSlot; # current active slots
LinkedList historySlots; # history unactive slots
map[int]int topHits; # topn posts
void onHit(int postId, int hits) {
# 因为上游有合并点击,所以有了hits参数
long ts = System.currentTimeMillis();
if(this.currentSlot == null) { # 创建第一个槽
this.currentSlot == new HitSlot(ts);
} elif(ts - this.currentSlot.timestamp > 60 * 1000) {
# 创建下一个槽,一分钟一个槽
this.historySlots.add(this.currentSlot);
this.currentSlot = new HitSlot(ts);
}
this.currentSlot.onHit(postId, hits);
}
void onBeat() {
# 维护窗口,移除过期的槽,然后统计topn,30s~60s调用一次
if(historySlots.isEmpty()) {
return;
}
HitSlot slot = historySlots[0];
long ts = System.currentTimeMillis();
if(ts - slot.timestamp > 8 * 60 * 60 * 1000) {
# 过期了8小时,移掉第一个
historySlots.remove(0);
# 计算topn的帖子
topHits = topn(aggregateSlots(historySlots));
}
}
}
上面的代码代表着每个分布式子节点的逻辑,它的目标就是定时维持一个8小时的统计窗口,并汇聚TopN的热门视频放在内存里。这个TopN的数据并不是特别实时,有一个大约1分钟的短暂的时间窗口。
按照视频的数据至少几百万,如果每个子节点都要对所有的播放量统计】,似乎也会占用不少内存,聚合和排序也会有不少计算量。最好的想法是每个子节点只负责一部分视频播放量的统计,这样可以明显节省计算资源。
我的微信公众号:架构真经(id:gentoo666),分享Java干货,高并发编程,热门技术教程,微服务及分布式技术,架构设计,区块链技术,人工智能,大数据,Java面试题,以及前沿热门资讯等。每日更新哦!
参考资料: