阅读 210

全网最好的高并发微博架构设计实现和落地

全网最好的高并发微博架构设计实现和落地

关注如何设计和实现

业务场景

微博相信大家都用过,如下图:

image.png微博最基本的就是关注,被关注以及查看。发动态,浏览动态,点赞,评论,收藏等。

上图我们简称relation页。relation页展示用户的关系相关信息,包含两个子页面:

  1. follower页,展示关注该用户的所有用户信息。

  2. attention页,展示该用户关注的所有用户信息。

image.png

image.png

主要操作

在上述页面中,用户可以为自己增加,删除attention,即关注某个其他用户或者对其他用户取消关注。可以删除follower,即取消其他某个用户对自己的关注。

业务特点

  1. 海量的用户数据。亿级的用户数量,每个用户千级的帖子数量,平均千级的follower/attention数量。

  2. 高访问量。每秒十万量级的平均页面访问,每秒万量级的帖子发布。

  3. 用户分布的非均匀。部分用户的帖子数量/follower数量,相关页面访问数量会超出其他用户一到几个数量级

  4. 时间分布的非均匀分布,某个用户可能突然在某个时间成为热点用户,其follower可能徒增数个量级

一个典型社交类系统的典型特性归结为三个关键词:大数据量,高访问量,非均匀性

relation的存储

最简单的就是基于DB存储,只需要两张表即可

table_relation和table_user_info

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取模的一致性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 wehre userId in (xx);

上述三条语句,前两条可以落在相同的分片上,DB操作次数为两次,但最后一条仍需要查询多次DB。

同时,进行垂直拆分的时候,如果我们要写DB,就提升了一倍。而且上述操作仍然需要count查询,即使在userId上建立了索引仍然会有问题存在:

  1. 对于某些用户,他们被很多人关注(比如明星几千万的关注),他们在follower表进行count查询时,需要在userId上扫描的行数仍然很多,我们称这些用户为热点用户。每一次展示热点用户的关注者数量的操作都是低效的。另一方面,热点用户由于被很多用户关注,他的timeline页面会被更频繁的访问,使得原本低效的展示操作总是被高频的访问,性能风险进一步扩大。

  2. 当某个用户的follower较多时,通常在relation页面里无法一页展示完,因此需要进行分页显示,每一页显示固定数量的用户,然而DB实现分页时,扫描效率随着offset增加而增加,使得这些热点用户的relation页展示到最后几页的时候,变的低效。

  3. 用户详细信息的展示,每次展示relation页面时,需要对每个follower或者attention分别查询info表,使得info的查询服务能力无法随着info分片线性增加。

引入缓存

针对上面的问题,我们这里就需要引入缓存来解决。

同时,在DB层面我们可以在userInfo表的信息中,将每个用户的关注者和被关注者的数量存入DB,这样一来,对于timeline页和feed页的relation摘要展示仅仅通过查询userInfo表即可完成。同时可以再缓存中也添加上关注者和被关注者的数量。一些大V账号,我们也可以进行服务器端的本地缓存进行二级存储。

引入缓存之后的业务操作实现方式也相应做了调整:

  1. 某用户timeline/feed页面的relation摘要信息展示:展示方式首先根据用户作为key查询缓存,未命中时,再查询DB。

  2. 某用户relation页面详细信息展示,分成两个子页面:follower列表展示和attention展示:

    1. 同样首先查询follower和attention的缓存,对于频繁被查询的热点用户,它的数据一定在缓存中,由此将DB数据量最多,访问频度最高的用户挡在缓存外。

    2. 对于每个用户的info信息,热点用户由于被更多的用户关注,他更有可能在详情页面被查询到,所以这类用户总在本地缓存中能够被查询到,本地缓存设置一个不长的过期时间。

对于热点用户的follower详情页,由于热点用户过长的缓存list,它们每次被查询都有极高的网络传输,同时因为热点,查询频率也更高,加重了网络负担。info查询中follower和attention的数量随时变化着,为了使得查询的数值实时,系统需要在尽量间隔短的时间重新进行count,对于热点用户,如果期望实现秒级数据延迟,那么意味着每秒需要对百万甚至千万级别的数据进行count。如何解决这些动态变化着的数据的大访问量,实时性成为挑战。

对于count来说,我们引入缓存,把count写入到缓存中,订阅用户收到的变更事件。增加和删除写缓存,异步去写数据库。同时通过对增量化模块中的每个事件记录产生的版本(也可以根据时间本身自增来实现),和对计数器每个key进行版本记录,可以实现去重防丢失等需求。

