亿级月活全民K歌feed业务在腾讯云cmongo中的应用及优化实践

业务背景及业务mongodb规模

      全民K歌作为腾讯音乐集团四大产品线之一,月活超过1.5亿,并不断推出新的音娱功能及新玩法,极大丰富了数亿用户的音乐娱乐活动。

       mongodb天然支持高可用、分布式、高性能、高压缩、schema free、完善的客户端访问均衡策略等功能。因此,作为腾讯音乐集体核心部门,K歌业务众多业务采用腾讯云cmongo作为主存储服务,极大的方便了K歌业务的快速迭代开发。当前,cmongo在全民K歌推荐系统、feed信息流、ugc、画像等业务线中被广泛使用。

      K歌信息流业务核心数据采用腾讯云cmongo进行数据存储,该业务采用mongodb分片架构,集群使用模式及规模如下:

  • 峰值流量:数十万/秒
  • 分片数:数个
  • 单个分片副本数:1主4从
  • 读写分离
  • ……

     本文主要分享K歌技术演进过程中的一些踩坑过程、方案设计、性能优化等,主要包括以下技术点:

  • 全民K歌业务特性
  • Feed业务读写如何选项
  • Feed核心表设计
  • Feed数据吐出控制策略优化
  • K歌业务层面踩坑及优化过程
  • K歌业务mongodb使用踩坑及优化
  • 腾讯云cmongo内核新特性在K歌业务中的使用

第一章:业务层面优化过程

  1. 腾讯音乐全民K歌业务特性

     每一个社交产品,都离不开feed流设计,在全民K歌的场景,需要解决以下主要问题:

  • 我们有一些千w粉丝,百万粉丝的用户,存在关系链扩散的性能挑战
  • feed业务种类繁多,有复杂的业务策略来控制保证重要的Feed的曝光

    对于feed流的数据吐出,有种类繁多的控制策略,通过这些不同的控制策略来

    1. v曝光频控,避免刷流量的行为
    2. 好友共同发布了一些互动玩法的Feed,进行合并,避免刷屏
    3. 支持不同分类feed的检索
    4. 安全问题需要过滤掉的用户feed
    5. 推荐实时插流/混排
    6. 低质量的Feed,系统自动发类型的Feed做曝光频控
  1. 读写选型

      Feed主流实现模型主要分为3种,这些模型在业界都有大型产品在用:

  1. 读扩散 QQ空间)
  2. 写扩散 (微信朋友圈)
  3. v读扩散+普通用户写扩散 (新浪微博)

      没有最好的模式,只有适合的架构,主要是权衡自己的业务模型,读写比,以及历史包袱和实现成本。

     K歌使用的是读扩散模型,使用读扩散模型的考虑如下:

  1. 存在不少千万/百万粉丝的大v,写扩散严重,推送延迟高,同时存储成本会高
  2. 低活用户,流失用户推送浪费计算资源和存储资源
  3. 安全合规相关的审核会引发大量写扩散
  4. 写扩散qps=3 x 读扩散qps
  5. K歌关系链导入的历史原因,早起写扩散成本高,同时后期改成读写扩散混合的模式改造成本大。

      但是读扩散模式存在以下比较明显的缺点:

  1. 翻页把时间线前面的所有数据拉出来,性能开销越来越大,性能越来越差
  2. 关注+好友数量可达万级别,实现全局的过滤,插流,合并,频控策略复杂,性能不足

3. 主要表设计

3.1. Feed表设计

     Feed这里的设计建立了2个表:

  • 一个是Feed详情表

     该表使用用户userid做片健,feedid做唯一健,表核心字段如下:

  • Feed cache

     该表使用uid做片健和唯一健,并且做ttl,表核心字段如下:

      FeedCache是一个kv存储的文档,k是uid,value是CacheFeedData jce序列化后的结果。

