参考:
redis设计滚动排行榜
直播间设计排行榜参考:https://blog.csdn.net/qq_30285985/article/details/112382087
周期榜
周期榜实现还是很容易的,给每个周期算出一个序号,作为榜单名后缀,进入新的周期自然切换读写新榜单,平滑过度。以日榜为例,根据时间戳ts计算每日序号s=ts/86400,以日序号s作为后缀即可实现零点后自动读写新日榜。小时榜与此雷同,不再赘述。
对于周榜,可以选定某一个周一(或周日,看需求)的时间戳为基准,计算基准到当前经过的周数为周序号,以此作为榜单后缀。
对于月榜,稍有不同,因为月份天数不固定,所以不能按照上述方法计算。但我们可以根据时间戳取得年、月信息,以年月做标志(如201810)后缀,即可实现月榜。
滚动榜参考:
https://cloud.tencent.com/developer/article/1370111
https://blog.csdn.net/a347911/article/details/135246516
https://blog.csdn.net/a347911/article/details/135295025
前言
业务已基于 Redis 实现了一个高可用的排行榜服务,长期以来相安无事。有一天,产品说:我要一个按周排名的排行榜,以反映本周内用户的活跃情况。于是周榜(按周重置更新的榜单)诞生了。为了满足产品多变的需求,我们一并实现了小时榜、日榜、周榜、月榜几种周期榜。本以为可长治久安了,又有一天,产品体验业务后说:我想要一个最近 7 天榜,反映最近一段时间的用户活跃情况,不想让历史的高分用户长期占据榜首,可否?于是,滚动榜(最近 N 期榜)的需求诞生了。
周期榜
周期榜实现还是很容易的,给每个周期算出一个序号,作为榜单名后缀,进入新的周期自然切换读写新榜单,平滑过度。以日榜为例,根据时间戳 ts 计算每日序号 s=ts/86400,以日序号 s 作为后缀即可实现零点后自动读写新日榜。小时榜与此雷同,不再赘述。
对于周榜,可以选定某一个周一(或周日,看需求)的时间戳为基准,计算基准到当前经过的周数为周序号,以此作为榜单后缀。
对于月榜,稍有不同,因为月份天数不固定,所以不能按照上述方法计算。但我们可以根据时间戳取得年、月信息,以年月做标志(如 201810)后缀,即可实现月榜。
滚动榜
方案探讨
滚动榜需要考虑多个周期榜数据的聚合与自动迭代更新,实现起来就没那么容易了。下面分析几个方案。
方案 1:每日一个滚动榜,当日离线补齐数据【数据更新不实时】
还以日榜为例,最近 N 天榜就是把前 N-1 天到当天的每一个日榜榜单累加即可,比如最近 7 天榜,就是前 6 天到当天的每一个日榜中相同元素数据累加。因此,最直观的一个方案是:首先记录每天的排行榜 R,那么第 i 天的最近 N 天榜 Si=∑N−1n=0Ri−n,其中,Ri−x 表示第 i 天的前 x 天的日榜。实现上,可以每日生成一个滚动榜 S 和当天日榜 R,加分时同时写入 S 和 R,每日零点后跑工具将前 N-1 天数据累加写入当日滚动榜 S。
这个方案的优点是直观,实现简单。但缺点也很明显,一是每日一个滚动榜,消耗内存较多;二是数据更新不实时,需要等待离线作业完成累加后 S 中的数据才完全正确;三是时间复杂度高,7 天榜还好,只需要读过去 6 天数据,如果是 100 天榜,该方案需要读过去 99 天榜,显然不可接受。
方案 2:全局一个滚动榜,当日离线补齐数据【必须要有一个离线job执行,不平滑】
基于方案 1,如果业务无需查询历史的 S,可以只使用全局一个 S,无需每日创建一个 Si。加分操作还是同时加当日的 Ri 和全局唯一的 S,但每日零点的离线作业改为从 S 中减去 Ri−(N−1) 的数据(即将最早一天的数据淘汰,从而实现 S 的计数滚动)。
此方案减少了内存使用,同时离线任务每次只需读取一个日榜做减法,时间复杂度为 O (1);但仍需要离线作业完成才能保证数据正确性,还是无法做到平滑过渡。
方案 3:每日一个滚动榜,实时更新【每个节点一个榜单,写入成本高】
要做到每日零点后榜单实时生效,而不需要等待离线作业的完成,一种方案是预写未来的榜单。不难得出,当日分数会计入往后 N-1 天的滚动榜中。因此,可以写当天的滚动榜 Si 的同时,写往后 N-1 天的榜单 Si+1 到 Si+N−1。
该方案不仅能脱离离线作业做到实时更新,且可以省略每天的日榜。但缺点也不难看出,对于 7 天滚动榜,每次写操作需要更新 7 个榜单,写入量小时还勉强能接受,如果写操作量大或者需要的是 30 天、60 天滚动榜,此方案可行性几乎为零。
方案 4:实时更新,常数次写操作
有不有办法做到既能实时更新,写榜数量也不随 N 的增加而增加呢?不难看出,第 i 天滚动榜 Si=∑N−1n=0Ri−n,而第 i+1 天的滚动榜 Si+1=∑N−1n=0R (i+1)−n=∑N−2n=0Ri−n+Ri+1。显然,Si+1=Si−Ri−(N−1)+Ri+1。由于 Ri+1 在刚达到零点时必然为空且可以在次日实时加到 Si+1 上,因此如果我们能提前准备好 Si−Ri−(N−1) 这部分数据,那么在零点进入 i+1 天后,Ri+1 自然就是可用状态了。
以3天滚动榜为例,次日滚动榜初始态为当日滚动榜减去n-2天的日榜数据。
+-------------------------------------------+
| |
+----+---+ +--------+ +--------+ |
| | | | | | |
| R(i-2) | | R(i-1) | | R(i) | |
| | | | | | |
+----+---+ +----+---+ +---+----+ |
| | | |
| | | |
| | | |
| | v+ v-
| |
| | + +--------+ +--------+
| +-----> | | + | |
| + | S(i) | +---+> | S(i+1) |
+-----------------+> | | | |
+--------+ +--------+
那么,如何提前准备好 Si−Ri−(N−1) 这部分数据呢?可以如下处理:
- 对一个元素加分时,加当日周期榜 Ri、滚动榜 Si;还需根据其在今日滚动榜中的分数 s、及 n-1 天日榜中的分数 r,计算出其在明日滚动榜中的初始分数
s-r
写入明日滚动榜中;即 3 个写操作; - 如果一个元素在当日没有任何加分操作,那么不会触发写入初始分数操作,所以还需要一个离线工具补齐。与方案 1、2 不同的是,该离线工具可提前一天运行,即当日运行离线工具补齐次日的滚动榜数据即可。
简而言之:第一步是运行离线工具生成次日的滚动榜;第二步是在写操作时同时更新次日的滚动榜。
该方案也是每日一个滚动榜。相对方案 3 而言,是空间换时间。如果空间不足且无保留历史的需求,可在离线工具中清理历史数据。
+--------------+
| |
| AddScore |
| |
+-+----+-----+-+
| | |
v | |
+--------+ +--------+ +-------++ | |
| | | | | | | |
| R(i-2) | | R(i-1) | | R(i) | | |
| | | | | | | |
+--------+ +--------+ +--------+ | |
| v
+--------+ | ++-------+
| | | | |
| S(i) +<--+ | S(i+1) |
| | | |
+--------+ +----+---+
^
|
|
+------+-----+
| |
| Tool |
| |
+------------+
方案 4 的实现
以下是实现参考。此处仅列出核心的 lua 脚本。Redis 命令调用脚本的参数定义为:
eval script 4 当日日榜key 当日滚动榜key 即将淘汰的日榜key 明日滚动榜key 榜单元素名 加分数
lua 脚本 script 如下:
--加今日日榜分数
redis.call('ZINCRBY', KEYS[1], ARGV[2], ARGV[1])
--加今日滚动榜分数
local rs = redis.call('ZINCRBY', KEYS[2], ARGV[2], ARGV[1])
local curRoundScore = 0
if (rs) then
curRoundScore = tonumber(rs)
end
--取即将淘汰的日榜分数
rs = redis.call('ZSCORE', KEYS[3], ARGV[1])
local oldCycleScore = 0
if (rs) then
oldCycleScore = tonumber(rs)
end
--计算次日滚动榜初始分数
local nextRoundScore = curRoundScore - oldCycleScore
if nextRoundScore < 0 then
nextRoundScore = 0
end
--设置次日滚动榜分数
redis.call('ZADD', KEYS[4], nextRoundScore, ARGV[1])
--返回今日分数
rs = redis.call('ZREVRANK', KEYS[2], ARGV[1])
return {curRoundScore, rs}
关于榜单 key 计算准确度的探讨 我们的业务是在排行榜接入层逻辑中计算榜单后缀的,这种方案对逻辑层多台机器的时间一致性要求较高,如果逻辑层服务器时钟不一致,可能在时间切换点上出现不同机器读写不同榜单的问题。如果业务对时间精确度要求严格,可以考虑通过 lua 脚步在 redis 端计算后缀。
.
关于内存容量限制的探讨 基于 ZSet 实现的排行榜,每个元素约需要 100 字节内存。如果榜单长度为 1000 万,则每个榜单约需要 1G 内存。滚动榜的计算需要每日保留一个日榜,如果滚动周期较长,则可能单机内存容量不足以容纳所有需要的榜单。 考虑到历史日榜数据是不会变更的,因此不在 lua 脚本中读取历史日榜数据也无一致性问题。故可以将榜单打散到多个 Redis 实例,在接入层做逻辑读取历史日榜的分数,再以参数形式传入给 lua 脚本处理。
总结
在榜单长度不大且并发量不高的场景下,使用关系数据库 + Cache 的方案实现排行榜有更高的灵活性。而在海量数据与高并发的场景下,Redis 是一个更好的选择。本文基于 Redis 实现的滚动榜,不论滚动周期多长,都只需要常数(3)次数的写操作,有较好的性能和可扩展性。且通过离线 + 在线的双预生成机制,确保了榜单实时生效,可用性较强。
更通俗的分析
固定时间开始的日周月榜
如果是单纯的日,周,月榜,可以仅记录当日,当周,当月三个榜单,然后到第二天,将当周,当月的榜单减去昨日的当日数据即可,仅需要维护4张榜单,一次写入3张表单,每天一个定时任务进行表单清洗即可。
更动态的滚动榜单
模拟方法
以小时榜举例,如果使用上一种方案,可以考虑24小时每小时一个榜单,第二个小时开始后,复制上一个小时的榜单,再乘以一个衰弱系数即可,这相当于模拟了一种滚动的场景。
真正滚动
如果要真正滚动,以日榜为例,需要构造当日每小时的小时榜,每次更新需要更新当前小时数的榜单和日榜,当到达每个小时的时间节点,创建一个新的小时榜,并且将日榜减去前第24小时的小时榜(仅减去一个),之后插入数据,插入新的小时榜和日榜即可。
在这种情况下,对于周榜和月榜,可以考虑与日榜做类似操作,分别减去第7*24,30*7*24小时的小时榜,然后每次更新都更新该两个榜单。但是如果不那么在意周榜和月榜的实时性,可以考虑每天凌晨减去第7天前,第30天前的日榜数据,再加上今日日榜数据即可。
上述方案其实也为未能做到非常高精度的滚动,例如要做到分钟级别,就得做分钟级别的榜单数据,才能够做到分钟级别的滚动,对精度要求越高,要维护的数据越多,所以选择一个合适的粒度做相应的滚动榜单,更粗的粒度仅做普通的榜单,可能是一个更好的做法。