当需要查看某个用户的relation详情页时,涉及对follower和attention列表的分页查询。通常单个用户的关注的人数量有限,绝大用户在1000以内,且每次查询对第一页查询的频率远高于后面的页,那么无论直接将列表存入DB或者是缓存中,都能做到较高的吞吐量。但是对于热点用户的follower,情况就比较复杂一点:follower的数量是不可控的,即使是小概率的翻页操作,对于follower很多热点用户,仍然是高访问量的操作;且每次翻页的扫描成本很高。单个分布式缓存的value列表无法承载过长的follower列表。

针对热点用户的follower列表查询问题,采用基于增量化的实现辅助解决。首先,同一个follower列表的前N页(假设5页)的访问频率占用到总访问量的绝大部分。而前N页的follower个数是常数个;其次follower列表的展示以follow时间进行排序,最近加入的follower通常排在最前面,即增量化模块的最新数据最有可能放在首N页。作为增量化的消费者每次拉取的最近N页条变更事件直接存入热点用户的follower缓存中。

image.png

帖子(post)的设计实现与优化

一般存储帖子,通常都是在MySQL中进行存储。大概有几个字段:postId,userId,postTime,content

其中:

  • userId记录这个帖子是谁发的

  • postTime记录发布时间。这里只需要精确到秒即可(同一个用户一秒之内发送的帖子数通常不多于一条)

  • content记录帖子内容

我们可以对userId+postTime建立二级索引,使得查看特定用户按照时间排列的所有帖子的操作变的更快。

select * from post where postId=?查询详情

select * from post where userId=? and posttime between ? and ?根据用户和事件查询

image.png

image.png

采用上面查询的话,会有一个严重的问题就是存在严重的回表(对二级索引查到的每条记录都需要到聚集索引中重新查询主数据)问题,降低DB的吞吐量。所以,可以将userId和postTime信息冗余到postId中,去掉二级索引减少回表

postId可用采用首6位位userId,每一位是0-9/A-Z这36个字符中的某一个,6位可以表示21个不同的用户,6位表示时间戳,后续时间戳(精确到秒)可以标识70年范围内的任意一秒,单个用户每秒发放的帖子不超过两位seq表达的最大值。14位的postId可以适用于本设计系统的规模。其中对于timeCompress的计算,可以设计为:

  • 帖子发布的时间减去sns系统初次发布的时间点中间间隔的秒,进行36进制编码。

  • 这样设计之后,timeCompress的字母序随着时间(粒度为秒)连续递增,可以充分利用DB的范围扫描。

所以我们查询某个用户发送的帖子的列表的场景,SQL变成了:

select * from post where postId between postId1 and postId2

或者

select * from post where postId like "xx%"

由于查询的是同一个用户的帖子,所以所有postId的前缀是相同的,如果查询这个用户某个时间范围内的帖子,那么6位timeCompress的前面几位也相同。这么一来就避免了回表的尴尬。

随着帖子数量的增加,单机DB的数据量和吞吐量达到了上限,此时引入水平拆分使得数据量和吞吐量线性伸缩

,所以进行水平拆分,根据userId进行水平拆分。由于postId的前缀中完全包含了userId的信息。所以postId可以独立作为路由运算的单元。

但是某些热点的user的读取量巨大,他们被路由到相同的DB上,后者也可能存在读取瓶颈。为此,常见的方式为:读写分离。采用1写N读,利用DB自身同步机制做主备复制。每次读取随机选取N个读库中的一个。

基于读写分离的DB解法存在两个问题:

  1. 采用读写分离之后存在数据延迟问题

  2. 较早的数据极少访问。而一旦读写分离,意味着每一条记录都需要存储多份。当这些数据刚发布的时候,访问频繁,随着时间的推移,他们就不再被访问了。那么百分之99的数据副本不会被读取到,存储效率低。

所以这里我们引入缓存,缓存的数据过期机制天然避免了陈旧数据对空间的占用。

这里我们如何设计Redis的key-value呢?我们不难发现,同一个用户一天发出的帖子数量是有限的,通常不超过10条,平均3条左右,单个用户一周发的帖子很难超过100KB,极端情况下1MB,远低于Redisvalue大小的上限。所以:

key:userId+时间戳(精确到星期)

value:Redis为hash类型,field为postId,value为帖子内容

expire设置为一个星期,即最多同时存在两个星期的数据(假设每贴平均长度0.1KB,1亿用户每天发3贴预计数量为400GB)

