业务开发中多多少少会遇到这种需求,需要一个排行榜,它需要对列表中成员的票数进行排序。
在一个排行榜中,成员的名次是要根据当前票数动态变化的,如果使用Mysql,将会造成频繁的修改,Mysql的性能将得不到满足。其实成员的数据结构特别简单: ,如果了解过Redis的有序集合( ),那你将发现Redis的有序集合非常适合做排行榜。
简单介绍一下Redis的有序集合,它的存储结构抽象一下可以理解为以下:
其中 就是Redis的key,而 和 总是成对出现, 是 的分数,这里可以理解为获取的票数。
有序集合提供了很多方法,如
为 添加名为 的选项并设置它的分数为 ;
获取某个 下成员 的分数;
,给 的分数增加
,返回从 到 名次的成员, 是可选项,添加后将一并返回成员的分数。这个命令是根据成员的 从小到大排名,还有一个命令 是按照 从大到小排名。
返回某个 下成员名为 的排名,分数按照从小到大,相对应的有个
当然还有其他的一些,这里就不再赘述,对于本投票系统,以上命令就已经足够了。
最后,我们梳理下投票系统需要的功能,需要实现下几点:
可以投票
获取排行榜
获取成员的排名和分数
参与人数统计
需要一个后台对选项进行配置以及数据展示
其实对于业务来说,从头做也不是很困难的事情,但是为了免去重复造轮子的麻烦,我们实现了一个通用的投票系统,通过HTTP接口调用的方式进行接入。接下来,将功能逐一给大家详细介绍一下。
先说最简单的排行榜。其实之前我们有个用于投票的系统,但是他没有用有序集合,他是这样做的:用redis最基本的 结构中记录票数, 是选项, 是票数,然后每个月的后三天不能再进行投票,因为需要一个定时任务,将票数统计出来持久存储,作为本月的排行榜。可以看到有好多缺点:第一不能实时计算榜单,只能每个月跑脚本最后计算出来;第二每个月都有几天用户不能投票,损失一大波流量;第三只能支持月榜。排行榜场景用了Redis而没用 挺遗憾的。
那我们在系统中设计了多个周期的排行榜,有小时榜、日榜、周榜、月榜、总榜。解释一下 就是所有列表成员,在一个小时内获取票数的排行榜,其他榜单以此类推。
能做到不同排行榜主要是在于ZSet的 的设置,一个 就是一个排行榜,向不同 对 的 进行增加,ZSet会帮助我们进行排序,而我们只需要调用 或者 即可返回排行榜。
这样设计的好处就是所有的数据都会被保存下来、榜单是完全实时的、不需要脚本、不需要暂停投票。投票时只要根据用户点击投票的时间,计算这个投票时间属于哪个小时、哪一天、哪一周、哪一月,计算对应榜单 ,给对应的 增加票数就可以了,如果不明白的可以接着看下面的 一节,有详细的举例说明。
看到下面几组key,我觉得大家就会一目了然,其中 是系统中不同投票列表的ID,如男星榜和女星榜的 是不同的。
小时榜:
2019年3月17日11点排行榜:
2019年3月17日12点排行榜:
日榜:
2019年3月17日排行榜:
2019年3月18日排行榜:
周榜:
2019年第12周排行榜:
2019年第13周排行榜:
月榜:
2019年1月排行榜:
2019年2月排行榜:
总榜:
多维度的频率限制
灵活的票数增加
HyperLogLog实现的人数统计
频率限制
首先是频率限制,对于一个完善的投票系统,不可能让用户无限制的进行投票。除了基本的IP限制之外,我们在系统中实现了更多维度的频率限制。
对单个用户的频率限制,如一分钟单个用户能投5票,一小时只能投10票,甚至在整个投票周期内,限制单个用户只能投一次票;
对被投票成员的限制,这种情况是为了避免恶意刷票而导致某个投票项票数增加太快而导致数据不好看,如在明星榜中,为了防止易烊千玺票数突然被刷爆,可以设置他每秒只能被投票1000次,每小时100000次等。
对不同渠道来源的频率限制,比如和某公司合作,从他渠道过来的流量,我们可以给他额外增加5票的限制,可以激励用户到他的平台进行投票,实现导流目的;
对不同终端进行频率限制,如PC端投了5票达到上限后,用户还可以在移动端再进行投票,从而能提高整个投票活动的活跃度;
这几个维度的配置在后台实现,配置也十分灵活,因为上面每个维度都支持多个时间周期的设置,周期可以是秒、分钟、小时、星期、月,而且可以多个频率限制进行组合。
票数增加
其次是票数增加,票数增加也没有想象的那么简单,并不是调用一个 就能完事了的,因为票数加完了之后,这个是为了产出排行榜的。
从产品经理的维度来看,他可能并不能满足当前用户投票的真实增长速度,因为往往在推广前期,用户量并没有这么多,如果看着排行榜里面十几、几十的票数是非常难看的,因此我们设计了一些功能
初始化票数:直接通过后台修改某个选项的投票数量。当要修改的人太多的话,运营将是很崩溃的,因此后台在初始化导入的时候,允许选项有一个初始的票数;
多个周期排行榜:上面说了,一组成员,可能会有多个周期的排行榜,如小时榜、周榜、月榜等;
投1票加N票:支持对某个选项设置,只要他被投1次票,可以增加N票,这个N可以在后台进行设置,这样会增快票数增长速度,让票数增加更快更好看;
票数在周期间继承:对于周期性的排行榜,每个周期的开始,往往票数会归为0,比如周榜,周一票数就会变为0。这会引起两个问题,第一,所有成员票数都是0,排行榜是完全随机的,你不知道谁在前面,就会导致热度很高的明星排在后面,这样用户来投票得花功夫去找他想投的明星;第二就是刚说的那个问题,票数不好看,其中一个有个解决方案,就是根据当初导入时初始化的票数对每个周期的票数重新设置,但这样每次周期开始都是一样的。那对于一个成熟的系统来说,他要自己学会初始化票数,因此我们可以将上一个周期的票数继承到下个周期的开始。
我这里挑选几点详细说明一下。
多个周期排行榜:列表具体要统计哪个周期的榜单,可以在后台设置。如女星榜设置要只要统计小时榜和日榜,某个用户在 2019年3月17日 12:33 点击给 投票,那么会根据 这个时间计算出两个榜单的Key:小时榜 、日榜 ,需要同时给这两个 中的成员 进行加票。这样就同时有了小时榜和日榜。
投1票加N票:这里需要记录到两个不同的排行榜,那其实最后还要在 中加一个维度,形如: 和 ,姑且称为 和 ,用户点击一次投1票,在 中给那个选项增加1票,然后从配置中获取后台设置N,再在 中给同样的选项增加N票。最后展示给用户的,可以用 进行展示。
票数在周期间继承:一开始想的是,在每个周期结束的时候,跑一个自动的脚本,把票数统计加入到下个周期的排行榜,但是我们后来发现逻辑复杂而且不容易维护,还有就是要跑多久才能跑完,周期结束前什么时候开始跑呢,一大堆问题。
在说解决方案之前,我先说一下有序集合 中, 并不是整数,是一个浮点数。那这样的话,我们就可以在用户投一票的时候,程序同时给下个周期加 票这样子,下个周期的开始票数是完全根据上个周期的票数计算的,完全实时,没有必要跑脚本之类。而能这样做的原因是因为周期长度都是固定的,完全可以根据点击投票的时间计算出下个周期的 。
人数统计
人数统计不同于人次统计,如果说人次统计和PV类似,那么人数统计就是UV。我借助Redis的数据结构 进行统计,因为我们只要一个最后总参与人数的大概数字,而我们也不需要知道到底是谁什么时候来投票了。这个时候 就能满足需求,同时占用空间特别小(每个 只需使用 12k 字节内存,以及几个字节的内存来储存键本身)。 本系统中我分别统计了某个选项的参与人数和整个投票的参与人数。
可以看到,一个投票接口,需要做好多工作:多个周期榜单票数增加、真假排行榜票数增加、下一周期票数增加、人数统计。同时也可以发现,这几个操作之间并没有任何关联,所以我们可以并行写入,降低接口的响应时间。那实际在做的时候,投票接口通过频率限制检查之后,这几个接口我就直接用go协程处理了,因为不需要我返回投票后的票数,这个完全可以在前端+1。
了解了排行榜和投票功能之后,这个功能理解起来就特别简单了,接口通过参数传入想要获取哪个榜单的成员名称,计算得到 ,通过调用 和 即可。
但其实也处理了一些复杂逻辑,比如能支持返回他的前一名,这样可以获取到他和前一名的差距;还支持获取他上个周期的排名和分数,这样能看到他本周期和上个周期相比票数上升了还是下降了。
同时这几个接口都是支持批量操作的,投票接口支持对多个榜单多个选项同时投票,排行榜接口支持同时获取多个排行榜,获取成员排名和分数的时候也支持同时获取多个成员的信息。这些都多亏了golang的goroutine,从而接口性能得以保证。
频率限制
投票列表
投票统计
一方面得益于golang的goroutine,另一方面在启动程序时将投票信息从mysql写入到内存中,接口只对redis进行读写操作,所以接口性能和稳定性表现都十分不错。下面是对几个接口的测试报告: