本文由云+社区发表
业务已基于Redis实现了一个高可用的排行榜服务,长期以来相安无事。有一天,产品说:我要一个按周排名的排行榜,以反映本周内用户的活跃状况。因而周榜(按周重置更新的榜单)诞生了。为了知足产品多变的需求,咱们一并实现了小时榜、日榜、周榜、月榜几种周期榜。本觉得可长治久安了,又有一天,产品体验业务后说:我想要一个最近7天榜,反映最近一段时间的用户活跃状况,不想让历史的高分用户长期占据榜首,能否?因而,滚动榜(最近N期榜)的需求诞生了。redis
周期榜实现仍是很容易的,给每一个周期算出一个序号,做为榜单名后缀,进入新的周期天然切换读写新榜单,平滑过分。以日榜为例,根据时间戳ts计算每日序号s=ts/86400,以日序号s做为后缀便可实现零点后自动读写新日榜。小时榜与此雷同,再也不赘述。 数据库
对于周榜,能够选定某一个周一(或周日,看需求)的时间戳为基准,计算基准到当前通过的周数为周序号,以此做为榜单后缀。 服务器
对于月榜,稍有不一样,由于月份天数不固定,因此不能按照上述方法计算。但咱们能够根据时间戳取得年、月信息,以年月作标志(如201810)后缀,便可实现月榜。并发
滚动榜须要考虑多个周期榜数据的聚合与自动迭代更新,实现起来就没那么容易了。下面分析几个方案。高并发
还以日榜为例,最近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天榜,显然不可接受。性能
基于方案1,若是业务无需查询历史的S,能够只使用全局一个S,无需每日建立一个Si。加分操做仍是同时加当日的Ri和全局惟一的S,但每日零点的离线做业改成从S中减去Ri−(N−1)的数据(即将最先一天的数据淘汰,从而实现S的计数滚动)。 lua
此方案减小了内存使用,同时离线任务每次只需读取一个日榜作减法,时间复杂度为O(1);但仍须要离线做业完成才能保证数据正确性,仍是没法作到平滑过渡。code
要作到每日零点后榜单实时生效,而不须要等待离线做业的完成,一种方案是预写将来的榜单。不可贵出,当日分数会计入日后N-1天的滚动榜中。所以,能够写当天的滚动榜Si的同时,写日后N-1天的榜单Si+1到Si+N−1。 ip
该方案不只能脱离离线做业作到实时更新,且能够省略天天的日榜。但缺点也不难看出,对于7天滚动榜,每次写操做须要更新7个榜单,写入量小时还勉强能接受,若是写操做量大或者须要的是30天、60天滚动榜,此方案可行性几乎为零。
有不有办法作到既能实时更新,写榜数量也不随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)这部分数据呢?能够以下处理:
s-r
写入明日滚动榜中;即3个写操做;简而言之:第一步是运行离线工具生成第二天的滚动榜;第二步是在写操做时同时更新第二天的滚动榜。
该方案也是每日一个滚动榜。相对方案3而言,是空间换时间。若是空间不足且无保留历史的需求,可在离线工具中清理历史数据。
+--------------+ | | | AddScore | | | +-+----+-----+-+ | | | v | | +--------+ +--------+ +-------++ | | | | | | | | | | | R(i-2) | | R(i-1) | | R(i) | | | | | | | | | | | +--------+ +--------+ +--------+ | | | v +--------+ | ++-------+ | | | | | | S(i) +<--+ | S(i+1) | | | | | +--------+ +----+---+ ^ | | +------+-----+ | | | Tool | | | +------------+
如下是实现参考。此处仅列出核心的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)次数的写操做,有较好的性能和可扩展性。且经过离线+在线的双预生成机制,确保了榜单实时生效,可用性较强。
此文已由做者受权腾讯云+社区发布