对某个用户一段时间范围的查找变为针对该用户本周时间戳的hscan命令,用户发帖等操作同时同步更新DB和缓存,DB的变更操作记录保证一致性。

但是,有些热用户的follower数量极高,意味着这个热点用户所在Redis服务器的查询频率为1000万每秒。

所以这里我们再进一步引入本地缓存来缓解服务端缓存压力。当一些比较热点的用户查询比较频繁的时候,我们可以直接把热点用户存入本地缓存中。用来缓解服务端的缓存的压力

点赞业务如何落地

根据点赞业务特点可以发现:

  1. 吞吐量高

  2. 能够接受数据不一致

如果只考虑点赞可以怎么做?

可以用MySQL做持久存储,Redis做缓存,读写操作落缓存,异步线程定期刷新DB。

counter表:id,postId,count

RedisKV存储:key:postId value:count

但是以微博为例,不仅有点赞还有转发数,评论数,阅读量等。所以,业务拓展性和效率问题是难点,如果这么设计,我们就需要多次查询Redis,多次查询DB。

假设首页有100条消息,就需要for循环每一条消息,每条消息要进行4次Redis访问进行拼装。

所以这里我们进行优化的话,可以在MySQL层面进行增加列优化

conunter表:id,postId,readCount,forwordCount,commentCount,PraiseCount

这样增加的话,缺点是如果增加一个计数服务的话,列就需要改变。那么不想改变列怎么办呢?

conunter表:id,postId,countKey(计数类型名称,比如readCount),countValue

Redis来获取业务的话,可以存储为hash用来计数。

关注用户发帖子了,要如何设计实现和落地

在微博,贴吧,掘金等平台,我们都可以对喜欢的人进行关注,关注了之后,这些人更新帖子,我们这边就会看到,按照时间进行排序和分页。

image.png

随着用户的relation的变化,某个用户的timeline也随之变化。而一个用户对自己帖子的增删,会影响多个其他用户的timeline。可见timeline的内容受用户间relation影响,当relation关系复杂时,timeline的性能将会受到挑战。

如何实现

经典的有两种实现:push和pull实现,首先讨论push模式

push模式

push模式的特点是:用户每次新增一条帖子,将此帖子推到他的follower所在DB分片上,follower在每次浏览timeline时,直接查询自己分片所存储的数据。

所以这里就需要一个timeline表和post表做对应

timeline字段包括:id,userId,postId,postTime

timeline表的约束是userId+postId,同一个用户的timeline下相同的一条帖子只能出现一次。

用户根据时间浏览自己timeline下一定时间范围内的帖子,落在userId所属的DB分片上:

select postId from timeline where userId='xx' and postTime between ? and ?;

select * from post where postId in (...);

用户关注了新用户:在新用户所在分片将postId获取,并插入到用户的timeline表中。

用户取关了新用户:delete from timeline where userId='xx' and postId like 'B%';

上述基于DB的push方案实现面临一个困难的场景:A用户关注的B用户的发布/删除了自己的帖子时,除了删除B本身的post表之外,需要插入/删除E的timeline表。假设B是一个热点用户,他拥有上亿的follower,那么这个用户的每一次新增删除帖子操作,将会被复制上亿次,造成增删帖子瓶颈。

pull模式

和pull模式不同,pull模式下用户每次新增/删除不需要同步到他的所有follower,所以不存在push模式下热点用户增删帖子瓶颈。但是每个用户查询一段时间的timeline时,需要同时查询其所有的关注的用户近期帖子列表。

