用户积分功能的设计
有一个SNS应用,用户在使用的过程中积累积分,例如登陆+3点,个人空间每次浏览+1点,结交每个朋友+5点等等。同时,很重要的一点是,用户需要看到自己的积分累计有多少,能够根据积分划分用户等级,在自己的空间展示积分。
在用户量比较大的情况下(例如超过三千万),这是一个比较典型的读写都很频繁的问题,而且写入的次数可能和读取的次数差别不大(大多数SNS应用中,读次数远超写次数的场景居多,例如用户的状态信息,更新一次以后有成千上万的访问)。
这实际是一个简单,但是典型的功能。试想,给文章投票(例如“顶”一下),给微博统计访问次数,给媒体打分……这些都是非常类似的功能。对于这样问题的思考和设计,考虑到典型性和推广性,是很有价值的。
需要准确的数据?
在常规情况下,我们可以自问自答这样几个关于产品设计约束的问题。例如:
这些问题,都是需要在产品设计阶段考虑清楚的。当然,从技术实现的角度来说,对于这种大用户量的积分功能的设计,实时性要求越低,越容易实现。我们可以把用户分为多个级别,如果只有那些top用户的排名才显得很重要,那么可以区分对待,例如对于100名以内的用户排名需要实时计算,那么实际可以实时计算200名以内的用户排名(其中的后100名用户视作缓冲,可能会和前100名用户排名发生交换)。
数据结构设计
这里谈论数据结构的设计主要是考虑到在如此高的读写频率下,我们需要在内存里面存放一部分或全部的用户积分信息,以减少对数据库或者文件存储系统的压力。
map
最常见的一种数据结构就是使用一个LRU的map,需要获知积分数据的时候,先根据用户的id去这个map里寻找积分数据。排名的问题要麻烦一些,如果非实时要求,可以定期、单独计算排名,例如每天凌晨1点算一次排名。对于那些top用户,他们的排名信息也和积分数据一样,使用一个LRU的map缓存在内存里面。在数据量不是非常大的情况下,所有的积分、排名信息都可以存储在内存中。
这个map如果对并发性能要求高,可以自己设计读写算法,也可以寻找开源实现。对于JDK中的ConcurrentHashMap,我曾经测试过,它的写入速度要略好于没有加同步保护的HashMap,但是读取速度大大低于没有同步保护的HashMap(大约只有一半)。
数组
使用数组也是适合的,特别是在用户id分布规律的时候(例如从1到999999),只需要一个大小为1000000的数组,由于访问是随机访问,非常快。在设计排名的时候,数组有一个妙用:用户积分的变化往往不大,例如从1024分变到1026分,但是如果要考虑排名实时性,就不得不把整个用户的排名重新计算一遍,这显得杀鸡用牛刀。如果把数组设计以存储排名信息,但是数组的下标不是表示用户id,而是表示积分:

例如上图,左侧是数组下标,右侧的中括号里表示的是排名,右侧逗号分隔的数表示的是相应的用户id。现在id为50的用户积分1024点,因为某个操作而增加了两点,变成了1026点,他的排名只需要计算上述三个蓝色表示的数组元素所存储的排名就够了,也就是说,积分少量的变化不需要进行整个数组排名的重计算。
树
树是一种有趣的办法,它的好处在于通过节点的分裂和合并,使得其的结构和节点内元素数量总是处在一个合理的位置:

每一个非叶子节点(实线)都分裂出若干个节点,直到叶子节点(虚线)为止。每个叶子节点关联若干个用户id,例如上图中,积分为100077的用户包括了403、7886和36651三位用户。有很多构建和调整这棵树的策略,例如:
我们当然也可以参照一些经典的关于树的数据结构的方案来思考,但总的来说,实现略有复杂,但是这种设计方法有较好的适应性,对于不同的积分-用户分布的情况,同一深度的节点所对应的积分区间各不相同,但总是让每个节点下的用户节点的数量保持在一个可接受的范围内。
如果要获知用户所在的排名,就需要遍历树的节点:找到该用户所在节点所有左侧的兄弟节点和所有递归父节点的所有左侧兄弟节点下所有的用户数量(下图中红色叶子节点存储了需要统计的用户id,蓝色节点所包含的所有用户id数量应当被统计进去):

数据库的选择和设计
数据库(甚至使用某种文件系统)是必不可少的,以持久化积分或排名的数据。通常情况下,这张表的读取和写入频率都很高,考虑一些常见的优化方式:
稍微多说一下关于功能独立的问题,把统计和映像功能独立到其它的服务器上去:

snapshot的作用仅仅是给当前数据做镜像,在很多情况下,用户的积分变更记录我们需要获知,但是也许不需要获取每次的变更,只需要定期取得这样一个镜像(包括数据库的状态)就可以了;而statistics是进行数据统计挖掘的服务器,数据尽可能从snapshot中获取,以免对主数据库造成影响。
批量和异步
批量和异步都是牺牲实时性和一致性来换取性能的做法。对于用户的评分,不需要每次都实时写入数据库,完全可以积攒到一定的程度批量写入,而且,数据库关心的应当是写入请求提供的增量数据,如“用户xxx评分增加15点”,而不是“应将总点数66553写入数据库”:

如上例,多台server中,每台机器都会触发事件将用户的积分信息批量写入数据库,一旦满足:
在读取环节,选用适当的缓存框架(特别是分布式和多层的缓存框架),可以帮助提高读取性能。需要注意的是缓存数据的过期条件,尤其是在集群环境中。有这样一个常见的场景,用户一个登陆操作增加了自己的积分,也许对于其他用户来说也许实时性并不重要,但是你肯定希望用户自己能立刻发现自己的积分上涨了。这时,积分的增加至少要在当前用户会话所在的服务器机器上体现出来,其他集群机器则可以延迟一些。
文章系本人原创,转载请保持完整性并注明出自《四火的唠叨》