全民K歌作為騰訊音樂集團四大產(chǎn)品線之一,月活超過1.5億,并不斷推出新的音娛功能及新玩法,極大豐富了數(shù)億用戶的音樂娛樂活動。
MongoDB天然支持高可用、分布式、高性能、高壓縮、schema free、完善的客戶端訪問均衡策略等功能。作為騰訊音樂集體核心部門,K歌Feed等業(yè)務(wù)采用騰訊云MongoDB作為主存儲服務(wù),極大的方便了K歌業(yè)務(wù)的快速迭代開發(fā)。
本文主要分享K歌技術(shù)演進過程中的一些踩坑過程、方案設(shè)計、性能優(yōu)化等,主要包括以下技術(shù)點:
·全民K歌業(yè)務(wù)特性
·Feed業(yè)務(wù)讀寫選型
·Feed數(shù)據(jù)吐出控制策略優(yōu)化
·Feed核心表設(shè)計
·K歌業(yè)務(wù)層面踩坑及優(yōu)化過程
·K歌業(yè)務(wù)MongoDB使用踩坑及優(yōu)化
業(yè)務(wù)層面優(yōu)化過程
1.騰訊音樂全民K歌業(yè)務(wù)特性
每一個社交產(chǎn)品,都離不開Feed流設(shè)計,在全民K歌的場景,需要解決以下主要問題:
·我們有一些千w粉絲,百萬粉絲的用戶,存在關(guān)系鏈擴散的性能挑戰(zhàn)
·Feed業(yè)務(wù)種類繁多,有復(fù)雜的業(yè)務(wù)策略來控制保證重要的Feed曝光
對于Feed流的數(shù)據(jù)吐出,有種類繁多的控制策略,通過這些不同的控制策略來實現(xiàn)不通功能:
·大v曝光頻控,避免刷流量的行為
·好友共同發(fā)布了一些互動玩法的Feed,進行合并,避免刷屏
·支持不同分類Feed的檢索
·安全問題需要過濾掉的用戶Feed
·推薦實時插流/混排
·低質(zhì)量的Feed,系統(tǒng)自動發(fā)類型的Feed做曝光頻控
2.讀寫選型
Feed主流實現(xiàn)模型主要分為3種,這些模型在業(yè)界都有大型產(chǎn)品在用:
·讀擴散(QQ空間)
·寫擴散(微信朋友圈)
·大v讀擴散+普通用戶寫擴散(新浪微博)
沒有最好的模式,只有適合的架構(gòu),主要是權(quán)衡自己的業(yè)務(wù)模型,讀寫比,以及歷史包袱和實現(xiàn)成本。
K歌使用的是讀擴散模型,使用讀擴散模型的考慮如下:
·存在不少千萬/百萬粉絲的大v,寫擴散嚴(yán)重,推送延遲高,同時存儲成本會高
·低活用戶,流失用戶推送浪費計算資源和存儲資源
·安全合規(guī)相關(guān)的審核會引發(fā)大量寫擴散
·寫擴散qps=3 x讀擴散qps
·K歌關(guān)系鏈導(dǎo)入的歷史原因,早起寫擴散成本高,同時后期改成讀寫擴散混合的模式改造成本大
但是讀擴散模式存在以下比較明顯的缺點:
·翻頁把時間線前面的所有數(shù)據(jù)拉出來,性能開銷越來越大,性能越來越差
·關(guān)注+好友數(shù)量可達(dá)萬級別,實現(xiàn)全局的過濾,插流,合并,頻控策略復(fù)雜,性能不足
3.讀擴散優(yōu)化
讀擴散模型的存儲數(shù)據(jù)主要分為3大塊:
·關(guān)系鏈
·Feed數(shù)據(jù)
·最新更新時間戳
3.1.優(yōu)化背景
未優(yōu)化前的關(guān)系鏈讀擴散模型,每次拉取Feed數(shù)據(jù)的時候,都需要通過關(guān)系鏈,時間戳,以及Feed索引數(shù)據(jù)來讀擴散構(gòu)建候選結(jié)果集。最后根據(jù)具體的Feedid拉取Feed詳情來構(gòu)建結(jié)果進行返回。
對于首屏,如果一頁為10條,通過關(guān)系鏈+最新時間戳過濾出最新的20個uid(預(yù)拉多一些避免各種業(yè)務(wù)過濾合并策略把數(shù)據(jù)過濾完了),然后拉取每個uid最新的60條Feed的簡單的索引信息來構(gòu)建候選集合,通過各種業(yè)務(wù)合并過濾策略來構(gòu)建最多10條最新Feedid,再拉取Feed詳細(xì)信息構(gòu)建響應(yīng)結(jié)果。
翻頁的時候把上一次返回的數(shù)據(jù)的最小時間戳basetime帶過來,然后需要把basetime之前的有發(fā)布Feed的uid以及basetime之后有發(fā)布的最近20個uid過濾出來,重復(fù)上面構(gòu)建候選集合的過程來輸出這一頁的數(shù)據(jù)。這種實現(xiàn)邏輯翻頁會越來越慢,延遲不穩(wěn)定。
3.2.優(yōu)化過程
針對以上問題,所以我們在讀擴散模型上進行了一些優(yōu)化,優(yōu)化架構(gòu)圖如下:
我們通過讀擴散結(jié)果的Cache模式,解決翻頁越來越慢,復(fù)雜的全局過濾邏輯。
Cahce優(yōu)勢
·靈活過濾,實現(xiàn)復(fù)雜的過濾合并邏輯
·翻頁讀Cache性能高,首頁使用Cache避免重復(fù)計算
時間線Cache需要解決的問題?弊端?
·關(guān)系鏈變更Cache有延遲
·臟Feed導(dǎo)致Cache體積減小
此外,我們把Cache主要分為全量生成過程,增量更新過程,以及修補邏輯三部分來解決這些問題:
·全量是在首次拉取,和24小時定時更新
·增量則是在首頁刷新,無最新數(shù)據(jù)則復(fù)用Cache
·通過緩存關(guān)系鏈,如果關(guān)系鏈變更,活臟Feed太多過濾后導(dǎo)致的Cache體積過小,則觸發(fā)修補邏輯
最終,通過這些策略,讓我們的Feed流系統(tǒng)也具備了寫擴散的一些優(yōu)勢,主要優(yōu)勢如下:
·減少重復(fù)計算
·有全局的Feed視圖,方便實現(xiàn)全局策略
4.主要表設(shè)計
4.1.Feed表設(shè)計
Feed這里的設(shè)計建立了2個表:
·一個是Feed詳情表
該表使用用戶userid做片健,F(xiàn)eedid做唯一健,表核心字段如下:
·Feed Cache表
該表使用uid做片健和唯一健,并且做ttl,表核心字段如下:
FeedCache是一個kv存儲的文檔,k是uid,value是CacheFeedData jce序列化后的結(jié)果。為了避免TTL刪除數(shù)據(jù)消耗線上業(yè)務(wù)性能:可以在寫入數(shù)據(jù)時指定過期時間。過期時間直接配置成業(yè)務(wù)低峰期時段。
4.2.賬號關(guān)系表設(shè)計
關(guān)注關(guān)系鏈常規(guī)涉及兩個維度的數(shù)據(jù):
·一個關(guān)注,一個粉絲(一個關(guān)注動作會產(chǎn)生兩個維度數(shù)據(jù))。
關(guān)注列表
關(guān)注一般不是很多,最多一般只有幾千,經(jīng)常會被全部拉出來,這個可以存儲為kv的方式(高性能可以考慮內(nèi)存型數(shù)據(jù)庫或cache)。
關(guān)注是用Redis存儲的,一個key對應(yīng)的value是上面RightCache這個結(jié)構(gòu)的jce序列化后的結(jié)果。
·粉絲
粉絲是一個長列表(幾百萬甚至上千萬),一般會以列表展示,存儲與MongoDB中,以用戶id為片健,每個粉絲作為一個單獨的doc,使用內(nèi)存型的存儲內(nèi)存碎片的損耗比較高,內(nèi)存成本大。關(guān)注和粉絲數(shù)據(jù)可以使用消息隊列來實現(xiàn)最終一致性。
粉絲數(shù)據(jù)按照MongoDB文檔存儲,主要包含以下字段:opuid,fuid,realtiontype,time。
MongoDb使用層面優(yōu)化
該業(yè)務(wù)MongoDB部署架構(gòu)圖如下:
K歌業(yè)務(wù)MongoDB架構(gòu)圖:客戶端通過騰訊云VIP轉(zhuǎn)發(fā)到代理mongos層,代理mongos接受到請求后,從config server(存儲路由信息,架構(gòu)圖中未體現(xiàn))獲取路由信息,然后根據(jù)這條路由信息獲取轉(zhuǎn)發(fā)規(guī)則,最終轉(zhuǎn)發(fā)該請求到對應(yīng)的存儲層分片。
在業(yè)務(wù)上線開發(fā)過程中,發(fā)現(xiàn)MongoDB使用的一些不合理,通過對這些不合理的使用方式優(yōu)化,提升了訪問MongoDB的性能,最終提升了整個Feed流系統(tǒng)用戶體驗。
K歌業(yè)務(wù)MongoDB訪問主要優(yōu)化點如下:
1.最優(yōu)片建及分片方式選擇
前面提到信息流業(yè)務(wù)Feed詳情表、粉絲列表存儲在MongoDB中,兩個表都采用用戶userId來做分片片建,分片方式采用hashed分片,并且提前進行預(yù)分片:
sh.shardCollection("xx.follower",{userId:"hashed"},false,{numInitialChunks:8192*分片數(shù)})
sh.shardCollection("xx.FeedInfo",{userId:"hashed"},false,{numInitialChunks:8192*分片數(shù)})
兩個表都userId做片建,并且采用hashed分片方式,同時提前對表做預(yù)分片操作,主要基于以下方面考慮:
·數(shù)據(jù)寫
通過提前預(yù)分片并且采用hashed分片方式,可以保證數(shù)據(jù)均衡的寫入到不同分片,避免數(shù)據(jù)不均引起的moveChunk操作,充分利用了每個分片的存儲能力,實現(xiàn)寫入性能的最大化。
·數(shù)據(jù)讀
通過userId查詢某用戶的Feed詳情和通過userId查詢該用戶的粉絲列表信息,由于采用hashed分片方式,同一個Id值對應(yīng)的hash計算值會落在同一個shard分片,這樣可以保證整個查詢的效率最高。
說明:由于查詢都是指定id類型查詢,因此可以保證從同一個shard讀取數(shù)據(jù),實現(xiàn)了讀取性能的最大化。但是,如果查詢是例如userId類的范圍查詢,例如db.FeedInfo.find({userId:{$gt:1000,$lt:2000}}),這種場景就不適合用hashed分片方式,因為滿足{$gt:1000}條件的數(shù)據(jù)可能很多條,通過hash計算后,這些數(shù)據(jù)會散列到多個分片,這種場景范圍分片會更好,一個范圍內(nèi)的數(shù)據(jù)可能落到同一個分片。所以,分片集群片建選擇、分片方式對整個集群讀寫性能起著非常重要的核心作用,需要根據(jù)業(yè)務(wù)的實際情況進行選擇。
K歌feed業(yè)務(wù)都是根據(jù)feedId、userId進行查詢,不存在范圍查詢,因此選用hash預(yù)分片方式進行片建設(shè)置,這樣可以最大化提升查詢、寫入功能。
2.查詢不帶片建如何優(yōu)化
上一節(jié)提到,查詢?nèi)绻麕掀?,可以保證數(shù)據(jù)落在同一個shard,這樣可以實現(xiàn)讀性能的最大化。但是,實際業(yè)務(wù)場景中,一個業(yè)務(wù)訪問同一個表,有些請求可以帶上片建字段,有些查詢沒有片建,這部分不帶片建的查詢需要廣播到多個shard,然后mongos聚合后返回客戶端,這類不帶片建的查詢效率相比從同一個shard獲取數(shù)據(jù)性能會差很多。
如果集群分片數(shù)比較多,某個不帶片建的查詢SQL頻率很高,為了提升查詢性能,可以通過建立輔助索引表來規(guī)避解決該問題。以Feed詳情表為例,該表片建為用戶userId,如果用戶想看自己發(fā)表過的所有Feed,查詢條件只要帶上userId即可。
但是,如果需要FeedId獲取指定某條Feed則需要進行查詢的廣播操作,因為Feed詳情表片建為userId,這時候性能會受影響。不帶片建查詢不僅僅影響查詢性能,還有加重每個分片的系統(tǒng)負(fù)載,因此可以通過增加輔助索引表(假設(shè)表名:FeedId_userId_relationship)的方式來解決該問題。輔助表中每個doc文檔主要包含2個字段:
·FeedId字段
該字段和詳情表的FeedId一致,代表具體的一條Feed詳情。
·UserId
該字段和詳情表userId一致,代表該FeedId對應(yīng)的這條Feed詳情信息由該user發(fā)起。
FeedId_userId_relationship輔助表采用FeedId做為片建,同樣采用前面提到的預(yù)分片功能,該表和Feed詳情表的隱射關(guān)系如下:
如上圖,通過某個FeedId查詢具體Feed,首先根據(jù)FeedId從輔助索引表中查找該FeedId對應(yīng)的userId,然后根據(jù)查詢到的userId+FeedId的組合獲取對應(yīng)的詳情信息。整個查詢過程需要查兩個表,查詢語句如下:
//根據(jù)feedId獲取對應(yīng)的userId db.FeedId_userId_relationship.find({“FeedId”:“375”},{userId:1})//假設(shè)返回的userId為”3567”//根據(jù)userId+FeedId的組合獲取具體的某條feed信息db.FeedInfo.find({“userId”:“3567”,“FeedId”:“375”})
如上,通過引入輔助索引表,最終解決跨分片廣播問題。引入輔助表會增加一定的存儲成本,同時會增加一次輔助查詢,一般只有在分片shard比較多,并且不帶片建的查詢比較頻繁的情況使用。
3.count慢操作優(yōu)化
前面提到,粉絲關(guān)系表存在MongoDB中,每條數(shù)據(jù)主要包含幾個字段,用戶的每個粉絲對應(yīng)一條MongoDB文檔數(shù)據(jù),對應(yīng)數(shù)據(jù)內(nèi)容如下
{"_id":ObjectId("6176647d2b18266890bb7c63"),"userid":“345”,"follow_userid":“3333”,"realtiontype":3,"follow_time":ISODate("2017-06-12T11:26:26Z")}
一個用戶的每個粉絲對應(yīng)一條數(shù)據(jù),如果需要查找某個用戶下面擁有多少個粉絲,則通過下面的查詢獲取(例如查找用戶id為”345”的用戶的粉絲總數(shù)):
db.fans.count({"userid":“345”}
該查詢對應(yīng)執(zhí)行計劃如下:
{
"executionSuccess" : true,
"nReturned" : 0,
"executionTimeMillis" : 0,
"totalKeysExamined" : 156783,
"totalDocsExamined" : 0,
"executionStages" : {
"stage" : "COUNT",
"nReturned" : 0,
......
"nSkipped" : 0,
"inputStage" : {
"stage" : "COUNT_SCAN",
......
}
},
"allPlansExecution" : [ ]
}
和其他關(guān)系型數(shù)據(jù)庫(例如mysql)類似,從上面的執(zhí)行計劃可以看出,對某個表按照某個條件求count,走最優(yōu)索引情況下,其快慢主要和滿足條件的數(shù)據(jù)量多少成正比關(guān)系。例如該用戶如果粉絲數(shù)量越多,則其掃描的keys(也就是索引表)會越多,因此其查詢也會越慢。
從上面的分析可以看出,如果某個用戶粉絲很多,則其count性能會很慢。因此,我們可以使用一個冪等性計算的計數(shù)來存儲粉絲總數(shù)和關(guān)注總數(shù),這個數(shù)據(jù)訪問量比較高,可以使用高性能的存儲,例如Redis的來存儲。冪等性的計算可以使用Redis的lua腳本來保證。
優(yōu)化辦法:粉絲數(shù)量是一個Redis的key,用lua腳本執(zhí)行(計數(shù)key incrby操作與opuid_touid_op做key的setnx expire)來完成冪等性計算。
4.寫大多數(shù)優(yōu)化
寫入數(shù)據(jù)可以根據(jù)業(yè)務(wù)的數(shù)據(jù)可靠性來選擇不同的writeConcern策略:
{w:0}:對客戶端的寫入不需要發(fā)送任何確認(rèn)。場景:性能要求高;不關(guān)注數(shù)據(jù)完整性
{w:1}:默認(rèn)的writeConcern,數(shù)據(jù)寫入到Primary就向客戶端發(fā)送確認(rèn)。場景:兼顧性能與一定層度得數(shù)據(jù)可靠性。
{w:“majority”}:數(shù)據(jù)寫入到副本集大多數(shù)成員后向客戶端發(fā)送確認(rèn)。場景:數(shù)據(jù)完整性要求比較高、避免數(shù)據(jù)回滾場景,該選項會降低寫入性能。
對于可靠性要求比較高的場景往往還會使用{j:true}選項來保證寫入時journal日志持久化之后才返回給客戶端確認(rèn)。數(shù)據(jù)可靠性高的場景會降低寫的性能,在K歌Feed業(yè)務(wù)使用初期的場景會發(fā)現(xiàn)寫大多數(shù)的場景都寫延遲不太穩(wěn)定,核心業(yè)務(wù)都出現(xiàn)了這種情況,從5ms到1s抖動。通過分析定位,我們發(fā)現(xiàn)是寫時候到鏈?zhǔn)綇?fù)制到策略導(dǎo)致的。
鏈?zhǔn)綇?fù)制的概念:假設(shè)節(jié)點A(primary)、B節(jié)點(secondary)、C節(jié)點(secondary),如果B節(jié)點從A節(jié)點同步數(shù)據(jù),C節(jié)點從B節(jié)點同步數(shù)據(jù),這樣A->B->C之間就形成了一個鏈?zhǔn)降耐浇Y(jié)構(gòu),如下圖所示:
MongoDB多節(jié)點副本集可以支持鏈?zhǔn)綇?fù)制,可以通過如下命令獲取當(dāng)前副本集是否支持鏈?zhǔn)綇?fù)制:
cmgo-xx:SECONDARY> rs.conf().settings.chainingAllowed
true
cmgo-xx:SECONDARY>
此外,可以通過查看副本集中每個節(jié)點的同步源來判斷當(dāng)前副本集節(jié)點中是否存在有鏈?zhǔn)綇?fù)制情況,如果同步源為secondary從節(jié)點,則說明副本集中存在鏈?zhǔn)綇?fù)制,具體查看如下副本集參數(shù):
cmgo-xx:SECONDARY> rs.status().syncSourceHost
xx.xx.xx.xx:7021
cmgo-xx:SECONDARY>
由于業(yè)務(wù)配置為寫多數(shù)派,鑒于性能考慮可以關(guān)閉鏈?zhǔn)綇?fù)制功能,MongoDB可以通過如下命令操作進行關(guān)閉:
cfg = rs.config()
cfg.settings.chainingAllowed = false
rs.reconfig(cfg)
鏈?zhǔn)綇?fù)制好處:可以大大減輕主節(jié)點同步oplog的壓力。
鏈?zhǔn)綇?fù)制不足:當(dāng)寫策略為majority時,寫請求的耗時變大。
當(dāng)業(yè)務(wù)采用“寫大多數(shù)”策略時,也相應(yīng)的關(guān)閉鏈?zhǔn)綇?fù)制;避免寫請求耗時變大。我們關(guān)閉了鏈?zhǔn)綇?fù)制后整體寫延遲文檔在10ms以內(nèi)。
5.海量qps業(yè)務(wù)抖動優(yōu)化
在一些核心集群,我們發(fā)現(xiàn)在高峰期偶爾會慢查詢變多,服務(wù)抖動,抖動的表象看起來是因為個別CPU飆升導(dǎo)致的,通過分析具體高CPU的線程,以及perf性能分析具體的函數(shù),我們發(fā)現(xiàn)主要是兩個問題:
高峰期連接數(shù)量陡漲,連接認(rèn)證開銷過大,導(dǎo)致的CPU飆升。
WT存儲引擎cache使用率及臟數(shù)據(jù)比例太高,MongoDB的用戶線程阻塞進行臟數(shù)據(jù)清理,最終業(yè)務(wù)側(cè)抖動。
為了優(yōu)化這兩個問題,我們通過優(yōu)化MongoDB的配置參數(shù)來解決:
·MongoDB連接池上下限一致,減少建立連接的開銷
·提前觸發(fā)內(nèi)存清理eviction_target=60,用戶線程參與內(nèi)存清理的觸發(fā)值提高到97%:eviction_trigger=97,增加更多的清理線程:evict.threads_max:20,從而減少高峰期慢查詢150k/min=>20k/min,服務(wù)穩(wěn)定性也的到了提升
優(yōu)化后效果如圖:
6.數(shù)據(jù)備份過程業(yè)務(wù)抖動優(yōu)化
騰訊云MongoDb默認(rèn)凌晨會定期對集群數(shù)據(jù)做全量備份和增量備份,并支持默認(rèn)7天內(nèi)的任意時間點回檔。但是,隨著集群數(shù)據(jù)量逐漸的增加,當(dāng)前該集群數(shù)據(jù)量已經(jīng)比較大,開始出現(xiàn)凌晨集群定期抖動,主要現(xiàn)象如下:
·訪問時延增加
·慢日志增加
·CPU使用率增加
通過分析,發(fā)現(xiàn)問題和數(shù)據(jù)備份時間點一致,由于物理備份和邏輯備份期間需要對整實例進行數(shù)據(jù)備份,系統(tǒng)資源負(fù)載增加,最終影響業(yè)務(wù)查詢服務(wù)。
優(yōu)化方式:數(shù)據(jù)備份期間隱藏節(jié)點,確保該節(jié)點對客戶端不可見。
作者:ctychen,ianxiong
全民K歌后臺開發(fā)一組/騰訊MongoDB團隊
騰訊云MongoDB:
騰訊云MongoDB當(dāng)前服務(wù)于游戲、電商、社交、教育、新聞資訊、金融、物聯(lián)網(wǎng)、軟件服務(wù)等多個行業(yè);MongoDB團隊(簡稱CMongo)致力于對開源MongoDB內(nèi)核進行深度研究及持續(xù)性優(yōu)化(如百萬庫表、物理備份、免密、審計等),為用戶提供高性能、低成本、高可用性的安全數(shù)據(jù)庫存儲服務(wù)。后續(xù)持續(xù)分享MongoDB在騰訊內(nèi)部及外部的典型應(yīng)用場景、踩坑案例、性能優(yōu)化、內(nèi)核模塊化分析。