有两种实现方式:push和pull实现,首先讨论push模式
概念
我们在讲如何设计Feed流系统之前,先来看一下Feed流中的一些概念:
Feed:Feed流中的每一条状态或者消息都是Feed,比如朋友圈中的一个状态就是一个Feed,微博中的一条微博就是一个Feed。
Feed流:持续更新并呈现给用户内容的信息流。每个人的朋友圈,微博关注页等等都是一个Feed流。
Timeline:Timeline其实是一种Feed流的类型,微博,朋友圈都是Timeline类型的Feed流,但是由于Timeline类型出现最早,使用最广泛,最为人熟知,有时候也用Timeline来表示Feed流。
关注页Timeline:展示其他人Feed消息的页面,比如朋友圈,微博的首页等。
个人页Timeline:展示自己发送过的Feed消息的页面,比如微信中的相册,微博的个人页等。
push模式
push模式的特点是:用户每次新增一条贴子,将此贴子推到他的follower所在DB分片上,将此用户推到他的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进行结合
我们可以对大V采用拉模式,普通用户采用推模式。对活跃粉丝采用推模式,非活跃粉丝采用拉模式(这种方式可以较好的避免大流量对平台的冲击)
但是这种架构是有一个很大的风险的:例如某一个大V突然发了一个很有话题性的Feed,那么可能会导致整个Feed产品中的所有用户都无法读取新内容了,原因是这样的:
- 大V发送Feed消息
- 大V使用拉模式
- 大V的活跃粉丝开始通过拉模式读取大V的新Feed
Feed内容太有话题性了,快速传播。未登录的大V粉丝开始登录产品,登录进去后自动刷新,再次桶读3步骤读取大V的Feed内容。
结果就是,服务崩了,服务雪崩。
完全使用推模式可以完全解决这个问题,但是会带来存储量的增大,大V微博发送总时间增大,从发送给第一个用户到发送给最后一个用户可能要花费几分钟的时间(一亿用户,100万行每秒,需要100秒),还要为最大并发预留好资源,如果使用表格存储,因为是云服务,则不需要考虑预留最大额度资源的问题。
还有一种解决方案就是把用户分为活跃用户和非活跃用户,但是这样做的缺点就是系统设计复杂性就上来了。
加入缓存
虽然上述用push和pull结合方式已经做到了很大的优化,但是每秒的DB访问频率也让DB很难承受,所以这里也需要加入缓存。
- 每个列表的元素是用户ID
- 每十秒产生一个新的用户列表,这10秒内所有发过帖子的用户的ID都在本列表内
- 同一个列表内的用户ID按照顺序排列,并用B+树。
作为缓存,增量查询部分不会保留数据。但是由于每个用户默认保留了上次查询的结果,可以认为增量部分不会查询太老的数据,假设保留1小时。每10秒如果算10万帖子的话,最多10万不同的用户ID,存储每个ID大约占用32-50个字节,缓存一个小时的数据大约占用1.6GB。
对于中小型的Feed系统,feed数据可以直接通过同步push模式进行分发。用户每发表一条feed,后端系统根据用户的粉丝列表进行全量推送,粉丝用户通过自己的inbox来查看最新的feed。
对于大型的Feed系统肯定是push+pull
一个feed调用流程需要进行如下操作:
- 根据用户uid获取关注列表
- 根据关注列表获取每一个被关注者的最新微博ID列表
- 获取用户自己收到的微博ID列表
- 对这些ID列表进行合并,排序以及分页处理后,拿到需要展现的微博ID列表
- 根据这些ID获取对应的微博内容
- 对于转发feed进一步获取源feed的内容
- 获取用户设置的过滤条件进行过滤
- 获取feed作者的user信息进行组装
- 获取请求者对这些feed是否收藏,是否点赞等进行组装
- 获取这些feed的转发,评论,赞等计数进行组装
- 组装完毕,转换成标准格式返回给请求方
可以看到这个步骤是非常复杂的,因此我们要合理的设计Feed系统的缓存。
一个典型的Feed系统的缓存设计主要分为:INBOX, OUTBOX, SOCIAL, GRAPH, CONTENT, EXISTENTCE, COUNTE共六个部分。
Feed id存放在INBOX cache和OUTBOX cache中,存储格式都是vector(有序数组),如图:
其中INBOX缓存层用于存放聚合效率低的feed id。当用户发表只展现给特定粉丝,特点成员组织的feed时,Feed系统会首先拿到待推送(push)的用户列表,然后将整个feed id 推送给对应的粉丝的inbox。因此inbox是以访问者UID来构建key的,其更新方式是先gets到本地,变更后再cas到异地缓存。
OUTBOX缓存层用于直接缓存用户发表的普通类型feed id,整个cache以发布者UID来构建key。
SOCIAL GRAPH缓存层主要包括用户的关注关系及用户的user信息。用户的关注关系主要包括用户的关注列表,粉丝列表,双向列表等。
EXISTENCE缓存层主要用于缓存各种存在性判断的业务,比如是否已赞,是否阅读这类需求。
COUNTER缓存用于缓存各种计数。Feed系统中计数众多,如用户的feed发表数,关注数,粉丝数,单挑feed的评论数,赞数,阅读数,话题相关计数等。
CONTENT缓存层主要包括热门feed的content,全量feed的content。热门feed是指热点事件爆发时,引发热点事件的源feed。由于热门feed被访问的频率大于普通feed,比如微博中单挑热门feed的QPS可能达到数十万的级别,所以热门feed需要独立缓存,并缓存多份,以提高缓存的访问性能。
简单数据类型的缓存设计
一般系统中的大部分数据都是简单的KV数据类型。这些简单数据类型只需要进行get,set操作,不会用于特殊的记数操作,最适合用Memcached作为缓存组件。在选用某个中间件的时候,我们需要进行容量规划,分析业务数据的size,cache数量,峰值读写等。来确定缓存容量的大小,节点数,部署方式等。
评估表:业务名称,用途,单位(user),平均size,数量,峰值读(QPS),峰值写(QPS),命中率,过期时间,平均穿透加载时间。为了保证服务的可用性。让请求不会大批量的穿透到DB层,给DB带来巨大的压力,极端情况下会引发雪崩,可以引入Main-HA双层架构。
对后端数据的缓存访问,会先访问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数量。