億級月活全民K歌feed業務在騰訊雲cmongo中的應用及優化實踐

語言: CN / TW / HK

業務背景及業務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在騰訊內部及外部的典型應用場景、踩坑案例、效能優化、核心模組化分析。

「其他文章」