3.2. 账号关系表设计

     关注关系链常规涉及两个维度的数据:

      一个关注,一个粉丝 (一个关注动作会产生两个维度数据)

  • 关注列表

     关注一般不是很多,最多一般只有几千,经常会被全部拉出来,这个可以存储为kv的方式(高性能可以考虑内存型数据库或cache)

     关注是用Redis存储的,一个key对应的value是上面RightCache这个结构的jce序列化后的结果。

  • 粉丝会

      粉丝会是一个长列表(几百万甚至上千万),一般会以列表展示,存储与mongodb中,以用户id为片健, 每个粉丝作为一个单独的doc,使用内存型的存储内存碎片的损耗比较高,内存成本大。关注和粉丝数据可以使用消息队列来实现最终一致性。

     粉丝是mongodb的文档,主要是fuidrealtiontypetime

 

4.读扩散优化

     读扩散模型的存储数据主要分为3大块:

  • 关系链
  • Feed数据
  • 最新更新时间戳。

4.1. 优化背景

       未优化前的关系链读扩散模型,每次拉取Feed数据的时候,都需要通过关系链,时间戳,以及Feed索引数据来读扩散构建候选结果集。最后根据具体的Feedid拉取Feed详情来构建结果进行返回。

        对于首屏,如果一页为10条,通过关系链+最新时间戳过滤出最新的20uid(预拉多一些避免各种业务过滤合并策略把数据过滤完了),然后拉取每个uid最新的60Feed的简单的索引信息来构建候选集合,通过各种业务合并过滤策略来构建最多10条最新Feedid,再拉取Feed详细信息构建响应结果。

        翻页的时候把上一次返回的数据的最小时间戳basetime带过来,然后需要把basetime之前的有发布feeduid以及basetime之后有发布的最近20uid过滤出来,重复上面构建候选集合的过程来输出这一页的数据。这种实现逻辑翻页会越来越慢,延迟不稳定。

4.2. 优化过程

      针对以上问题,所以我们在读扩散模型上进行了一些优化,优化架构图如下:

我们通过读扩散结果的Cache模式,解决翻页越来越慢,复杂的全局过滤逻辑。

Cahce优势

  • 灵活过滤,实现复杂的过滤合并逻辑
  • 翻页读Cache性能高,首页使用Cache避免重复计算

时间线Cache需要解决的问题?弊端?

  • 关系链变更Cache有延迟
  • feed导致Cache体积减小

此外,我们把Cache主要分为全量生成过程,增量更新过程,以及修补逻辑三部分来解决这些问题:

  • 全量是在首次拉取,和24小时定时更新
  • 增量则是在首页刷新,无最新数据则复用Cache
  • 通过缓存关系链,如果关系链变更,活脏Feed太多过滤后导致的Cache体积过小,则触发修补逻辑。

 

     最终,通过这些策略,让我们的Feed流系统也具备了写扩散的一些优势,主要优势如下:

  • 减少重复计算
  • 有全局的Feed视图,方便实现全局策略

粉丝求count慢优化

     前面提到,粉丝关系表存在mongodb中,每条数据主要包含几个字段,用户的每个粉丝对应一条mongodb文档数据,对应数据内容如下:

1.	{ "_id" : ObjectId("6176647d2b18266890bb7c63"), "userid" : “345”, "follow_userid" : “3333”, "realtiontype" : 3, "follow_time" : ISODate("2017-06-12T11:26:26Z") }    

     一个用户的每个粉丝对应一条数据,如果需要查找某个用户下面拥有多少个粉丝,则通过下面的查询获取(例如查找用户id”345”的用户的粉丝总数)

    db.fans.count({"userid" : “345”})

     该查询对应执行计划如下:

1.	{  
2.	        "executionSuccess" : true,  
3.	        "nReturned" : 0,  
4.	        "executionTimeMillis" : 0,  
5.	        "totalKeysExamined" : 156783,  
6.	        "totalDocsExamined" : 0,  
7.	        "executionStages" : {  
8.	                "stage" : "COUNT",  
9.	                "nReturned" : 0,  
10.	                ......  
11.	                "nSkipped" : 0,  
12.	                "inputStage" : {  
13.	                        "stage" : "COUNT_SCAN",  
14.	                        ......  
15.	                }  
16.	        },  
17.	        "allPlansExecution" : [ ]  
18.	}  

      和其他关系型数据库(例如mysql)类似,从上面的执行计划可以看出,对某个表按照某个条件求count,走最优索引情况下,其快慢主要和满足条件的数据量多少成正比关系。例如该用户如果粉丝数量越多,则其扫描的keys(也就是索引表)会越多,因此其查询也会越慢。

       从上面的分析可以看出,如果某个用户粉丝很多,则其count性能会很慢。因此,我们可以使用一个幂等性计算的计数来存储粉丝总数和关注总数,这个数据访问量比较高,可以使用高性能的存储,例如Redis的来存储。幂等性的计算可以使用Redislua脚本来保证。

     优化办法:粉丝数量是一个Rediskey,用lua脚本执行(计数key incrby操作与opuid_touid_opkeysetnx expire)来完成幂等性计算。

      满足这个条件,则直接返回一个可能的计算过程的幂等性验证,设计一个可行的高可用方案进行处理,这个过程的处理过程可以整的明天,分片的查询性能的最大化功能,如何实现这个最大化过程的设计。

第二章:mongodb使用层面优化

      该业务mongodb部署架构图如下:

     如上图所示,K歌业务mongodb架构图客户端通过腾讯云VIP转发到代理mongos层,代理mongos接受到请求后,从config server(存储路由信息,架构图中未体现)获取路由信息,然后根据这条路由信息获取转发规则,最终转发该请求到对应的存储层分片。

       在业务上线开发过程中,发现mongodb使用的一些不合理,通过对这些不合理的数据库使用方式优化,提升了访问mongodb的性能,最终提升了整个feed流系统用户体验。K歌业务mongodb访问优化点如下:

1. 最优片建及分片方式选择

      前面提到信息流业务feed详情表、粉丝列表存储在mongodb中,两个表都采用用户 userId来做分片片建,分片方式采用hashed分片,并且提前进行预分片:

         sh.shardCollection("xx.follower", {userId:"hashed"}, false, { numInitialChunks: 8192*分片数} )

        sh.shardCollection("xx.feedInfo", {feedId:"hashed"}, false, { numInitialChunks: 8192*分片数} )

    两个表分别选择feedIduserId做片建,并且采用hashed分片方式,同时提前对表做预分片操作,主要基于以下方面考虑:

  • 数据写

通过提前预分片并且采用hashed分片方式,可以保证数据均衡的写入到不同分片,避免数据不均引起的moveChunk操作,充分利用了每个分片的存储能力,实现写入性能的最大化。

  • 数据读

       通过feedId查询某条feed详情和通过userId查询该用户的粉丝列表信息,由于采用hashed分片方式,同一个Id值对应的hash计算值会落在同一个shard分片,这样可以保证整个查询的效率最高。

说明:由于查询都是指定id类型查询,因此可以保证从同一个shard读取数据,实现了读取性能的最大化。但是,如果查询是例如feedId类的范围查询,例如db.feedInfo.find({feedId:{$gt: 1000$lt:2000}}),这种场景就不适合用hashed分片方式,因为满足{$gt: 1000}条件的数据可能很多条,通过hash计算后,这些数据会散列到多个分片,这种场景范围分片会更好,一个范围内的数据可能落到同一个分片。所以,分片集群片建选择、分片方式对整个集群读写性能起着非常重要的核心作用,需要根据业务的实际情况进行选择。

2. 查询不带片建如何优化

       上一节提到,查询如果带上片建,可以保证数据落在同一个shard,这样可以实现读性能的最大化。但是,实际业务场景中,一个业务访问同一个表,有些请求可以带上片建字段,有些查询没有片建,这部分不带片建的查询需要广播到多个shard,然后mongos聚合后返回客户端,这类不带片建的查询效率相比从同一个shard获取数据性能会差很多。

      如果集群分片数比较多,某个不带片建的查询SQL频率很高,为了提升查询性能,可以通过建立辅助索引表来规避解决该问题。以feed详情表为例,该表片建为用户userId,如果用户想看自己发表过的所有feed,查询条件只要带上userId即可。

      但是,如果需要feedId获取指定某条feed则需要进行查询的广播操作,因为feed详情表片建为userId,这时候性能会受影响。不带片建查询不仅仅影响查询性能,还有加重每个分片的系统负载,因此可以通过增加辅助索引表(假设表名:feedId_userId_relationship)的方式来解决该问题。辅助表中每个doc文档主要包含2个字段:

  • FeedId字段

