积分、衰减与排行榜:不确定中的有限确定性

350 阅读6分钟

没有策划不喜欢排行榜,没有玩家不享受登上榜一的快感。然而背后的程序员可能要为了种种奇怪需求再掉无数头发。

场景

这儿有大约100000名玩家,他们各有各的积分、地区、胜率、达成时间等。地区影响玩家会登上哪一张排行榜,积分、胜率、达成时间则是排行榜的排序字段。

情况A:只是排行榜

如果我们想出具一个前500的排行榜,很简单。使用sql的order by desc或是redis的zset都能解决这个问题。 如果人数再上升,可以考虑根据玩家段位等信息进行预先分桶; 如果需要根据胜率、达成时间等排序,可以把这些转换成小数加在积分后。 这当然难不住各位。

情况B:只是衰减机制

如果要给玩家的积分加上衰减机制,事情会变得复杂一点。但是大体上来说,lazy方式总是可行的:在每次获取积分时,一并获取玩家衰减的相关条件,而后计算玩家的实际分数

  1. 在定时(比如每天、每周固定时间)衰减的机制下,可以通过定时、快照、回传变化量,控制最终一致性的方案解决:这有点像银行账户结息,每个固定时间点为你的账户上再加上一点变化,小心翼翼地维持着数据的正确性。
  2. 不定时衰减的机制下,假如玩家7天整没进行过游戏则衰减20%,这样对于每个玩家结算时间是分开的。如果为每个玩家准备一个衰减钩子,在玩家人数上升后会变得非常难以控制。推荐通过lazy方法解决。

情况C:排行榜+衰减

前面那些都可以说是小菜一碟,熟练进行各种开发的诸位当然还有种种更好的办法,欢迎在评论区留下你的得意方案!

不过,如果要求即时计算的排行榜和要求lazy计算的衰减,被你的产品经理粘到了一起,要如何解决呢?

直接对数据库数据排序,则必须同时进行大量涉及IO的积分计算;可数据库里的数据又都是衰减前的,也没法拿来做排序,这可如何是好?

思路

假如我们把所有涉及衰减计算的字段都存入DB,这样确实可以在获取时就计算出玩家的实际分数,但这样有一个问题:要计算的行数太多太多,且索引在经过计算的字段上会失效。

另一方面,把计算任务全丢给DB也不太负责,毕竟这不是DB的本职工作。

我们重新考虑一下玩家分数变化的模式。衰减、衰减,并不是清空,只是减少,而且不是所有人都会衰减,衰减后对排名的影响也是有限的,原先名列前茅的玩家衰减后可能依然名列前茅,名落孙山还是名落孙山。是否可以考虑划一个稍大的范围,来完成排行榜?

核心:不确定性中的确定性

这里我们假设玩家的加分是实时的(比赛、对局等事件驱动),衰减是延迟的(时间点、延时钩子),那么显然,数据库中的衰减前分数大于等于玩家的实际分数,衰减前分数较高的玩家,衰减后也会处于一个相对较高的位置上。

这里假设我们要排出所有玩家的前500名,可以先order by score desc取一批玩家数据,但不是只取500人,而是乘一个1以上的倍率——倍率选取的考量在下文——这里先取1.5,750人,只对这750人的积分计算衰减后结果,然后排序。

这时取两个数据:750人衰减前分数的最小值min750,以及计算衰减后的排行榜500名分数线line500。

已知玩家的衰减前分数大于等于衰减后分数,那么,数据库中的衰减前分数代表的其实是玩家分数的上界。

换句话说,衰减前分数没有达到line500的玩家,是没有上榜机会的。

回头来看min750。

假如min750小于等于line500,那么没有玩家在衰减后有可能再接触到line500,所以前750人排序出的排行榜已经是准确的了。

如果min750大于line500,那么衰减前分数在[line500,min750]区间范围的玩家仍然有机会挤入这个排行榜。所以,这种情况下只能再IO一次,获取衰减前分数,计算这些玩家的衰减后分数,进行排序。这部分玩家的引入只可能让line500上升而非下降,所以必然不可能需要第三次IO。

至此排行榜完成。

优劣:为什么要这样实现这个功能?

先说劣势。

  • 通过这种方式实现的排行榜无法随时精确得出不在榜单上的玩家的排名。如果有类似需求,只能回归到按时刷新排行榜并延迟更新的老路上去。或者推测分数线进行大致的推测,来满足玩家的晋升感。

之后是优势。

  • 参与排名的玩家可以在数据库交互前、后筛选,都不会影响排行榜结果,只要min750和line500的值正确即可。
  • 数据库可以100%利用索引,IO速度快。所有的计算可以放在本地,数据库上只需对筛选信息(例如地区字段)及积分做索引。
  • 兼容即时刷新和懒加载延时刷新,后台可以随时看到最新的排行榜单。

优化

优化 - 不想再IO:衰减后分数计算

虽然拿到玩家的id,再去查玩家的筛选相关字段,是一种能计算出衰减后分数的做法,但这显然不太优雅。

条件允许的情况下,推荐像上文描述的,把影响玩家衰减后分数的字段(比如最后几场比赛的时间)也放到表中,在获取时一并取出,这样就可以实现纯内存的计算,大大提升速度。

优化 - 倍率:小了会亏,大了浪费

倍率存在的目的是尽量一次取出排行榜。事实上,如果玩家的分数完全不会衰减,那么倍率取1就1没问题,前500就是前500;如果玩家的分数会频繁衰减或衰减得很快,那你的倍率可能要取得大一点,这个要根据业务而定。

做业务不光是打螺丝,不是只有算法岗才会用到数学知识,我是禹安,周末写点好用的业务/流程/中间件心得,下次见。