作者:jaskeylin,腾讯会议后台架构师。Apache RocketMQ committer、拥有11年中间件产品和和大型业务后台的双背景研发经历,对海量用户、高并发、多地域容灾等架构设计拥有丰富经验,热衷与技术总结和知识分享。
一 背景在腾讯会议320的APP改版中,我们需要构建一个一级TAB,在其中放置“我的录制”、“最近浏览”+“全部文件”的三大列表查询页。以下是腾讯会议录制面板的界面(设计稿)
我的录制就是作者本人所生产的录制文件,而所谓“最近浏览”很好理解,就是过去观看过的录制的足迹留痕。而所谓全部文件则相对比较复杂,可以认为是“我的录制”+“最近浏览”+“授权给我的录制”三个集合的并集。
我的录制就是作者本人所生产的录制文件,而所谓“最近浏览”很好理解,就是过去观看过的录制的足迹留痕。而所谓全部文件则相对比较复杂,可以认为是“我的录制”+“最近浏览”+“授权给我的录制”三个集合的并集。
虽然三个TAB的样式几乎长一模一样,但是数据集是完全不同的,数据源可能也不一样,接口自然也是需要单独设计的。但无论哪个接口,三者均面临着同样的3个挑战:
1.调用量大。作为一个4亿用户量的APP,一个一级入口的流量足以让所有后台设计者起敬畏之心。
2.数据量大。腾讯会议的录制的数据库的存量数据巨大。未来还将持续保持高速的增长,存储的压力、写入/查询的压力很大。
3.耗时要求高。作为一级TAB的入口,产品对于其中的体验要求极高,秒开是必须的,这意味着一次接口调用查询一页的耗时在高峰压力下也要在百毫秒级别内。
面对这些挑战,下面介绍腾讯会议的后台系统是如何应对的。
二 分页列表类接口设计挑战在讲具体设计之前,无论是“我的录制”还是“最近浏览”,本质上都是一个列表类功能。这类功能随处可见,但是要把这个功能做到高并发、高可用、低延迟,其实并不是一个简单的任务。我们以简化版的“我的录制”为例去看这里可能有什么挑战。
一个列表的每一行记录实际上元素非常的多(上图已标注出来)。我们姑且先假设这里的cover,title,duration,meet_code,auth_value,create_time,size是存储在同一张表中(实际上不是)来看看最简单的一个列表系统,在数据量大、并发量大的时候有哪些挑战。
2.1 深分页问题从功能的角度来看,“我的录制”的实现其实就是一条SQL的事情:
select * from t_records where uid = '{my_uid}' limit X, 30;
以上SQL表示查询我的录制列表的内容,每页30条。随着用户翻页的进行,X会逐渐增大。
如果这是一个几万几十万的数据表,这样实现是完全没有问题的,实现简单,维护容易又能实现业务需求。
2.1.1 索引的工作原理但是在腾讯会议的录制场景下,非常很多表的数据量都是超级大表,而且一张表的字段有30+个。只考虑第一页的情况下,需要在这样的的大表中搜出来符合数据的用户就是一件挺消耗性能的事情。即:select * from t_records where uid = '{my_uid}' limit 30;
第一步:在命中索引uid的情况下,先找到uid={my_uid}的索引叶子节点,找到对应表的主键id后,回表到主键索引中再找到对应id的叶子节点,读出来足够一页的数据,并且把所有字段的内容回传给业务。此过程大约如以下图所示(图片来源于网络,以user_name作为索引,但原理是一样的):
2.1.2 深分页时的索引工作原理假设加了分页 select * from t_records where uid = '{my_uid}' limit X,30;
innodb的工作过程会发生变化,我们假设X=6000000
数据库的server层会调用innodb的接口,由于这次的offset=6000000,innodb会在非主键索引中获取到第0到(6000000 + 30)条数据,返回给server层之后根据offset的值挨个抛弃,最后只留下最后面的30条,放到server层的结果集中,返回给业务。这样看起来就非常的愚蠢。
坏事不单只如此,因为这里命中的索引并不是主键索引,而是非主键索引,扫描的这6000000数据的过程还都需要回表,这里的性能损耗就极大了。而且,如果你在尝试在一张巨型表中explain如上语句,数据库甚至会在type那一栏中显示“ALL”,也就是全表扫描。这是因为优化器,会在执行器执行sql语句前,判断下哪种执行计划的代价更小。但优化器在看到非主键索引的600w次回表之后,直接摇了摇头,说“还是全表一条条记录去判断吧”,于是选择了全表扫描。
所以,当limit offset过大时,非主键索引查询非常容易变成全表扫描,是真·性能杀手。
这是:https://ramzialqrainy.medium.com/faster-pagination-in-mysql-you-are-probably-doing-it-wrong-d9c9202bbfd8
中的一些数据,可以看到随着分页的深入(offset递增),耗时呈指数型上升。
2.2 深分页问题的解决思路要解决深分页的问题,其中一个思路是减少回表的损耗。网络上有不少的分享了,总体归结起来就是“延迟join”,和游标法。
2.2.1 延迟join可以把上面的sql改成一个join语句:
select * from t_records inner join ( select id from t_records where uid = '{my_uid}' limit X,30; ) as t2 using (id)这样的原理在于join的驱动表中只需要返回id,是不需要进行回表的,然后原表中字段的时候只需要查询30行数据(也仅需要回表这30行数据)。当然,以上语句同样可以改写成子查询,这里就不再赘述。
2.2.2 Seek Method深分页的本质原因是,偏移量offset越大,需要扫描的行数越多,然后再丢掉,导致查询性能下降。如果我们可以精确定位到上次查询到哪里,然后直接从那里开始查询,就能省去“回表、丢弃”这两个步骤了。
我们可以seek method,就像看书一样,假设我每天睡觉前需要看30页书,每天看完我都用书签记录了上次看到的位置,那么下次再看30页的时候直接从书签位置开始看即可。这样以上SQL需要做一些业务逻辑的修改,例如:
select id from t_records where uid = '{my_uid}' and id> {last_id} limit 30;这也是我们平时最常用的分页方法。但是这个方法有几个弊端,需要我们做一定的取舍:
Seek Method 局限一:无法支持跳页。
例如有些管理后台需要支持用户直接跳到第X页,这种方案则无法支持。但现在大部分的列表产品实际上都很少这样的述求了,大部分设计都已经是瀑布流产品的设计,如朋友圈。
少数PC端的场景即便存在传统分页设计,也不会允许用户跳到特别大的页码。
所以这个限制通常情况是可以和产品沟通而绕过的,一些跳页的功能实际上也很少人会使用。通常存在于一些PC端的管理后台,而管理后台的场景下,并发量很低,用传统分页模式一般也能解决问题。
Seek Method 局限二:排序场景下有限制
大部分的列表页面的SQL并没有我们例子中这么简单,至少会多一个条件:按照创建时间/更新时间等排序(大部分情况还是倒序),以按照录制创建时间排序为例,这条SQL如下1:
select * from t_records where uid = '{my_uid}' order by create_time desc limit X, 30;如果需要改成瀑布流的话,这里大概率需要这样改:
select * from t_records where uid = '{my_uid}' and create_time < {last_create_time }order by create_time desc limit 30;这样一眼看去没有什么问题,但是问题是create_time 和 id有一个最大的区别在于ID肯定能保证全局唯一,但是create_time 不能。万一全局范围内create_time 出现重复,那么这个方法是有可能出现丢数据的。如下图所示,当翻页的时候直接用create_time>200的话,可能会丢失3条数据。
要解决这个问题也有一些方法,笔者尝试过的有:
1.主键字段设计上保证和排序字段的单调性一致。怎么说呢?例如我保证create_time越大的,id一定越大(例如使用雪花算法来计算出ID的值)。那么这样就依旧可以使用ID字段作为游标来改写SQL了
2.把<(顺排就是>)改成<=/>=,这样以后,数据就不会丢了,但是可能会重复。然后让客户端做去重。这样做其实还有一个隐患,就是如果相同create_time的数据真的太多了,已经超过了一页。那么可能永远都翻不了页了。
2.2.3 列表接口缓存设计挑战解决完深分页的问题,不意味着我们的列表就能经得起高并发的冲击,它最多意味着不会随着翻页的进行而性能断崖式下降。但是,一旦请求量很大的话,很可能第一页的请求不一定扛得住。为了提升整个列表的性能,肯定要做一定的缓存设计。下面来介绍一下一个最常见、最简单的缓存手段。
方案一:列表结果缓存缓存最简单的就是缓存首页/前几页的结果,因为大部分情况下列表80%产品都是只使用第一页或者少数的前几页。以录制列表为例,假设我们缓存第一页的录制结果,那么可以用List去缓存。如下图所示:
首页结果缓存的缺点
1.一致性维护的困难。例如新增、删除视频的时候,固然需要维护这个List的一致性。你能想到可能是新增、删除的时候,直接对list进行遍历,找到对应的视频进行新增或者删除操作即可。但实际上在读、写并发场景下,动态维护缓存是很容易导致不一致的。如果为了更好的一致性考虑,可以考虑有变更的时候便删除掉整个list缓存。
2.过于频繁的维护缓存。无论走修改策略还是删除策略,其维护的时机就是缓存的内容发生变动的时候。缓存整个结果意味着结果变动的内容可能性非常大。例如录制的状态是经常发生变更的:新建->录制中、转码中、转码完成、完成等等。录制的标题也是可能发生变动的,录制的打击状态也是随时可能变的,权限也是可能被管理员修改的。这些单一录制的任意字段都可能需要对整个list缓存进行维护(修改/删除),如果采取的是删除策略,那么频繁的维护动作会导致缓存经常失效而性能提升有限。如果采取更新策略则又维护困难且有一致性的问题。
3.缓存扩散维护的困难。这个在“我的录制”里不存在这样的问题,但是假设我们把“我的录制”的功能范围扩充为:属于我的录制以及我看过的录制。这种情况下用首页结果缓存就会有巨大挑战。因为一个视频X可能被N个人看到。但是每个人都有自己的首页缓存(一个人一条list的redis结构),当X的某个字段(例如标题)发生变动的时候,我们需要找到这N个人的list结构。你可能会说,可以采取keys的操作找出来这些list去维护即可。但是keys操作是O(N)时间复杂度的操作,性能极差。哪怕我们采取scan去替换keys,在N极大的情况下,这里的损耗也是非常巨大的。
方案二:ID查询+元素缓存另外一个可行的方案是先查询出这一页的ID数据,然后再针对ID去查询对应页面所需要的其他详情数据。如下图所示:
这样的好处是缓存设计可以不针对某个用户的页面结果去缓存,而是把元素信息缓存起来,这个方案有3个好处:
1.查询数据库只查询ID的话,可以走聚簇索引,少一次回表。而且select 的字段数据也变少,查询因为搜索的字段变少了,本身查询的性能也会提升(网络传输的数据变少了)。
2.缓存的维护很简单。因为缓存的变化是单一数据结构的变化而不是一个集合的变化,维护起来会轻便很多。
3.没有缓存扩散的问题。假设一个录制被N个人浏览过,这个录制的状态变更也仅需要变动一个缓存key。
但是这个方案也有挑战。假设一页查询30个数据,实际上是一次数据库操作+30次缓存的操作。这里30次缓存操作可能有一些命中换成有一些没命中缓存。最简单粗暴的方案就是把30个ID凑一起例如:1_2_3_4........_30,一批查询就是一个大缓存结果。但是这样就又绕回去方案一里面的缓存缺点里了。所以只能一个缓存一个KEY,但是要实现一个机制让命中缓存的直接读缓存,让没有命中缓存的走数据库查询后再回填到缓存中。这里是有一定实现复杂度的,而且如果30次缓存操作都是串行的话,叠加起来耗时也是个不小的,还需要考虑并行获取的情况。其示意图如下所示:
这套方案本身集成到了腾讯会议一个缓存组件FireCache上,业务只需要调用API即可,不需要考虑哪些缓存命中哪些缓存不命中,也不需要考虑并发的问题。
方案三:ID列表缓存+元素缓存方案一和方案二的结合。
对于ID的列表,也采取Redis集合结构体去缓存,这样查询ID列表能尽量的命中缓存。当查到列表后,元素本身也是像方案二一样缓存起来的,那么也会大量命中缓存。这里的集合结构体可以采取ZSet,也可以采取List。采取Zset的场景大概率是动态更新策略的场景,而采取List的场景则更多是动态删除策略的场景。两者的优劣前面已经介绍过了不再解释。
以上就是最典型的列表接口的常见优化方案,在没有其他特殊的建设下可以按照合适的需要选择。
以下是三个方案的优点和缺点。
混合数据源列表问题有些时候,数据来源可能不是简单的一条SQL语句就能拿到数据的。例如朋友圈需要展示的数据不仅仅是自己发表过的数据。还涉及到朋友发表的数据中权限可见的数据。我们假设一个类似朋友圈的产品底层也是用关系型数据库存储的,下面我们看基于这样的数据库设计是实现一个列表查询有哪些挑战。
挑战一:复杂查询如果按照常规的搜索条件独立去搜,我们的SQL语句实际上是一条很复杂的并集语句。这个语句大概是:
select * from t_friends_content where creator_id = {自己} union allselect * from t_friends_content where creator_id in (select friend_id from t_relations where hostid={自己})最后这两个语句还需要分页和排序!
如果不考虑性能的话(一些B端的场景使用量非常低频),仅仅两条SQL语句做union操作也勉强可以接受。但是大部分情况下我们做一个C端业务的话,这个性能是不能接受的!
挑战二:跨库分页&排序问题更难以解决的问题还有一个?很可能朋友圈的内容库,关系链库是来自于两个独立的数据库。更有甚者,可能来自于两个系统。这样的跨库场景下分页、排序将带来极大的实现复杂度和一个指数级的性能下降。例如,我需要查询第3页的朋友圈数据,实际上我是需要查询前3页的“我发表的朋友圈”+前3页的“我朋友的朋友圈”之后,在内存中混合排序才能得到真实的第三页数据。如果这里的页码越来越深,这里的性能会指数级的下降。
如下图的一个例子所示:要搜索第三页的数据,实际上不能看两个数据源的第三页数据,而是要在第二页中找到的数据才是对的。
跨库分页的问题在分库的场景下也会一样遇到,例如查询我发表的朋友圈时候,如果朋友圈的数据库是分库的(假设是按照朋友圈ID去分库),也一样有类似的问题。具体内容可以参考这里的文章:业界难题-“跨库分页”的四种方案-腾讯云开发者社区-腾讯云
以上是一个列表设计可能遇到的一些常见挑战。可以看到,在外行看来非常简单的列表查询,一旦要求要做成一个高性能的产品,其实现的难度对比就像一俩玩具车和一台F1方程式跑车的区别。功能都是能跑,但是面临的挑战完全不一样。这也是优秀后台研发工程师的价值所在——同样一模一样的功能,要以一个高性能、高可用的标准去实现它,这两者无论从设计还是实现上根本就不是同一个东西。
介绍完通用的方案和挑战后,以下介绍腾讯会议的录制列表是怎么设计的。
三 腾讯会议“我的录制”列表优化实践3.1 录制列表的挑战“我的录制”列表本来是腾讯会议一直存在的功能。功能背后的接口在现网也稳定运行了非常久的时间。虽然数据库的数据量非常大,但因为这个接口的业务功能并不复杂(就只是查询属于自己的录制文件),而且只开放给了企业的管理员,调用量很低,所以从实现上后台老的实现就是直接使用数据库的查询来完成的。
但是随着这次的录制面板的在APP一级入口开放,会给这里接口性能提出更多地挑战要求。由于录制列表的数据目前存在于一个庞大的单表MySQL中,底层的压力是非常巨大的。在我们第一轮的压测中,在270qps的场景下,成功率仅有94.71%。
为了应对这个挑战,在业务层,我们需要顶住流量的冲击,我们的选择是通过缓存保护好后面的数据接口。
3.2 录制列表的缓存设计实际上,我的录制列表是一个非常经典的业务场景:业务简单(仅查自己的数据)但是要求的并发高,典型的场景如视频网站中的我的视频、微博中的我的微博、收件箱里我的消息等等。常用的方案前文已经介绍过了。以下介绍在腾讯会议的实践方案。
3.2.1 “我的录制”的2层缓存设计实际上针对这类型的查询,如果最简单粗暴的优化,是把数据全量缓存到Redis中。直接把Redis当存储使用。这样性能绝对扛扛的。(目前腾讯会议主面板的列表的存储设计就是没有DB只存Redis的)。但千万级别的数据库数据使用Redis重新缓存一轮需要非常高的成本(RMB成本),而且也会有Big Key的大集合问题(每个用户的录制视频是持续性单调递增,造成list中的数据太大)。考虑到用户的查询行为绝大部分的场景会集中于首页,我们可以缓存首页的数据来让绝大部分的数据能命中缓存从而保护到后台。
最简单的思路当然是直接缓存某个用户的首页的结果(即前文的方案一),但是这个设计有以下几个问题:
1.首页返回的数据包是比较大的(接口40多个字段),这样会导致每个用户需要缓存的数据体积较大,所需要的缓存成本较多。
2.用户的数据发生变动的时候,会惊扰不必要的缓存数据。例如一个用户的首页数据是id=1到id=30的数据,假设我们缓存了1-30这些数据的详细内容,这时候当用户新增了一个id=31的视频的时候,或者id=5的这个视频的标题发生变更的时候,我需要整体失效掉id=1-30这个缓存的结果。但实际上每次的变动其实只是一个数据,但是我们却被迫惊扰了30个数据。这也是方案一最大的缺点。
最后我们采取了实现最为困难的方案三,但是列表的缓存我们简化了只存第一页的数据。所以缓存的首页数据是带缓存,也就是说用一个list缓存了id=1到id=30的id。然后需要查询这些录制详情的时候,我们再走对应的批量接口获取对应的详情。最终形成一个二级的缓存结构。第一级是ID索引,第二级是录制详情缓存。
这样当某个视频的数据发生变更的时候,我只需要失效掉这个list的缓存,而每个背后详情的缓存是不会失效的。
3.2.2 性能优化后的效果在这样的缓存设计下,我的录制接口能经收住了700+qps的压力:
同时平均耗时也从原来的308ms降低到了70ms,提升了4倍的性能!
“我的录制”列表后续优化方向
目前我们实现的一级索引缓存(id缓存),是用list维护的。一旦有新增录制、修改录制,为了缓存的一致性维护方便,目前的手段都是直接清空缓存,等下次的查询的时候再重新装载。这样的好处是肯定不会出现读写一致性的并发导致数据不一致的问题。后续如果有更高的性能要求,我们可以考虑新增缓存直接往list 中 push 数据,即有动态删除数据的策略改成动态更新的策略。
四 “全部文件”架构实现4.1 多数据源查询的挑战4.1.1 数据来源复杂,查找功能开发难度大录制全部文件的功能是包含多种数据来源的。如下图所示,是笔者的一个面板数据,虽然都是录制,但是其来源于非常多的可能,可能是自己创建的,也可能是自己浏览过的,也可能是自己申请查看的,也可能是别人邀请我看的。以下是测试环境的一张截图,数据来源比较全,读者可以从中看到其数据源是比较复杂的。
所以这肯定是一个多数据源的查询场景。属于前面我们提到的“混合数据源列表”的一个典型场景。要完成一个完整的全部文件需求,按照正常思路去开发,需要聚合查询以下几种数据源:
1.用户本身的录制数据。例如创建了一个录制,就需要出现在面板中。本数据目前存储于媒体应用组的云录制系统后台。
2.录制的授权数据。例如一个录制假设是参会者可见,那么我参加了这个会议也要出现在我们的面板中,例如一个录制被指定给我可见,我也要马上能看到这份数据。这里的数据存储星环后台二组的权限系统。
3.用户的浏览记录。如果一个视频被我浏览过,就应该马上能出现在列表中。哪怕这个视频后续被删除/移除了我的权限(后续权限被剥夺,封面图、标题会降级显示)。这是浏览记录系统维护的独立数据库,由星环后台一组维护。
4.用户操作的删除记录。出现过的录制,只要一个用户不想看到,就可以移除,可以认为是一个黑名单工作。这部分工作是全新功能,开发这个需求前没有这部分数据。
试想一下,如果要完成一个 my-all-records的接口开发,至少你需要做一下的工作:搜索基于uid=自己,搜索4个数据源的数据。其中1 2 3 数据求并集,最后再对数据4求差集。如下图所示:
但这样做虽然简单,但是有几个问题是极难克服的:
1.性能查询负担,当一个数据库的数据量都是千万-亿级别的,本身查询的耗时客观摆在这里。做了重度工作之后还需要做各种集合运算,成本很高。最后出来的效果就是接口的耗时很高,反映到录制面板体验上就是用户需要等待较高的时间才能看到数据,这对于用户体验追求极致的团队而言是无法接受的。
2.稳定性挑战。每次查询海量数据数据源本身就是一个“高危动作”,如果一个海量数据库查询成功率是99%,那么四个数据库都成功的概率就会下降到96%。
3.成本。为了实现困难且耗时高的查询,我可以选择购买足够高配置的数据库、优化数据库的部分配置参数去抗住查询数据的压力,提升查询成功率。同时在服务器中,通过代码的一些优化,并发多协程地去并行化四个查询的动作。最后部署足够多的机器,应该也能使得整体的稳定性提升,但是这样需要非常高的设备成本,在“降本增效”的大背景下是无法承受的。这是一种用战术勤奋去掩盖战略勤奋的做法。
4.分页挑战。如果说通过内存聚合还能勉强做到功能的可用。但是引入分页后这个问题变得几乎无解,因为在一个分布式系统中,要聚合第N页的数据需要合并所有系统的前N页数据才能计算得出,注意是计算前N页不是第N页,相当于做一个多路归并排序!也就是翻页越深,查找量和计算量越大。而且我们这个场景更复杂,分页后还需要剔除一部分删除记录,其挑战如下图所示:
4.1.2 查询性能的挑战由于腾讯会议具有海量的C端请求,作为一级TAB的录制面板,当他全量的开放之后讲具有很大的查询量。这对查询系统的QPS提出了很大的要求。根据现在峰值的会议列表的QPS来折算,最后目标定在了录制面板列表接口需要承接700qps的查询的目标。按照一页30个录制数据返回来看,这个目标需要一秒返回21000个录制的数据,其并发挑战是不小的。同时录制面板作为一个核心功能,我们希望能达到面板的秒开,那么对于接口耗时也同样有着要求。我们认为,一个页面要能秒开,接口耗时最多只能到500毫秒。
4.2 全部文件的基本架构设计为了应对以上两个挑战,我们选择了计算机领域中典型的以空间换时间的思路。我们需要一个独立的数据源,能提前计算好用户所需的文件,然后搜索的时候只搜索该数据源就能得到所筛选的已排序的列表数据。设计图如下:
4.2.1 存储选型的述求:为此,我们需要评估这个数据库的量级以便选择合适的数据库。(部分数据已模糊化处理)
录制数据库存量数据是约x000w;授权数据库存量数据月x000w;浏览数据虽然一开始量级不大,但是随着浏览留痕的上线将以极快的速度增长;删除记录表功能是新做的未来预计数据量也很少,估计在十万级别。故合并数据库的行数将达到一个很大的级别(x亿)。每日新增的录制数据约xw。按照一条数据被x人浏览/授权来初步计算,每天新增面板数据量将在x万的数据。一年的增量x亿附近,也就是说5年内,在业务体量没有上升的前提下,数据量就将达到x亿级别的量级。
对于数据库要求就是三点:
1.横向扩展能力好,能存储x亿级别的数据
2.在x亿级别的数据量下能保持写入、查询的稳定
3.查询性能高,写入性能较好
4.成本低
4.2.2 存储对比一览:在此,我们考虑过几个数据库,考虑了存量量级、查询性能、写入性能、成本等因素下,大致对比因素如下表所示:
最终从业务适配、扩展性和成本等多种维度的考虑,我们选择了MongoDB作为最终的选型数据库。
4.2.3 数据扩展性设计:这里我们不讨论具体的表设计、索引设计等的细节。撇开这些非常业务的设计外,在整体架构的扩展性、稳定性上,也有很多的挑战点。下面抛出来其中的比较共性的3点,以便读者参考。
1.如何让数据存储是平衡的,也就是说尽可能能让数据均匀的扩散,而不是集中在极少数的分区,即数据的分片要具有区分度。如果某个分片键值的数据特别多,导致数据聚集,就会导致该chunk块性能下降,即避免jumbo chunk。这是非常容易导致的。假设我们用用户的UID作为分区键,那一旦用户的录制如果非常多,它的数据多到足以占据分区一半以上的量,那么这个用户数据所在的分区就一定会有数据倾斜。这是架构师设计数据分片的时候特别需要避免的。
均匀分配的分区数据
部分分区存储了过多的数据,即数据倾斜。
2.数据避免单调性。即如何让写入数据的性能能尽可能横向扩展。因为每天新增的数据量极大,这意味着除了查询性能,写入性能的要求也很高,如果写入一直是针对某个分片节点进行写入,这是有写入单点瓶颈的,也就是所谓的数据的增长要规避单调性。
3.如何让热点查询命中分片键。这个很好理解,如果不命中分片键,再好的存储也无法提供足够的查询性能。
解决思路:
最直观的解法肯定是基于UID做数据分区的,因为从数据查询的角度看,肯定是需要基于用户uid查询的,这意味着核心的查询肯定能命中uid这个分片键。其次uid分片也具有不错的区分度,大部分情况下数据是比较均衡的。其次uid背后的数据并不是单调递增的,因为全局范围内,所有的用户都在产生数据,也就是说写入操作不会只在某个chunk。
但是uid分片有一个问题就是数据倾斜问题,例如有部分的用户会特别活跃,导致某些用户的留痕记录会特别多。假设我们称这些用户叫大V用户(类似微博的大V很多粉丝,他的粉丝数据也很容易造成倾斜)。如果使用uid分片,那么大V用户的数据所处的chunk就可能是jumbo chunk。
为了解决这个问题,我们采取了uid+文件id作为联合分片键,这样可以实现大部分小用户的数据只需要集中在同一个chunk种,而数据量过大的用户则会劈开到多个chunk里,从而避免热点的问题。最后三个挑战均能解决。
4.3 数据一致性的挑战使用空间换时间的方法,最大的问题就是一致性。
一致性来源于两个地方:
1.数据的数量是否是一致的。这个很好理解,例如新增了一个录制,全部文件的数据库需要有这个录制;刚刚授权了一个人,全部文件的数据库也需要有这个录制。同样道理,删除录制后也需要在全部文件数据库中删除此记录。
2.数据字段是否是一致的。这个也好理解,因为数据本身是一直在发生变化的,例如录制的状态、权限、标题、封面乃至安全审批状态,浏览/授权这个视频的时间都会发生变化。
4.3.1 数据量一致性的解决方案要解决一致性的问题,除了分布式事务这种很重的解决手段外,我们选择了可靠消息+离线对账混合处理的设计去解决。
一:消息可靠性消费
虽然我们有多个数据库的数据源需要处理,但是好消息是一个录制的生产和删除从路径上比较收敛的。所以我们可以在录制的开始、录制的删除、权限点变更的时候,通过监听外部系统的Kafka事件来做数据的同步变更。然后通过Kafka 消息的at-least-once特性来保证消费的最终成功。对于消费多次都不成功的消息,依赖消息做可靠消费最难解决的问题有两点:
1.消息失败导致消息丢失。
虽然Kafka有at-least-once机制,如果一个消息一直消费失败,是会阻塞该分区后面的消息消费的。所以我们实现了一套建议的死信通知的能力。在重试一定次数还是无法消费成功,我们会投递这个消息到一个死信主题中,并且告警出来。一旦收到这个告警,就会人工介入去做数据的补偿,保证数据最后能被人为干预修复成合适的状态。
2.消息幂等。
Kafka在非常多的环节都可能导致消息重复。例如消费者重启、消费者扩容导致的Rebalance、Kafka的主备切换等等。为了保证数据不会被重复写入,我们自研的一个消费的框架,通过封装消费逻辑的流程,抽象出了一个幂等Key的概念由消费的代码去实现,当发现相同的幂等Key已经存在于Redis则认为近期已经消费过,直接跳过。
二:对账
虽然,消息消费一侧我们有把握能保证消息最终能落库,但是消息生产侧和一些一些产品逻辑的遗漏,是会可能导致数据是有丢失的。对于一个拥有大型的数据的系统,我们必须对自己写的代码保持足够的怀疑,无论是系统内部还是外部系统,没有人能保证不写出bug。
这时候就需要有人能发现这些数据的不一致,而不是等待用户发现问题再投诉。为此,我们专门开发了一个复杂的对账系统,用来把每个情况的数据进行增量、全量的对账。这个过程中,我们也确实发现了不少问题,例如权限系统某些场景下会忘记发送Kafka事件、例如媒体中台系统对于混合云的场景下会丢失webhook事件导致Kafka消息忘记发送,再例如产品的审批流设计中没有很好的考虑录制文件权限已经改变的场景,导致文件数据依然能被审批通过的场景等。
4.3.2 数据字段一致性的解决方案如果说数据量的一致性还算好解决的话,数据字段的一致性则异常复杂。从理论上说,两者的解决思路是一样的,即:在数据发生变更的时候,可靠地消费这个变更消息,然后更新全部文件的数据记录。
然而字段一致性的困难在于,这种变化点极多,例如标题会修改、安全的打击会修改、权限值会修改、甚至还有云剪辑后时长都可能会修改。如果真的每个字段都要时刻保持一致,这里几乎需要改动到原本云录制系统、权限系统的方方面面,所有的更新时机都得对齐,而且还存在并发消费的问题导致字段准确性堪忧,极容易出现各种个月的bug。
“如无必要,勿增实体”,这是我们常说的奥卡姆剃刀定律。我们在其中得到了一些灵感。
如果说新增了全部文件表是因为没办法解决多数据库的分页问题,多数据库的聚合计算问题,是用空间换时间的必要性。那么把详情字段也冗余存储就是增加了实体的设计。
我们最终的设计决定不冗余可能发生变更的字段。最终其设计类似于一个数据库的索引树一般。我们的全部文件数据库存储的仅仅是多个数据源的索引,数据发生增减,索引自然需要维护,但是数据表详情字段的变更并不需要变更索引的。如下图所示:
这样设计后,整个业务系统的架构变得简洁起来。我们仅需要花大力气去维护索引的有效性和查询性能即可。而数据的详情例如标题、权限值、封面等都通过已有的批量接口获取最真实的值即可,从而从根源上避免了维护十几个字段的一致性问题。
最后,这套系统的设计如下:
243.3 第一版本性能效果通过这套设计,我们仅使用了1个月左右的时间便上线了录制面板的功能。虽然整体的架构上具备不错的扩展性,这个版本的性能实际上是无法达到预期的,第一版本的压测数据如下:
可以看到,当查询性能的qps达到200以上时,就出现了成功率的下降,再往后压测成功率明显下降,可见已到瓶颈。而且从耗时上看,也较高,接近一秒。
这个也是我们预期内的。因为数据库一次查询并不能查询回来所需的所有字段,背后字段的获取的接口是有瓶颈的。例如一个用户一页数据是30个录制视频,那么除了第一部从MongoDB获取到这30个视频的索引外(从我们压测时候看这一步非常的快,侧面证明了MongoDB的性能以及我们分片、索引设计的合理性),还需要调用数个接口去获取权限、详情、安全状态等。这里每次接口调用实际上是一个批量接口,如果压测是200qps,那么实际上对于后端的数据查询是200*30=6000/s的数据行搜索压力。现在无论权限库还是云录制库,底层都是千万级的存储(存储在MySQL中),这样的查询压力无疑是很有压力的。
为了应对全量后更高的QPS压力,优化点在哪里呢?
优化方向:一个数据源一个缓存如果套用前面介绍过列表系统的优化,我们这里的优化可以算作是方案二的优化。所以里面是有能力做数据源的缓存的。最后我们的优化思路实际上很直接:如果我需要调用N个批量的接口,如果我能把N个接口都有缓存,是不是就可以大量降低后端的压力,通过命中缓存来降低获取数据的时间,从而大幅提升性能。
这个思路是可行的,但是挑战也很大。原因在于这里每个接口实际上都是批量的接口。例如一个用户A的首页是id=1、id=2.....id=30的视频的详情记录表,后端提供的批量接口的入参就是这30个id。然后返回30个数据回来。这时候如果我们要设计缓存,最直接的设计就是把id=1到id=30的拼成一个很长的key串,缓存这一批的结果,下次再查询的时候就能命中了。
但是这样设计有两个问题:
1.缓存利用效率很低。例如A用户的首页是id=1到id=30被我们缓存了,但是B用户的首页可能是id=1到id=29,这时候其实B用户是无法利用之前缓存的视频内容的,哪怕B用户看到的数据实际上是A用户的子集。这样会很大的增加缓存的存储量,从而提升系统的成本
2.缓存维护非常困难。例如id=3的视频现在发生了标题的变更,这时候我们应该去通知A用户和B用户的缓存要刷新,否则他看到的就是变更前的脏数据,但是对于A用户它的缓存key可能是1234567....30,你现在拿着id=3这个信息是没办法找到这个key去更新的,除非我们还要额外维护一套key和id的关系。
为此,我们的解决方案是,面对批量查询接口,我们也会只缓存每一个具体的数据,一个数据一个key。然后把数据分为两波,第一波是数据是否已经在缓存的,则直接查询。第二波是缓存查询不到的,则走批量接口。这样一来,任何一个数据只要被其中一个用户的查询行为载入了缓存,它都能被其他用户复用。同时,任何一个数据的更新都及时地刷新缓存。
当然因为我们需要调用的批量接口非常多,如果每个接口都需要做这样的数据分拆逻辑,无疑是工作量巨大的。为此,我们星环后台一组开发了一个FireCache组件,其中提供了类似的批量缓存能力,使得这一工作变得简单。
性能优化后的效果压测数据:
在这样大量缓存化的方案之后,我们大量的提升录制面板获取全部文件的接口性能,在压测达到目标700qps时候,平均耗时仅96毫秒。
在并发提升了3倍压力的背景下,平均耗时还提升了10倍!可见优化效果明显。也能达到我们现网流量的压力以及秒开的后台性能要求。
现网数据:现网虽然没有压测的峰值数据,但是数据情况更为复杂,但是表现依旧非常理想。
五 小结本文介绍了列表系统的性能设计遇到的一些挑战点,以及在做缓存优化的时候可以采取的三个解决方案。最后,我们还花了很大的篇幅介绍了腾讯会议录制系统的实践思路,希望能帮助大家举一反三,综合自己业务的特点做出更好的设计。总的来说,为了应对录制面板的高并发、海量数据的挑战,腾讯会议的录制系统总共采取了以下的几个手段,最终达成了较好的效果。
1.MongoDB的海量数据实践设计
2.对账系统
3.二级缓存设计
4.可靠消息的处理
5.缓存组件的开发