该字段和详情表的feedId一致,代表具体的一条feed详情。

  • UserId

       该字段和详情表userId一致,代表该feedId对应的这条feed详情信息由该user发起。

      feedId_userId_relationship辅助表采用feedId做为片建,同样采用前面提到的预分片功能,该表和feed详情表的隐射关系如下:

      如上图,通过某个feedId查询具体feed,首先根据feedId从辅助索引表中查找该feedId对应的userId,然后根据查询到的userId+feedId的组合获取对应的详情信息。整个查询过程需要查两个表,查询语句如下:

1.	//feedId_userId_relationship表分片片建为feedId,提前hashed预分片  
2.	db. feedId_userId_relationship.find({“feedId”: “375”},  {userId:1}) //假设返回的userId为”3567”  
3.	//feedInfo表分片片建为userId,提前hashed预分片  
4.	db. feedInfo.find({“userId”: “3567”})  

如上,通过引入辅助索引表,最终解决跨分片广播问题。引入辅助表会增加一定的存储成本,同时会增加一次辅助查询,一般只有在分片shard比较多,并且不带片建的查询比较频繁的情况使用。

3. 读写分离

     mongodb读策略默认支持多种访问策略,根不同业务需求,由客户端配置指定,通过readPreference配置选项进行设置,支持以下多种读策略:

  • primary 

      读primary主节点,如果不做任何配置,默认为该配置。

  • primaryPreferred 

     优先读主节点,如果主节点异常,则读从节点。

  • secondary 

     读全部通过从节点读数据,减少主节点压力

  • secondaryPreferred 

     从优先读,从异常后,读主节点

  • nearest 

     选择离自己网络延迟最小的节点访问,在跨可用区部署场景下,一般采用该配置

      K歌业务在发展初期,采用默认的读primary主节点方式进行数据读操作,前期查询都很正常,但是随着K歌业务规模的增长,业务访问主节点开始出现瓶颈:“主节点卡死、备节点闲死”。

      当前,业务的访问QPS已经超过数十万/秒,集群中的每个分片一主四从,从节点非常空闲,资源比较浪费。调整读策略为SecondaryPreferred配置后,主节点压力释放,业务访问不在抖动。

4. 数据备份过程业务抖动优化

        腾讯云cmongo默认凌晨会定期对集群数据做全量备份和增量备份,并支持默认7天内的任意时间点回档。但是,随着集群数据量逐渐的增加,当前该集群数据量已经比较大,开始出现凌晨集群定期抖动,主要现象如下:

  1. 访问时延增加
  2. 慢日志增加
  3. CPU使用率增加

      通过分析,发现问题和数据备份时间点一致,由于物理备份和逻辑备份期间需要对整实例进行数据备份,系统资源负载增加,最终影响业务查询服务。

     优化方式:数据备份期间隐藏节点,确保该节点对客户端不可见。

5. 排序查询索引优化

     在排序类查询中,经常出现慢查,以下面查询为例:

Db.feedInfo.find({userId: “353”, feedtype: 4 }).sort({userId: 1, feedtype: -1 })

    提前对该查询加索引:{userId: 1, feedtype: 1 }

     该SQL中查询条件是{userId: “353”, feedtype: 4 },排序方式{userId: 1, feedtype: 1 },字段一样,上线后发现很多慢查,通过分析其执行计划,发现该查询存在内存排序现象,因为某些用户满足查询条件的数据较多,甚至超过数万,加重了内存排序负担。该查询对应执行计划如下:

