运营活动需要实现一个投票打榜的功能:用户通过做任务获取票,获得票之后点击页面的“投票”按钮,给主播加票;打榜页面会根据主播的得票数对主播进行排序,展示榜单前N名。
一、榜单存储设计
方案1:zset
对于上述榜单功能的设计,最简单的方式就是利用redis的zset结构存储榜单的排名,key为本次榜单的标识符,member存储主播的id,score存储主播的票数。但我们都知道zset会有个存储的数量限制,推荐的最大值是5000,如果说榜单排序的主播个数超过上述限制,那此时需要设计更为复杂的方案。
方案2:zset+k-v
如果参与榜单排名的主播数过多,只用zset存储容易导致大key问题,这时候需要搭配redis k-v存储所有主播的票数。具体解决方案如下:
1. 榜单只存储topN个主播的票数,比如榜单页面需要展示top100的主播票数,N=100即可;
2. 所有主播的票数单独存储,采用redis k-v存储票数。key为主播id,value代表主播的票数。
我们本次的功能因为主播数较多,因此采用方案2实现。
二、榜单更新
榜单更新方式
方案1:同步更新
同步更新是最简单的实现方式,在用户调用投票接口时直接更新主播的票数和榜单的排名,但这个方案直接访问redis,只适用于活动流量较小的情况。从稳定性的角度考虑,建议走下面的异步更新方案。
方案2:异步更新
考虑到活动过程中,容易出现突发流量,同步更新的方式从稳定性的考虑角度不建议,因此采用异步更新的方式来实现。
将用户投票的消息异步发送kafka,在consumer内异步处理给主播投票的消息,给主播加票,同时更新榜单排名。
三、数据一致性
榜单更新逻辑
基于zset+kv的实现方式,用户给主播投票,我们需要更新两个地方的数据
1. 更新主播在kv里的票数
2. 更新主播在zset的票数
上述的更新过程是两个操作,于是在实际更新榜单的过程中,可能会导致一些特殊case的出现,下面我们来分case具体分析下解决方案。
case1:并发更新导致的数据不一致
假设有两个用户A、B同时对主播进行投票+10票,主播的原始票数是10;两个操作的顺序有可能会导致数据不一致
由图中可以看到,解决并发更新最简单的方式是将上述两步操作用lua脚本封装成原子操作,但是目前公司的redis使用规范不提供使用lua脚本,因为脚本复杂度不可控,容易造成一些难以定位原因的redis故障,我们尝试想一些别的方法。
主播更新票数主要分为两种情况,用户上榜时直接更新榜单内的票数,用户不上榜时更新kv数据,再用新的票数去更新榜单数据。于是最后的方案定位以下四步:
1、先假设主播在榜上,使用zIncrByParams().xx()指令直接更新榜单上的票数;如果成功,直接走步骤4;失败走步骤2;
2、失败说明主播不在榜上,查询主播当前票数+delta之后,使用zIncrByParams().nx()指令更新榜单上的票数;如果更新成功,说明此时没有别人更新,走步骤4;如果更新失败,说明此时有别人先一步让主播上榜,走步骤3;
3、步骤2失败,说明此时主播已经被更新到榜上了;重复步骤1,使用zIncrByParams().xx()指令直接更新榜单上的票数。
4、更新kv内的主播票数,zincrBy即可。
这个方案保证了主播在榜上更新票数的原子性,对于主播不在榜上的情况对票数更新加分布式锁,保证同时只有一个线程更新票数,能解决大多数情况下并发更新导致的数据不一致情况。
case2: 距离上一名-1
除了上述并发更新的问题,主播票数存储在两个地方,如何保证获取到的主播票数一致性是一个问题。这里踩了另外一个坑。
笔者在展示榜单排序时,以zset的排序为准获取主播的排序,但展示票数时请求了k-v内单独存储的票数。上述的实现在实际过程中,出现了一个意外的case:距离上一名的票数计算出来是-1。初始以为是redis更新k-v和更新zset时有失败的情况,但后续实际增加监控发现redis的可用性还是很高的,基本不存在这种情况。
在此假设基础上,最有可能的情况是在更新k-v和更新redis的中间请求了数据,导致两部分数据一致,出现了距离上一名票数-1的极端case。
最终的实现方案是,榜单页面展示时上榜主播的票数以zset内存储的为准,保证排名和票数获取数据来源的一致性,单独查询主播票数时以k-v内存储的票数为准,即可避免上述问题的出现。
四、风控和安全
榜单漏出的数据除了上述问题之外,还需要考虑数据的安全性,比如上榜主播昵称是否满足基本的风控要求,需要具有紧急下榜的能力。
五、榜单更新的实时性
榜单更新我们采用的是异步操作,那么前端页面展示部分,如何去保证榜单页面更新的实时性呢?
初始以为直接前端mock票数即可,后来发现榜单页面的更新,除了更新票数之外,还涉及到排名部分的更新,如何保证榜单页面实时展示给用户是个亟待解决的问题。
我们此处采用的是和前端配合的形式,后端接口榜单整个异步操作的延时基本可以保证在1秒以内,前端用户投票之后延迟1秒去请求接口,1秒之后请求的接口数据基本为新数据,这个过程中给用户的体验基本是实时更新了榜单页面。
六、定榜
榜单的话一般会涉及到一个定榜操作,对于榜单topN的数据会发放一些奖励等等,因此主播和观众对于榜单定榜的结果较为关注,定榜的实时性也是一个较为重要的问题。
针对上述投票打榜的活动,投票接口我们会限制只有活动时间内可投票。但因为我们选择的方案是异步更新榜单排序,用户的投票消息会延迟几秒后处理,所以我们的定榜时间要延迟于活动的结束时间。那如果消息延迟过大怎么办呢,迟迟不顶榜?
最简单的方式是设定一个最大延时定榜时间,异步处理consumer内增加系统时间判断,如果系统时间大于最大延时定榜时间的话,其他的投票消息不处理,即可达到定榜的结果。