案例:社交场景架构进化:从数据库到缓存。

本文以一个典型的社交类应用为例,基于一个简化的领域模型和业务场景,叙述该应用在面临不断增加的业务吞吐量时,传统的基于数据库的方案将面临的性能风险,随后阐述如何利用缓存技术对这些典型的性能问题进行解决。
本文分为5个小节,首先引入示例应用的领域模型和业务场景,随后分别针对其relation、post、timeline三个模型的相关场景分别叙述基于数据库的解决方案和问题,以及在此之上音容的缓存方案。最后一小节讨论对这个示例应用在机房本身面临瓶颈时,如何应用缓存辅助其多机房部署。

社交业务示例

本章引入的示例应用类似于微博的及时文本发布系统,允许用户相互关注并且浏览其关注用户发布的文本。

业务模型

在这个简化的示例系统里,我们引入三种关键的业务模型:发布内容(post),单向关注(follow),基于时间的内容流(timeline),如下图所示。

上述示例中,有8个用户,其中用户A和A'关注了用户B,用户B又关注了用户C和C'。上图中用户间的箭头表示相互的“关注”关系,定义该关系中的两个概念:

  • follower:B关注了C,则B是C的follower。
  • followee:C被B用户关注,则C是B的followee。

通过“关注”关系连接的各个用户就会形成一幅幅有向图,他们的边是关注关系,节点是用户。这些有向图有的很大很密表示他们所代表的用户间的关系很紧密;有的很小且和其他的图分割,形成孤岛,他们是社交关系中相对孤立的用户或者小圈子;有的节点有极多的边指向他,代表这个节点是大V很多人关注。复杂的社交关系被这个有向图描述,承载这个有向图的系统也会呈现出及其复杂的特性,但是对于每一个单独的节点(用户)而言,却只有简单的两个信息:他指向了谁、谁指向了他。而常见的社交应用通常也只站在单独一个用户(节点)的视角,为这些数量极多、形状/特性及其多样和复杂的有向图中相对简单的部分提供服务。
每一个用户都会发表帖子(post),这些帖子根据时间排序,形成了帖子发布者的timeline。上述示例中分别展示了用户C、C'、C''各自的timeline。
针对每一个用户的followee,其timeline根据其中post的发表时间排序,组成了这个用户的feed。也就是说,这个用户可以看到被他关注的用户发表的帖子。

业务场景

基于上述的业务模型,本文示例系统可以进行一系列的操作,通过这些操作形成了这个系统在社交场景下的功能。

主要页面

为简化叙述,本系统为每个用户提供三个页面:

feed页

feed页用户展示用户的followee们发布的帖子。

  • 这些post按照时间从新到旧的排序在页面上自上而下依次排序。
  • feed页的内容允许分页。每一页(page)展示有限条数的post,page号从新到旧连续增加,page内部仍按post时间排序。
  • feed页面是用户进入示例社交系统的首页,所以需要展示一些摘要信息。本实例展示的摘要信息包括:用户的followee个数、follower个数、timeline的post总条数。

timeline页

timeline页用于展示指定用户发布的所有帖子。

  • 这些帖子仍然按照时间从新到旧依次自上而下展示,过多的帖子同样允许分页。
  • timeline页面同样需要展示一些摘要信息,包括本timeline从属用户的:followee个数、follower个数、本timeline的总条数。

relation页

relation页展示用户的关系相关信息,包含两个子页面:

  • followee页,展示被该用户关注的所有用户信息。当该用户有太多的followee时,followee允许分页,每页展示固定数目的followee用户信息。用户信息包含用户名、描述和头像等。
  • follower页,展示被该用户关注的所有用户信息。分页和信息内容类似followee页。

主要操作

在上节描述的几个页面上,用户除了可以查看页面内容,还可以进行一系列与社交相关的操作。操作主要分成两类:

  • 一类是关系相关操作。用户可以为自己增加、删除followee,即关注某个其他用户或者对其他某个用户取消关注;可以删除follower,即取消其他某个用户对自己的关注。通过这三类操作可以改变上述小节中描述的有向图中自己相关的部分的拓扑结构。
  • 另一类是与内容相关的操作。用户可以发布新的帖子,每当新帖子发布时,该用户的follower就可以在自己的feed页面看到帖子的内容、发布时间等信息;该用户的timeline页面也会展示出这条帖子。用户还可以删除自己发布的帖子,一旦删除,follower们的feed页里将不再出现这条帖子;用户自身的timeline页面也不再展示。随着新的发布或删除,用户自己的feed页的摘要信息部分中的timeline总条数会发生变化,timeline页的相关摘要信息也随之变化。