pull的实现很简单,并不需要单独新增timeline表,每个用户自己发出的帖子都维护在post表和缓存中。pull模式下针对写操作,没有额外的开销,但是对于更加频繁的读操作(用户查看一段时间内所有关注用户的帖子)时,需要用户对自己的所有关注对象的帖子进行按时间的扫描。假设每个用户平均关注500人,那么每次用户刷新timeline页面将进行100次扫描(假设有100个DB分片。一个大型的社交系统,假设同时在线100万人,平均每10s刷新一次页面,那么DB的查询压力将是每秒1亿次查询,仅仅依靠DB,需要上万的DB实例。

这里pull模式是有一个可以优化的点的,在我们关注列表中,其实大部分都是大v,意味着很多用户其实同一时间被多个follower的timeline查询,那么这些并发的关注查询可以只访问一次DB,可以把同一时间的查询进行合并,不过这样其实优化是甚微的。

push和pull结合

单纯基于pull和push模式都是有缺陷的,所以这里可以把push和pull结合一下来优化。

当某个用户发布一个帖子的时候,只需要将post同步到100个数据库分片上(假设有100个数据库分片),假设存储的副本的表叫post_rep,它至少需要三个字段:posterId,postId,postTime。push份数可控(无论多少个follower,只需要复制100份)。数据库按照timeline所属用户进行分片,那么每个用户只需要一次DB查询,查询数量得到了控制。同样1000万用户同时在线,10s刷新一次,单台DB分片的查询频率为每秒1万次,落到DB能够承受的正常范围。

假设每个用户每天平均发布3条帖子,且都集中在白天的8小时,系统总共1亿个用户,那么每10秒将会有10万条新的post插入,每个用户500个关注,预计每20秒才会在timeline中出现一条新帖子。假设每次timeline更新如果查询的时间范围就是最近10秒,那么采用推拉结合的方式,每个用户每次扫描10万条记录,却只从中选出平均0.5条新纪录,扔存在一定的性能风险。

push和pull结合全量化查询

对于每个用户已经查询过的timeline,可以将其存储并标注"最后查询时间",使得每次timeline刷新时只需要查询"最后查询时间"之后的记录。

对timeline查询结果的存储可以采用缓存完成,称为timeline的最近查询缓存。缓存中可能存在已经删掉的post,但由于缓存仅存储postId,这些删掉的post在根据postId查询post阶段将会过滤掉,不影响查询结果。timeline最近查询缓存通过一定过期时间保证容量可控。

加入缓存

虽然上述用push和pull结合方式已经做到了很大的优化,但是每秒的DB访问频率也让DB很难承受,所以这里也需要加入缓存。

可以在内存中的缓存保存了多个"增量列表":

  • 每个列表的元素是用户ID

  • 每十秒产生一个新的用户列表,这10秒内所有发过帖子的用户的ID都在本列表内

  • 同一个列表内的用户ID按照顺序排列,并用B+树。

作为缓存,增量查询部分不会保留数据。但由于每个用户默认保留了上次查询的结果,可以认为增量部分不会查询太老的数据,假设保留1小时。每10秒如果算10万帖子的话,最多10万不同的用户ID,存储每个ID大约占用32-50个字节,缓存一小时的数据大约占用1.6GB

社交Feed缓存架构设计和落地

对于中小型的Feed系统,feed数据可以直接通过同步push模式进行分发。用户每发表一条feed,后端系统根据用户的粉丝列表进行全量推送,粉丝用户通过自己的inbox来查看最新的feed。

image.png

但是像微博这种大型的Feed系统。一般都是采用pull+push模式,用户发表feed后首先写入消息队列。由队列处理进行异步更新,更新时不再push到所有粉丝的inbox,而是存放到发表者自己的outbox;用户查看时,通过pull模式对关注人的outbox进行实时聚合获取。基于性能方面的考虑,部分个性化数据仍然先push到目标用户的inbox。用户访问时,系统将自己的inbox和他所有关注人的outbox一起进行聚合,最终得到feed列表。

一个feed调用流程需要进行如下操作:

  1. 根据用户uid获取关注列表

  2. 根据关注列表获取每一个被关注者的最新微博ID列表

  3. 获取用户自己收到的微博ID列表

  4. 对这些ID列表进行合并,排序以及分页处理后,拿到需要展现的微博ID列表

  5. 根据这些ID获取对应的微博内容

  6. 对于转发feed进一步获取源feed的内容

  7. 获取用户设置的过滤条件进行过滤

  8. 获取feed作者的user信息进行组装

  9. 获取请求者对这些feed是否收藏,是否赞等进行组装

  10. 获取这些feed的转发,评论,赞等计数进行组装

  11. 组装完毕,转换成标准格式返给请求方

Feed请求需要获取并组装如此多的后端资源数据,同时考虑用户体验,接口请求耗时要在100ms以下,因此Feed系统需要大量使用缓存(cache),并对缓存体系进行良好的架构。缓存体系在Feed系统占有重要位置,可以说缓存设计决定了一个Feed系统的优劣。

一个典型的Feed系统的缓存设计主要分为:INBOX,OUTBOX,SOCIAL GRAPH,CONTENT,EXISTENCE,COUNTER共六部分。

Feed id存放在INBOX cache和OUTBOX cache中,存储格式是vector(有序数组),如图:

image.png

其中INBOX缓存层用于存放聚合效率低的feed id。当用户发表只展现给特定粉丝,特点成员组织的feed时,Feed系统会首先拿到待推送(push)的用户列表,然后将整个feed id 推送给对应的粉丝的inbox。因此inbox是以访问者UID来构建key的,其更新方式是先gets到本地,变更后再cas到异地缓存。

OUTBOX缓存层用于直接缓存用户发表的普通类型feed id,整个cache以发表者UID来构建key。其中outbox又主要分为vector cache和archive data cache;vector cache;vector cache用于缓存最新发表的feed id,comment id等,按具体业务类型分池放置。如果用户最近没有发表新feed,vector cache为空,就要获取archive data里的feed id

SOCIAL GRAPH缓存层主要包括用户的关注关系及用户的user信息。用户的关注关系主要包括用户的关注列表,粉丝列表,双向列表等。

CONTENT缓存层主要包括热门feed的content,全量feed的content。热门feed是指热点事件爆发时,引发热点事件的源feed。由于热门feed被访问的频率大于普通feed,比如微博中单挑热门feed的QPS可能达到数十万的级别,所以热门feed需要独立缓存,并缓存多份,以提高缓存的访问性能。

EXISTENCE缓存层主要用于缓存各种存在性判断的业务,比如是否已赞,是否阅读这类需求。

COUNTER缓存用于缓存各种计数。Feed系统中计数众多,如用户的feed发表数,关注数,粉丝数,单挑feed的评论数,赞数,阅读数,话题相关计数等。

简单数据类型的缓存设计

一般系统中的大部分数据都是简单的KV数据类型。这些简单数据类型只需要进行get,set操作,不会用于特殊的记数操作,最适合用Memcached作为缓存组件。在选用某个中间件的时候,我们需要进行容量规划,分析业务数据的size,cache数量,峰值读写等。来确定缓存容量的大小,节点数,部署方式等。

评估表:业务名称,用途,单位(user),平均size,数量,峰值读(QPS),峰值写(QPS),命中率,过期时间,平均穿透加载时间。为了保证服务的可用性。让请求不会大批量的穿透到DB层,给DB带来巨大的压力,极端情况下会引发雪崩,可以引入Main-HA双层架构。

image.png

对后端数据的缓存访问,会先访问Main层,如果miss继续访问HA层,如果HA层命中,则返回结果,再将value回写到Main层,后续对相同的key的访问可以直接在Main层命中。由于Main-HA两层cache中数据不尽相同,通过Main-HA结构,业务可以获取更高的命中率。同时即便出现部分Main节点不可用,也可以通过HA层保证缓存的命中率,可用性

对于微博这种会经常发生超热点事件的情况,可以再加一层L1,L1缓存是小容量缓存,每组L1缓存的容量为Main层的三分之一 - 六分之一。client访问时,首先随机选择一个L1缓存进行访问,如果miss再按照Main->HA的顺序以此访问。由于L1的内容容量远远小于Main,稍冷的数据会迅速剔除,所以L1会持续存储最热的数据,而且L1有多组,大量热数据访问会平均分散到多个L1。

集合数据类型的缓存设计

对于需要计算的集合类数据,如Feed系统中用户关系中的关注列表,分组,最新粉丝列表等,需要进行分页获取,关系计算,从而满足诸如"共同关注","我关注的人里谁也关注了TA",可以用Redis进行缓存。Redis提供了丰富的集合类存储结构,支持list,set,zset,hash等。

Redis的架构已经是很完善的了,可以用高可用集群架构进行部署访问。不过,在大集合数据进行全量读取的场景,如用hgetAll获取数据,单次请求可能需要数百上千的元素,用过这个的同学都知道,hgetAll是一个比较坑的东西,容易卡死或者宕机。所以对于获取所有元素这块,我们可以在Redis的上层加一层Memcached,或者直接存在Redis中,单独存一份xx-All,全量查询的时候,查询Memcached或者Redis中后缀-All的,这样可以很好的提升系统的读取性能,还可以减少slave数量。

计数,判断数据类型的缓存设计

在Feed系统中还有一些业务数据,无法直接当做简单数据类型或集合数据类型。比如判断性业务,判断一个用户是否赞了某条feed,是否阅读了某条feed等。如果直接缓存,会需要消耗大量的内存。还有计数类业务,存储一个key为8字节,value为4字节的计数。如果用Redis来存储,每日新增n条记录,每日的成本可能会高达数百G,这成本是很高的。所以微博的话,在Redis上进行了扩展,新增了ConunterService和Phantom两个组件。

ConunterService组件

CounterService 的主要应用场景就是计数器,比如微博的转发、评论、赞的计数。采用Redis进行KV存储的话,大概一条KV需要至少65个字节,上面说了,存储一条计数服务,KV加起来是12个字节,其余四十多个字节都被浪费掉了。一条微博,通常有好几个计数:比如转发数,评论数,点赞数。而一天微博会新增无数的帖子。

ConunterService存储结构采用上面是内存下面是 SSD,预先把内存分成 N 个 Table,每个 Table 根据微博 ID 的指针序列,划出一定范围。任何一个微博 ID 过来先找到它所在的 Table,如果有的话,直接对它进行增减;如果没有,就新增加一个 Key。有新的微博 ID 过来,发现内存不够的时候,就会把最小的 Table dump 到 SSD 里面去,留着新的位置放在最上面供新的微博 ID 来使用。如果某一条微博特别热,转发、评论或者赞计数超过了 4 个字节,计数变得很大超过限制该怎么处理呢?可以把这些放到Aux Dict 进行存放。对于落在 SSD 里面的 Table,可以有专门的 Index 进行访问,通过 RDB 方式进行复制。根据时间维度,近期的热数据放在内存,之前的冷数据放在磁盘,降低机器成本。如果之前的冷数据被频繁的访问则放到LRU缓存进行加载。加载的时候采用异步IO,不会影响服务的整体性能。

Phantom组件

微博还有一种场景是“存在性判断”,比如某一条微博某个用户是否赞过、某一条微博某个用户是否看过之类的。这种场景有个很大的特点,它检查是否存在,因此每条记录非常小,比如 Value 用 1 个位存储就够了,但总数据量又非常巨大。比如每天新发布的微博数量在 1 亿条左右,是否被用户读过的总数据量可能有上千亿,怎么存储是个非常大的挑战。而且还有一个特点是,大多数微博是否被用户读过的存在性都是 0,如果存储 0 的话,每天就得存上千亿的记录;如果不存的话,就会有大量的请求最终会穿透 Cache 层到 DB 层,任何 DB 都没有办法抗住那么大的流量。

Phantom 跟 CounterService 一样,采取了分 Table 的存储方案,不同的是 CounterService 中每个 Table 存储的是 KV,而 Phantom 的每个 Table 是一个完整的 BloomFilter,每个 BloomFilter 存储的某个 ID 范围段的 Key,所有 Table 形成一个列表并按照 Key 范围有序递增。当所有 Table 都存满的时候,就把最小的 Table 数据清除,存储最新的 Key,这样的话最小的 Table 就滚动成为最大的 Table 了。

当一个 Key 的读写请求过来时,先根据 Key 的范围确定这个 Key 属于哪个 Table,然后再根据 BloomFilter 的算法判断这个 Key 是否存在。

计数服务扩展

上面说了用db+cache的方式来完成计数服务。在一些软件中,也有用cache直接存储计数的。定期清理,只不过是缓存的周期稍微长一些。比如QQ空间的点赞。你会发现过很长的时间再回头一看,以前点赞的数据都没了。剩下只有新帖子还有点赞数据。

对于并发特别高数据量特别大的服务不够友好。所以上面说了可以用ConunterService,当然,这个是微博自己的,还没有开源。这里的话,我们可以简单自己对Redis做以下小小的优化

struct item {
    int64_t id;
    int star_num; //点赞
    int repost_num; //转发
    int comment_num; //评论
};复制代码

存储数字,而不是字符串,没有多余的指针存储

程序启动时开辟一大片内存。插入时,

h1 = hash1(item.id);
h2 = hash2(item.id);复制代码

查询时,比较id和item.id是否一致,一致就表示查到。不一致表示值为0;

删除时,查找到所在位置,设置特殊的标志;下次插入时,可以填充这个标志位,以复用内存。

这里其实一般转发数和评论数都不会很多,几百几千已经是很多了,超过十万,比如鹿晗官宣恋情这种基本上没几个。所以这里还可以做一个优化:

struct item{
    int64_t id;
    int star_num;
    unsigned short repost_num; 转发数
    unsigned short comment_num;评论数
};复制代码

对于转发数和评论数大于65535的帖子,我们在这里记录一个特殊的标志位,然后去另外的dict中去查找。

这样就大大的节省了内存的占用。

上面讲的也是一个优化的点,当然不是所有公司都有这么大的体量,也不是所有公司需要优化中间件来解决问题。所以这就是一个思考的点。而且上述我说的还可以继续进行优化。


作者:Five在努力
链接:https://juejin.cn/post/7032512073502294053


文章分类
代码人生
文章标签
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