粉丝关注列表如何设计和落地
业务场景
上图我们简称relation页。relation页展示用户的关系相关信息,包含两个子页面:
- follower页,展示关注该用户的所有用户信息。
- attention页,展示该用户关注的所有用户信息
主要操作
用户可以为自己增加,删除attention,即关注某个其他用户或者对其他用户取消关注。可以删除follower,即取消其他某个用户对自己的关注。
业务特点
- 海量的用户数据。亿级的用户数量,每个用户千级的帖子数量,平均千级的follower/attention数量。
- 高访问量。每秒十万量级的平均页面访问,每秒万量级的帖子发布。
- 用户分布的非均匀。部分用户的帖子数量/follower数量,相关页面访问数量会超出其他用户一到几个数量级
- 时间分布的非均匀分布,某个用户可能突然在某个时间成为热点用户,其follower可能徒增数个量级
一个典型社交类系统的典型特性归结为三个关键词:大数据量,高访问量,非均匀性
relation存储层设计
最简单的就是基于DB存储,只需要两张表即可。
table_relation表:id主键,关注者id,被关注者id
table_user_info表:id主键,用户信息(头像,名称,注册时间,大v认证,手机号等信息)
所以我们这里只需要查询两张表就可以查询出关注,粉丝和用户信息:
select count(1) from table_relation where 关注者id = 'xx';
select count(1) from table_relation where 被关注者id = 'xx';
select * from table_user_info where id = 'xx';
随着用户数量的增多,table_relation和table_user_info表的行数增多。千万,亿级用户,每个用户相关关系百级,那么就需要水平切分。
对于某一个用户的信息查询,首先根据userId计算出它的数据在哪一个分片,再在对应的分片info表里查询到相关数据。userId分片的映射关系有多种方式,例如:hash取模,userId字段的某几个特殊位,一致性hash等等。
这里有一个问题。table_relation根据follower进行拆分,查询某个用户关注的人很容易,因为相同的followerId的数据一定分布在相同的分片上(我关注了谁)。但是一旦需要查询谁关注了某个用户(谁关注了我),这样查询需要路由到所有分片上进行,因为相同的attentionId的数据分散在不同的分片上,查询效率低。由于查询follower和attention的访问量是相似的,所以无论根据followerId还是attentionId进行拆分,总会有一般的场景查询效率低下。
所以针对上述问题,进行垂直拆分,分为follower表和attention表,分表记录某个用户的关注者和被关注者,接下来在对follower表和attention表分别基于userId进行水平拆分。
follower表:主键id,userId,followerId
attention表:主键id,userId,attentionId
这个时候实现查询:
select count(1) from table_follower_xx where userId = 'xx';
select count(1) from table_attention_xx where userId = 'xx';
// 查询用户
select * from table_user_info where userId in (xx);
上述三条语句,前两条可以落在相同的分片上,DB操作次数为两次,但最后一条仍需要查询多次DB。
同时,进行垂直拆分的时候,如果我们要写DB,就提升了一倍。而且上述操作仍然需要count查询,即使在userId上建立了索引仍然会有问题存在:
- 对于某些用户,他们被很多人关注(比如明星几千万的关注),他们在follower表进行count查询的时候,需要在userId上扫描的行数仍然很多,我们称这些用户为热点用户。每一次展示热点用户的关注者数量的操作都是低效的。另一方面,热点用户由于被很多用户关注,他的timeline页面会被高频的访问,使得原本低效的展示操作总是被高频的访问,性能风险进一步扩大。
- 当某一个用户的follower比较多的时候,通常会在relation页面里无法一页展示完,因此要进行分页处理,每一页显示固定数量的用户,然而DB实现分页的时候,扫描效率随着offset增加而增加,使得这些热点用户的relation页展示到最后几页的时候变得低效。
- 用户详细信息的展示,每次展示relation页面的时候,需要对每个follower或者attention分别查询info表,使得info的查询服务能力无法随着info分片线性增加。被attention限制住了。
引入缓存
针对上面的问题,我们这里就需要引入缓存来解决。
同时,在DB层面,我们可以在userInfo表的信息中,将每个用户的关注者和被关注者的数量存入DB,这样一来,对于timeline页和feed页的relation摘要展示仅仅通过查询userInfo表即可完成。
同时可以在缓存中也添加上关注者和被关注者的数量。一些大V账号,我们也可以进行服务器端的本地缓存进行二级缓存。
引入缓存之后的业务操作实现方式也相应做了调整:
- 某用户timeline/feed页面的relation摘要信息展示:展示方式首先分局用户作为key查询缓存,未命中的时候,再查询DB。
- 某用户relation页面详细信息展示,分为两个子页面:follower列表展示和attention展示:
- 同样先查询follower和attention的缓存,对于频繁被查询的热点用户,它的数据一定在缓存中,由此将DB数据量最多,访问频度最高的用户档在缓存外。
- 对于每个用户的info信息,热点用户由于被更多的用户关注,他更有可能在详情页面被查询到,所以这类用户总在本地缓存中能够被查询到,本地缓存设置一个不长的过期时间。
热点用户需要考虑的实时性问题
对于热点用户的follower详情页,由于热点用户过长的缓存list,它们每次被查询都有极高的网络传输,同时因为热点,查询频率也更高,加重了网络负担。info查询中follower和attention的数量随时变化着,为了使得查询的数值实时,系统需要在尽量间隔短的时间重新进行count,对于热点用户,如果期望实现秒级数据延迟,那么意味着每秒需要对百万甚至千万级别的数据进行count。如何解决这些动态变化着的数据的大访问量,实时性成为挑战。
新增的用户是放在最前面的,往往我们最前面的N页查询是比较频繁的,占总查询的百分之99.
我们就可以去单独分出来一个list页,在redis中,我们把新增的关注用户放在list页,我们可以直接去查询这块新增数据。
剩下后面的数据的话,我们可以去查询redis list查询,也可以去查询db。
db层面做sql优化,时间会短一点,同时我们可以查询后面用户的数据。我们也可以存在list中,时间设置的稍微短一些,这样方便。
当需要查看某个用户的relation详情页时,涉及对follower和attention列表的分页查询。通常单个用户的关注的人数量有限,绝大用户在1000以内,且每次查询对第一页查询的频率远高于后面的页,那么无论直接将列表存入DB或者是缓存中,都能做到较高的吞吐量。但是对于热点用户的follower,情况就比较复杂一点:follower的数量是不可控的,即使是小概率的翻页操作,对于follower很多热点用户,仍然是高访问量的操作;且每次翻页的扫描成本很高。单个分布式缓存的value列表无法承载过长的follower列表。
针对热点用户的follower列表查询问题,采用基于增量化的实现辅助解决。首先,同一个follower列表的前N页(假设5页)的访问频率占用到总访问量的绝大部分。而前N页的follower个数是常数个;其次follower列表的展示以follow时间进行排序,最近加入的follower通常排在最前面,即增量化模块的最新数据最有可能放在首N页。作为增量化的消费者每次拉取的最近N页条变更事件直接存入热点用户的follower缓存中。
最后最重点的一个问题,如果大V上了一个综艺或者拍了一个电视剧,短时间内突然爆火。导致粉丝关注越来越多从几百万或者一千万直接干到四五千万了,这时候要怎么办呢?如果把他的关注列表和粉丝列表都放在和普通用户的关注列表和粉丝列表一个分片下,是会影响正常用户的性能的,所以我们要怎么做呢?
这里我们如果碰到这种大V了,我们可以进行数据迁移,把大V单独放在一个放到一个分片上,这样就不会影响正常用户的操作了。
由此我们可以看到,微博的大v认证是从产品的角度解决是技术的问题,是一举两得的解决方案,微信红包的抢拆分离也是同样的效果。