1.	db.feedInfo.find({userId:"353","feedtype":4}).sort({"userId" : 1, "feedtype" :-1}).explain().queryPlanner.winningPlan  
2.	{  
3.	        "stage" : "SORT",  
4.	        "sortPattern" : {  
5.	                "userId" : 1,  
6.	                "feedtype" : -1  
7.	        },  
8.	        "inputStage" : {  
9.	                "stage" : "SORT_KEY_GENERATOR",  
10.	                "inputStage" : {  
11.	                        "stage" : "FETCH",  
12.	                        "inputStage" : {  
13.	                                "stage" : "IXSCAN",  
14.	                                "keyPattern" : {  
15.	                                        "userId" : 1,  
16.	                                        "feedtype" : 1  
17.	                                },  
18.	                                "indexName" : "userId_1_feedtype_1",  
19.	                                "isMultiKey" : false,  
20.	                                "multiKeyPaths" : {  
21.	                                        "userId" : [ ],  
22.	                                        "feedtype" : [ ]  
23.	                                },  
24.	                                "isUnique" : false,  
25.	                                "isSparse" : false,  
26.	                                "isPartial" : false,  
27.	                                "indexVersion" : 2,  
28.	                                "direction" : "forward",  
29.	                                "indexBounds" : {  
30.	                                        "userId" : [  
31.	                                                "[\"353\", \"353\"]"  
32.	                                        ],  
33.	                                        "feedtype" : [  
34.	                                                "[4.0, 4.0]"  
35.	                                        ]  
36.	                                }  
37.	                        }  
38.	                }  
39.	        }  
40.	}  

     从上面的查询可以看出,该查询走了userId_1_feedtype_1索引,其执行步骤如下:

步骤1:根据userId_1_feedtype_1查找满足{userId:"353","feedtype":4}条件的所有数据,这里满足条件的数据可能数万行。

步骤2:根据生成的排序key,按照{userId: 1, feedtype: -1 }排序规则,也就是userId正序,feedtype反序的方式对步骤1的数万行数据进行内存排序。

      从上面的分析可以看出,整个查询因为内存排序过程繁琐响应比较慢,仔细分析查询条件和排序条件,于是优化索引,保持和sort排序条件一致,创建新的{userId: 1, feedtype: -1 }索引,其执行计划如下:

1.	db.feedInfo.find({userId:"353","feedtype":4}).sort({"userId" : 1, "feedtype" :-1}).explain().queryPlanner.winningPlan  
2.	{  
3.	        "stage" : "FETCH",  
4.	        "inputStage" : {  
5.	                "stage" : "IXSCAN",  
6.	                "keyPattern" : {  
7.	                        "userId" : 1,  
8.	                        "feedtype" : -1  
9.	                },  
10.	                "indexName" : "userId_1_feedtype_-1",  
11.	                "isMultiKey" : false,  
12.	                "multiKeyPaths" : {  
13.	                        "userId" : [ ],  
14.	                        "feedtype" : [ ]  
15.	                },  
16.	                "isUnique" : false,  
17.	                "isSparse" : false,  
18.	                "isPartial" : false,  
19.	                "indexVersion" : 2,  
20.	                "direction" : "forward",  
21.	                "indexBounds" : {  
22.	                        "userId" : [  
23.	                                "[\"353\", \"353\"]"  
24.	                        ],  
25.	                        "feedtype" : [  
26.	                                "[4.0, 4.0]"  
27.	                        ]  
28.	                }  
29.	        }  
30.	}  

     上面的执行计划相比前面内存排序更加简介,整个查询过程直接通过sort排序索引userId_1_feedtype_-1快速返回满足条件的数据。

      排序索引调整后的时延对比如下:

关于作者

全民K歌后台开发一组:

ctychen,ianxiong

腾讯云mongodb:

腾讯云MongoDB当前服务于游戏、电商、社交、教育、新闻资讯、金融、物联网、软件服务等多个行业;MongoDB团队(简称CMongo)致力于对开源MongoDb内核进行深度研究及持续性优化(如百万库表、物理备份、免密、审计等),为用户提供高性能、低成本、高可用性的安全数据库存储服务。后续持续分享MongoDb在腾讯内部及外部的典型应用场景、踩坑案例、性能优化、内核模块化分析。

{{o.name}}
{{m.name}}

猜你喜欢

转载自my.oschina.net/u/4087916/blog/5542070