业务特点

以上就是示例社交系统的业务场景,经过简化,它可以基于关系型数据库以较小的成本实现。当然,小成本的简单实现在数据量、访问量极小的情况下是可以满足需求的。随着数据、访问规模的逐渐增加,我们会发现他面临一个个的问题。本文后续小节的叙述顺序就是对这个社交系统的关系、内容两方面,从最简单的实现触发,逐步引入新的数据、访问特征,考察他将面临的问题,最后我们再继续剔除更加多的方案。
社交类系统随着规模的增加,通常会表现出以下特征考验我们“小成本”的设计:

  • 海量的数据。亿级的用户数量,每个用户千级的帖子数量,平均千级的follower/followee数量。
  • 高访问量。每秒十万量级的平均页面访问,每秒万量级的帖子发布。
  • 用户分布的非均匀。部分用户的帖子数量/follower数量、相关页面访问量会超出其他用户一到数个量级。
  • 时间分布的非均匀。高峰时段的访问量、数据变更高出非高峰时段一到数个量级;高峰时段的长短也非均匀分布,存在日常的高峰时段和突发事件的高峰时段。
  • 用户+时间的非均匀分布。某个用户可能突然在某个时间成为热点用户,其follower可能突增数个量级。

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

关系(relation)的存储

本小节讨论关系(relation)相关的设计演进。“关注”——是用户和用户间的“关注”关系。我们先从最简单的设计开始。

基于DB的最简方案

表达用户信息和相互关系,基于DB只需要两张表可实现,示意如下图所示。

relation表主要有两个字段followerId和followeeId,一行relation记录表示用户关系拓扑的一条边,由followerId代表的用户指向followeeId代表的用户。
userInfo表关注每个用户的详细信息,比如用户名、注册时间等描述信息。它可以是多个字段,本示例为了简化描述,统一将这些描述信息简化成一个字段。

场景实现

基于relation 相关的展示和操作可以用如下方式实现。

  • 某用户(例如用户B)timeline/feed页面的relation摘要信息展示,可以通过两条SQL实现:
SELECT COUNT(*) FROM table_relation WHERE followerId='userB';
SELECT COUNT(*) FROM table_relation WHERE followeeId='userB';

上述两条语句分别展示出了userB的follower和followee数量。

  • 某用户(例如用户B)relation页面详细信息展示,分成两个子页面:follower列表展示和followee列表展示:
SELECT followeeId FROM table_relation WHERE followerId='userB';
SELECT userId,userInfo FROM table_user_info WHERE userId IN (#followeeId#...);
SELECT followerId FROM table_relation WHERE followeeId='userB';
SELECT userId,userInfo FROM table_user_info WHERE userId IN (#followerId#...);

上述四条语句分别展示被用户B关注的用户和关注用户B的用户列表。

  • 某用户(例如用户B)关注/取消关注某用户(例如用户C):
INSERT INTO table_relation (followerId,followeeId)
VALUES ('userB', 'userC');
DELETE FROM table_relation WHERE followerId='userB' and followeeId='userC'

问题引入

随着用户数量的增加,table_relation/info表的行数膨胀。如前述小节描述的那样,亿级的用户,每个用户相关关系百级,那么table_relation的行数将膨胀到亿级。由此,表的水平拆分(sharding)势在必行。
水平拆分需要根据表的某个字段作为拆分字段,例如info表的拆分以userId为拆分字段进行,如下图所示。

对于某个用户的信息查询,首先根据userId计算出他的数据在哪个分片,再在对应分片的info表里查询到相关数据。userId到分片的映射关系有多种方式,例如hash取模,userId字段的某几个特殊位,hash取模的一致性hash映射等。
对于info表,水平拆分字段的选取较为明确,选取userId即可。但是对relation的水平拆分,如何选取拆分字段显得不那么简单了,如下图所示。

假设根据followerId进行拆分,查询某个用户关注的人显得容易,因为相同followerId的数据一定分布在相同分片上;但是一旦需要查询谁关注了某个用户,这样的查询需要路由到所有分片上进行,因为相同followeeId的数据分散在不同的分片上,查询效率低。由于对于某个用户,查询他的关注者和关注他的用户的访问量是相似的,所以无论根据followerId还是followeeId进行拆分,总会有一半的场景效率低下。

DB的sharding方案

针对上面提出的问题,我们通过优化DB的table_relation表方案,使之适应sharding。经过优化后的relation设计如下图所示。

首先将原有的relation表垂直拆分为followee表和follower表,分表记录某个用户的关注者和被关注者,接下来再对followee和follower两张表分别基于userId进行水平拆分。

场景实现

相对于上节的实现方案,sharding后的relation相关操作中,变化的部分如下。

  • 某用户(例如用户B)timeline/feed页面的relation摘要信息展示,可以通过两条SQL实现:
calculate sharding slide index by userB
SELECT COUNT(*) FROM table_followee_xx WHERE userId = 'userB';
SELECT COUNT(*) FROM table_follower_xx WHERE userId = 'userB';

针对用户B的关系数量查询可以落在相同分片上进行,所以一次展示只需要查询两次DB。

  • 某用户(例如用户B)relation页面详细信息展示,分成两个子页面:follower列表展示和followee列表展示:
calculate sharding slide index by userB
SELECT COUNT(*) FROM table_followee_xx WHERE userId = 'userB';
SELECT COUNT(*) FROM table_follower_xx WHERE userId = 'userB';
calculate sharding slide index by follower/followee Ids
SELECT userID,userInfo FROM table_user_info WHERE userID in (#followerId#...,#followerId#);

上述三条语句分别展示被用户B关注的用户和关注用户B的用户列表,其中前两条可以落在相同分片上,DB操作次数为两次,但最后一条仍需查询多次DB,我们下文继续讨论如何优化他。

  • 某用户(例如用户B)关注某用户(例如用户C):
calculate sharding slide index by userB
START TRANSACTION;
INSERT INTO table_follower (userId,followerId) VALUES('userB','userC');
INSERT INTO table_followee (userId,followerId) VALUES('userB','userC');
COMMIT

上述关注用户的操作由上节所属方案的一条变成两条,并且包装在一个事务中。写入量增加了一倍,但由于水平拆分带来的DB能力的提升远远超过一倍,所以实际吞吐量的提升仍然能够做到随着分片数量线性增加。

问题引入

上述对relation表的查询操作仍然需要进行count,即使在userId上建了索引仍然存在风险;

  • 对于某些用户,他们被很多人关注(例如大V类用户),他们在对follower表进行count查询时,需要在userId上扫描的行数仍然很多,我们称这些用户为热点用户。每一次展示热点用户的关注者数量的操作都是低效的。另一方面,热点用户由于被很多用户关注,他的timeline页面会被更频繁的访问,使得原本抵消的展示操作总是被高频的访问,性能风险进一步放大。
  • 当某个用户的follower较多时,通常在relation页面里无法一页展示完,因此需要进行分页展示,每一页显示固定数量的用户。然而DB实现分页时,扫描效率随着offset增加而增加,使得这些热点用户的relation页展示到最后几页时,变得低效。
  • 用户详细信息的展示,每次展示relation页面时,需要对每个follower或者followee分别查询info表,使得info的查询服务能力无法随着info分片线性增加。

引入缓存

针对上节所述三个问题,我们首先引入缓存,数据划分如下图所示。

在DB层,增加userInfo表的冗余信息,将每个用户的关注着和被关注者的数量存入DB。这样一来,对于timeline和feed页的relation摘要展示仅仅通过查询userInfo即可完成。
同时引入缓存层,需要注意的是,与关系相关的两张表,在缓存层对应的value变成了列表,这样做有两个原因:

  • 列表的存储使得查询可以通过一次IO完成,无须像数据库那样经过二级检索依次扫描相同key对应的所有行。然而数据库很难做到以list作为value的类型并且很好的支撑list相关的增删操作。
  • 缓存是key-value结构,相同的userId难以分为多个key存储并且还能保证他们高效扫描(缓存的没有DB中的基于key前缀的range扫描)。

同时userInfo和userCnt相关信息也分别放入了不同的缓存表中,将DB的一张表分为两张缓存表,原因是info信息和cnt信息的展示场景不同,不同key的频度也不同。
在最上层,对访问量极高的用户的info信息进行服务器端的本地缓存。

场景实现

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

  • 某用户(例如用户B)timeline/feed页面的relation摘要信息展示:展示方式变成了首先根据用户B作为key查询缓存,未命中时,再查询DB。
  • 某用户(例如用户B)relation页面详细信息展示,分成两个子页面:follower列表展示和followee列表展示:

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

对于每个用户的info信息,热点用户由于被更多的用户关注,他更有可能在详情页面被查到,所以这类用户总是在本地缓存中能够查询到。同时,本地缓存设置一个不长的过期时间,使得他和分布式缓存层或者数据库层的数据不会长时间不同步。过期时间的设置和存放本地缓存的服务器数量相关。

  • 某用户(例如用户B)关注/取消关注某用户(例如用户C):

每一次插入/删除DB的记录时,同时需要对对应缓存的list进行变更。我们可以利用Redis的list/set类型value的原子操作,在一次Redis交互内实现list/set的增删。同时在DB的一个事务中,同时更新userInfo表的cnt字段。

DB和缓存无法共处于同一个ACID的事务,所以当DB更新之后的缓存更新,通过在DB和缓存中引入两张变更表即可保证更新事件不丢失:DB每次变更时,在DB的事务中向变更表插入一条记录,同时有一个唯一的变更ID或者叫版本号,随后再在缓存中进行修改时,同时也设置这个版本号,再回来删除DB的这条变更记录。如果缓存更新失败,通过引入定时任务补偿的方式保证变更一定会同步到缓存。

问题引入

relation的相关操作通过缓存和DB冗余的方式基本解决了,但仍然遗留了两个问题:

  • 热点用户的follower详情页查询数据量问题:热点用户由于有过长的缓存list,他们每次被查询到的时候有着极高的网络传输量,同时因为热点,他的查询频度也更高,加重了网络传输的负担。
  • info查询的multi-key问题仍然没有完全解决:虽然对热点用户本地缓存的方式避免了distributed缓存的查询,但是每个用户的follower/followee中,大部分用户是非热点用户,他们无法利用本地缓存。由于这些非热点用户的占比更大,info接收的服务吞吐量需求仍然没有显著减少。
  • info查询中一个重要的信息是被查询实体的followee和follower的数量,尤其是followee数量上限很高(部分热点用户存在百万甚至千万级的量),这两个cnt数量随时变化着,为了使得查询的数值实时,系统需要在尽量间隔短的时间重新进行count,对于热点用户,如果期望实现秒级数据延迟,那么意味着每秒需要对百万甚至千万级别的数据进行count。如何解决这些动态变化着的数据的大访问量、实时性成为挑战。

下节叙述如何利用二级缓存和冗余解决这三个问题。

缓存的优化方案

对于上述两个遗留问题,可以通过引入增量化来解决。他对解决上述3个问题提供了基础,其思路是将增量数据作为一等公民(first-class),通过对增量数据的流式处理,支撑relation的各种查询场景,尤其是热点场景。
对于本文中的示例系统,在引入缓存后仍存在的热点场景如下:

  • 热点用户(follower很多的用户)的关系详情查询:他/她的关注者列表。
  • 所有用户的计数相关摘要,包括热点用户的计数摘要、热点用户的follower/followee的摘要。

首先来看一下增量数据的流转,如下图所示。

最左侧为增量事件的发起者,例如用户C关注了B和A,则产生两个follow数据,他们将会分别shuffle到A和B所在的数据分片,使得A、B所在的分片中存放着他们各自的变更数据。这些变更以某种方式按照变更产生时间排序,例如唯一主键以createTime时间戳的某种保序压缩(例如将timemillis做36-base编码抱枕字母序关系不变)作为前缀让DB的查询实现顺序扫描,使得变更数据的订阅方,如计数器或者近期增量列表能够根据变更事件的时间进行获取。

独立的计数服务

对于实时变更着的follower/followee数量的频繁查询,采用数据库的count函数来实现无法保证性能和吞吐量,即便引入缓存,为了保证缓存的时效性,也会因较短间隔的DB count查询引发性能问题。这些问题通过引入单独的计数服务使得count计算做到O(1)的查询复杂度可以得到缓解。
计数服务可以设计成key-value结构,持久化到分布式缓存,key为用户id,value为该用户的follower/followee数量。于是查询服务转化为对缓存的某个key的简单get操作。
对于value的写入,可以利用上述增量化模块,订阅用户收到的变更事件。对于follow事件,直接对key对应的value做自增操作;对于unfollow事件,则做自减操作。例如,对于user C follow 了 A 这个事件,对key为A的value 做自增操作。
同时通过对增量化模块中的每个事件记录产生的版本(也可能根据时间本身自增来实现),和对计数器每个key进行版本记录,可以实现去重防丢失等需求。
每个key的更新频率取决于单位时间内针对该key的事件数量。例如对于有1亿follower的热点用户,假设每个follower每十天变更一次对某个followee的关注与否(实际上变更频率不会这么频繁),那么改key的变更频率峰值为500次每秒(自然情况下的峰值约等于当天的所有访问平均分布到5~8小时之内的每秒访问量),小于数据库单key的写入吐度量上限(约等于800tps)。如果考虑批量获取变更事件,则单key峰值写入会更低。

根据事件时间排列的relation详情

当需要查看某个用户的relation详情页时,涉及对follower/followee列表的分页查询。通常单个用户关注的人数量有限,绝大多数用户在1000以内,且每次对第一页的查询频度远高于后续分页,那么无论直接将列表存入DB或是分布式缓存,都能做到较高的吞吐量:DB数据以用户为二级索引,采用默认的排序时大多数清空第一页一个block可以承载;分布式缓存时单个value可以涵盖这些followee列表。
但是,对于热点用户的follower,情况更加复杂一些:follower的数量不可控,使得:

  • 即便是小概率的翻页操作,对于follower很多的热点用户,仍然是高访问量的操作;且每次翻页的扫描成本很高。
  • 单个分布式缓存的value列表无法承载过长的follower列表。

针对热点用户的follower列表查询问题,采用基于增量化的实现辅助解决。
首先,同一个用户follower列表的前N页(假设为前5页)的访问概率占到总访问量的绝大部分(假设超过99%),而前N页的follower个数是常数个(假设每页展示20个follower,前5页只有100个follower需要展示);其次,follower列表的展示以follow时间进行排序,最近加入的follower通常排在最前,即增量化模块的最新数据最有可能放在首N页。
基于上述两个假设,针对这99%访问量的前N页,可以直接查询增量数据:作为增量化的消费者每次拉取的最近N页条变更事件直接存入热点用户的follower缓存中,对外提供查询服务。由于变更事件既有follow也有unfollow,无法直接确定拉取多少条,此时可根据历史的follow和unfollow数量比例对“N页条数”进行放大再拉取,之后只取其中的follow事件部分存入缓存。

帖子(post)的存储

本小节讨论帖子相关的设计演进。帖子关注的是用户发布的“帖子”的内容。我们先从最简单的设计开始。

基于DB的方案

表达每个用户发出的所有帖子,单张表即可,如下图所示。

其中:

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

我们对userId+postTime建立二级索引,使得查看特定用户按照时间排列的所有帖子的操作变得更快。
对于帖子的两种典型查询的实现如下:
根据PostId查询帖子内容:

select * from post where postId=?

查询某个用户发送的帖子列表:

select * from post where userId=? and posttime between ? and ?

查询优化

如果采用userId+postTime的二级索引方式,对上述第二条存在严重的回表(对二级索引查到的每条记录都需要到聚簇索引中重新查询主数据)问题,降低DB的吞吐量。为此,可以将userId和postTime信息冗余进postId中,去掉二级索引减少回表。
postId可用下面的方式来设计格式,如下图所示。

postId首6位为userId,每一位是0~9/A~Z这36个字符中的某一个,6位可以表示21亿个不同的用户,后续时间戳(精确到秒)可以标识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 "userAprefix%"

由于查询的是同一个用户的帖子,所以所有postId的前缀都相同,如果查询这个用户某个时间范围的帖子,那么6位timeCompress的前面几位也相同(例如10分钟以内的帖子前4位timeCompress一定相同)。由于DB的聚簇索引采用B+树类似的存储,相同前缀的数据相邻存放,这样一来使得上述sql使用的DB的rangescan,避免了回表造成的随机读。

吞吐量优化

随着帖子数量的增加,单机DB的数据量和吞吐量达到上限,此时引入水平拆分(sharding)使得数据量和吞吐量线性伸缩,如下图所示。

水平拆分以userId作为拆分字段,相同userId的数据存放在相同DB分片上。
由于postId的前缀中完全包含了userId的信息。所以postId可以独立作为路由运算的单元。

DB方案的问题

帖子数据根据userId做拆分,但是某些热点user(假设follower数量为1亿)的读取量巨大,他们将被路由到相同的DB上,后者也可能存在读取瓶颈。为此,常见的方式为:读写分离。采用1写N读,利用DB自身的同步机制做主备复制。每次读取随机选取N个读库中的一个。
基于读写分离的DB解法存在两个问题:

  • 采用读写分离之后仍然存在数据延迟问题。当读库数量较多时(随着读取量水平伸缩),为保证写入的可用性,通常复制会采用异步方式进行。异步化的引入使得读库的写入时间难以保证。帖子是sns的基础服务,下节描述的时间线同样会读取帖子数据,如果读库的写入延迟高于上层如时间线服务的写入,将会出现时间线上有相关Postid但是却查不到内容的情况。
  • 同时sns的特点是近期数据访问频繁,较早的数据极少访问。而读写分离一旦引入,意味着每一条记录都需要存储多份。当这些数据刚刚发布时,他们是较新的访问频繁的数据,但是随着时间的推移,他们主键不再被访问,但是仍然保持着多份副本。假设sns系统运行10年,而只有近1星期左右的数据被经常访问,那么98%的数据副本不会被读取到,存储效率低。

引入服务端缓存

对于读多写少的场景,除了DB层的读写分离,缓存也是常见的解法。对于存储效率低的问题,缓存的数据过期机制天然的避免了陈旧数据对空间的占用,所以引入缓存提升DB性能成为自然选择。

key-value的选型

缓存设计前首先要确定一个问题:以什么作为key和value。最自然的方案当然是postId作为key,帖子内容作为value。然而在后续timeline的方案中可以看到,查询某个用户一段时间的帖子是一个常见操作。如果此处以postId作为key,那么这个常见操作对于缓存来讲将是multiple-key的查询。如何优化?
我们不难发现,同一个用户一天发出的帖子数量是有限的,通常不超过10条,平均3条左右,访问最频繁的1周以内的帖子数很少超过100条;同时单条帖子的长度是有限的(假设为1KB),那么单个用户一周发的帖子很难超过100KB,极端情况下1MB,远低于redis单value的大小上限;同时redis这类缓存系统也支持对list型元素进行范围扫描。因此,缓存的key-value可以按如下方式设计:

  • key:userId+时间戳(精确到星期)。
  • value:redis的hash类型,field为postId,value为帖子内容。
  • expire设置为1星期,即最多同时存在两个星期的数据(假设每帖平均长度为0.1KB,1亿用户每天发3贴预计数量为400GB)。

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

服务端缓存的问题

引入服务端缓存利用了帖子访问频度随时间分布的局部性,降低了DB的压力。同时由于失效事件的引入,减少了DB副本带来的旧数据空间浪费。但对于热点用户的查询仍然存在问题:假设热点用户的follower数量极高(1亿follower,10%活跃),意味着这个热点用户所在redis服务器的查询频度为1000万每秒,造成单点。

本地缓存

服务端缓存解决了近期数据的访问吞吐量问题,但是对于热点用户存在单点问题,我们进一步引入本地缓存服务端缓存的压力。
对于近期发布(设为1周)的某一个帖子,他所属用户的follower越多,意味着他被访问的频率越高;follower越多的用户数量越少,其发布的帖子也越少。因为帖子的访问频度随用户发布的局部性明显,所以本地缓存的目标是解决服务端缓存(近期帖子)中热点用户的访问问题。
本地缓存在顶层key为用户id,value为该用户近期发布的帖子,相同key内value的逐出规则为基于时间的先进先出(较早的帖子首先逐出),key键的逐出规则为基于user的访问频度较少先出。

对于分散在不同服务器上的本地缓存,数据如何同步成为问题。对于帖子的新增,问题不大,因为即便本地缓存没有数据,降级为查询一次服务端缓存即可。对相同用户针对相同时间范围的查询,通过并发控制,做到单台服务器一个并发,即便对热点用户,落到服务端缓存的流量也是可控的(每台服务器一个并发)。但对于帖子的删除,情况会有些不同。当本地缓存查到有数据时,如何知道该数据是否已被删除?可以采取的解法是为每个缓存中的用户保留一个最近更新时间,当这个用户的本地缓存上次查询服务端缓存距当前超过一定时间(假设1秒)时,再重新查询一次服务端缓存。同样通过并发控制,的那台缓存服务器上针对热点用户的查询频度正比于服务器数量,也是可控的。
本节首先介绍了基于DB的实现方案并在DB框架内进行了优化,但随着数据量、访问量的增加,纯DB方案遇到瓶颈,随后讨论了缓存方案的演进。帖子的访问频度存在两个维度的非均匀性(局部性):时间的非均匀性(近期发布的帖子被访问的频率远高于早期)和发布者的非均匀性(follower多的用户的帖子被访问频率远高于follower低的用户),且访问频度越高的帖子,在这两个维度下的数量都越少。本节分别通过服务端缓存和本地缓存利用了上述两种局部性,提升了整体吞吐量。

时间线(timeline)的存储

timeline是社交应用的关键场景。每个用户可以看到他们关注的用户发出的帖子,根据这些帖子的发出时间进行排列和分页。
随着用户间relation关系的变化,某个用户的timeline也随之变化;而一个用户对自己帖子的增删,会影响到多个其他用户的timeline。可见timeline的内容受用户间relation影响,当relation关系复杂时,timeline的性能将会收到挑战。
本小节首先基于DB设计出最简单的timeline实现方案,随后分析他在relation变得复杂时面临着何种性能挑战,并尝试在DB的基础上优化。随后将缓存引入DB的方案中,讨论如何设计缓存解决DB方案遗留的问题。

基于DB的方案——push模式

timeline聚合着某个用户followee的所有帖子,我们首先假设整个系统的用户总数、帖子总数庞大,并从已将水平拆分(sharding)作为前提开始,讨论timeline典型的两种实现:基于push和pull的实现。首先讨论push模式。

原始实现

push模式的特点是:用户每次新增一条帖子,将此帖子“推”到他/她的follower所在的DB分片上,后者在每次浏览timeline时,直接查询自己分片所存储的数据。这种模式典型的表结构如下图所示。

其中:

  • post表和timeline表都按照他们所属的用户进行水平拆分(相同用户的记录存放在相同分片)。
  • post表在上一小节已叙述。timeline表记录着每个userId的timeline里所看到他/她关注的用户的帖子:userId就是timeline的拥有者、postId即timeline里包含的这条帖子的主键、postId标识了帖子的发帖者的userId、以及帖子的发布时间字段(postTime)。
  • timeline表的唯一约束只有一个:userId+postId。即,同一个用户的timeline下相同的一条帖子只能出现一次。这里不妨用userId+postId作为timeline表的主键。

回顾上一小节关于postId的优化部分。为了加速对某个用户发出的帖子的查询效率,postId格式为:postId+compress(time)+seq。因此postId里已经包含了发帖者的userId了,所以这里posterId字段可以省略,如下图所示。

其中:userId+postId为主键、userId+postTime为索引。
常见的操作如下:

  • 操作1    用户E根据时间浏览自己timeline下一定时间范围内的帖子。

落在userId所属DB分片上:

select postId from timeline where userId=E and postTime between ? and ?

根据postId对应的发帖用户选择分片。涉及多次DB查询。对于post小节所述缓存策略,如何通过postId获取post对象不在本小节讨论范围。这里假设获取post已经做到了足够优化,将讨论重点落在timeline上。

select * from post where postId in (...)
  • 操作2    用户E follow 了新用户B:在B所在分片将B的postId获取,并插入到E的timeline表中。
  • 操作3    用户E unfollow 了用户B。

以主键第二个字段的前缀,进行聚簇索引(主记录)的范围扫描,IO次数可控。

delete from timeline where userId='E' and post like 'B%'

上述设计的操作1存在以下问题:利用timeline的userId+time联合索引可以通过范围扫描的方式获取所有timeline的主键/rowkey,所以索引本身消耗只需少量IO。但存放关键信息的postId字段不在userId+time这个二级索引上,意味着需要回表查询,IO次数不可控。

push模式下的优化

由于查询timeline相对于relation变更更加频繁,所以索引设计侧重于查询。优化方案如下图所示。

其中,将原有的postId字段替换为postId',后者在格式上由(posterId+time+seq)更改为(time+posterId+seq),postId和postId'两者承载的信息相同,可以直接相互转换。
替换之后,上述查询操作变为:

select postId from timeline where userId=? and postId between 'timel%' and 'time2%'

利用前缀进行范围查找,由于查找的是聚簇索引避免了回表,查询效率得以提升。
上述两种基于DB的push方案实现都面临一个困难的场景:E关注的B发布/删除了自己的帖子时,除了修改B本身的post表之外,需要插入/删除E的timeline表。假设B是一个热点用户,他/她拥有上亿的follower,那么这个用户的每一次新增删除帖子操作,将会被复制上亿次,造成增删帖子瓶颈。

基于DB的方案——pull模式

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

原始实现

对于pull模式最简单的实现,并不需要单独新增timeline表,每个用户自己发除的帖子都维护在post表和前述小节介绍的post缓存中。pull模式下针对写操作,没有额外的开销,但是对于更加频繁的读操作(用户查看一段时间内所有followee的帖子)时,需要用户对自己的所有followee的帖子进行按时间的扫描。假设每个用户平均关注500人,那么每次用户刷新timeline页面将进行100次查询(假设有100个DB分片)。一个大型的社交系统,假设同时1000万人在线,平均每10s刷新一次页面,那么DB的查询压力将是每秒1亿次查询,仅仅依靠DB,需要上万个DB实例。
那么,基于pull的纯DB模式,有无可能优化呢?我们发现,当平均每个用户follow了500个其他用户时,每个用户的平均follower也是500,意味着这个用户的每一条帖子,都有500个用户会在构造timeline时查询到。假设某一个用户同时被多个follower的timeline查询着,那么这些并发的查询可以只访问一次DB,称为代理的查询优化。假设10%的用户在线,10s刷新一次,查询DB一次10ms,那么同一个用户在同一时刻被代理查询优化的概率只有5%,优化效果甚微。
由此可见,在pull模式下,虽然避免了热点用户的更新问题,查询效率和每个用户的followee相关,由于单个用户所follow的其他用户数量可控,查询的效率也可控,但是仍然存在优化空间。

pull模式下的优化——push/pull结合

单纯基于pull的模式下,对于最频繁的timeline查询操作,由于每个用户的500个(平均)followee分布在全部DB分片上(假设100个DB分片),每个user的每次timeline传都是100次DB查询,查询压力极大。而push模式下,由于部分热点用户的存在,使得帖子发布之后的复制份数不可控。有没有一种方式使得复制份数可控,同时查询压力又尽量小呢?
这里介绍一种pull/push的结合方案,视图解决上述问题,如下图所示。

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

select * from post_rep where posterId in (...平均500个id) and postTime between ... and ...

如果以posterId作为索引的首字段,即便采用覆盖索引(covering index),仍然是500次左右独立的索引查询。因此采用postTime作为索引首字段。
假设每个用户每天平均发布3条帖子,且都几种在白天的8小时,系统总共1亿个用户,那么每10秒将会有10万新的post插入,每个用户500个followee,预计每20秒才会在timeline中出现一条新帖子。假设每次timeline更新如果查询的时间范围就是最近10秒,那么采用推拉结合的方式,每个用户每次扫描10万条记录,却只从中选出平均0.5条新纪录,仍存在一定的性能风险。

push/pull结合全量化查询

上节中提到的每次查询的postTime范围只有10秒,这时基于以下假设:存在一个全量化查询的缓存支持,实现概要如下:

  • 对于每个用户已经查询过的timeline,可以将其存储并标注“最后查询时间”,使得每次timeline刷新时只需要查询“最后查询时间”之后的记录。
  • 对timeline查询结果的存储可以采用缓存完成,称为timeline的最近查询缓存。缓存中可能存在已经删掉的post,但由于缓存仅存储postId,这些删除的post在根据postId查询post阶段(上一小节已述)将会被过滤掉,不影响查询结果。timeline最近查询缓存通过一定过期时间保证容量可控,本章不再详述。

增量查询引入服务端缓存

上述基于DB的方案,演进到pull/push结合时,基本能够承载timeline的容量规模遗留一个问题:单DB实例每秒1万次、单次10万记录的索引扫描操作可能存在风险。我们称剩下这个有风险的查询叫做“timeline增量查询”。
本小节引入定制的服务端内存缓存,实现timeline增量查询的读写优化。

数据结构

这个定制化的缓存结构如下图所示。

这个内存中的缓存保存了多个“增量列表”:

  • 每个列表的元素是用户ID,如上图的A、B、C...;
  • 每10秒产生一个新的列表,这10秒内所有发过帖子的ID都在本列表内;
  • 同一个列表内的用户ID按照字母序排列,并用B+树索引。

作为缓存,增量查询部分不会保留数据。但由于每个用户默认保留了上次查询的结果,可以认为增量部分不会查询太老的数据,假设保留1小时。根据前面小节的假设(1亿用户平均每天3条帖子),每10秒大约10万条帖子,最多10万个不同的用户ID,存储每个ID大约占用32~50字节,缓存1小时的数据大约占用1.6GB。

读写实现

首先来看对访问最频繁的读取操作,对外提供的服务为查询某个用户所有followee在某段时间内是否有发帖。即,扫描某个用户的followee列表(平均500个)是否包含在扫描时间范围内的这些“增量列表”中。
由于所有的增量列表都已排序,扫描的时间复杂度为:

其中M为该用户的followee个数(平均为500),Ni为搜索时间范围内的第i个“增量列表”的长度(平均为10万)。
假设Log以48为底(k为48),10万个节点需要3层,则每次查询需要耗时约60微秒,一个16线程的服务器每秒可以支撑25万次查询。假设每个用户10秒刷新一次,取最近两个增量列表(20秒范围),单台这样的服务器可以支撑120万个用户的访问请求,一个大型社交系统(1亿用户10%在线)需要不超过10台缓存服务器。
每当用户发布了一个帖子,他/她的follower需要在1~2秒内看到这条帖子,而上述每个“增量列表”保存的是10秒内的数据,意味着增量列表是不断更新着的。这里不妨用copy-on-write的方式定期(如0.5秒)将最新新写入的数据加入到对应的增量列表。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值