高性能億級錄制列表查詢系統設計實踐
作者 | jaskeylin
一、背景
在騰訊會議320的APP改版中,我們需要構建一個一級TAB,在其中放置“我的錄制”、“最近瀏覽”+“全部文件”的三大列表查詢頁。以下是騰訊會議錄制面板的界面(設計稿)
我的錄制就是作者本人所生產的錄制文件,而所謂“最近瀏覽”很好理解,就是過去觀看過的錄制的足跡留痕。而所謂全部文件則相對比較復雜,可以認為是“我的錄制”+“最近瀏覽”+“授權給我的錄制”三個集合的并集。
我的錄制就是作者本人所生產的錄制文件,而所謂“最近瀏覽”很好理解,就是過去觀看過的錄制的足跡留痕。而所謂全部文件則相對比較復雜,可以認為是“我的錄制”+“最近瀏覽”+“授權給我的錄制”三個集合的并集。
雖然三個TAB的樣式幾乎長一模一樣,但是數據集是完全不同的,數據源可能也不一樣,接口自然也是需要單獨設計的。但無論哪個接口,三者均面臨著同樣的3個挑戰:
- 調用量大。作為一個4億用戶量的APP,一個一級入口的流量足以讓所有后臺設計者起敬畏之心。
- 數據量大。騰訊會議的錄制的數據庫的存量數據巨大。未來還將持續保持高速的增長,存儲的壓力、寫入/查詢的壓力很大。
- 耗時要求高。作為一級TAB的入口,產品對于其中的體驗要求極高,秒開是必須的,這意味著一次接口調用查詢一頁的耗時在高峰壓力下也要在百毫秒級別內。
面對這些挑戰,下面介紹騰訊會議的后臺系統是如何應對的。
二、分頁列表類接口設計挑戰
在講具體設計之前,無論是“我的錄制”還是“最近瀏覽”,本質上都是一個列表類功能。這類功能隨處可見,但是要把這個功能做到高并發、高可用、低延遲,其實并不是一個簡單的任務。我們以簡化版的“我的錄制”為例去看這里可能有什么挑戰。
一個列表的每一行記錄實際上元素非常的多(上圖已標注出來)。我們姑且先假設這里的cover,title,duration,meet_code,auth_value,create_time,size是存儲在同一張表中(實際上不是)來看看最簡單的一個列表系統,在數據量大、并發量大的時候有哪些挑戰。
1. 深分頁問題
從功能的角度來看,“我的錄制”的實現其實就是一條SQL的事情:
select * from t_records where uid = '{my_uid}' limit X, 30;
以上SQL表示查詢我的錄制列表的內容,每頁30條。隨著用戶翻頁的進行,X會逐漸增大。
如果這是一個幾萬幾十萬的數據表,這樣實現是完全沒有問題的,實現簡單,維護容易又能實現業務需求。
(1) 索引的工作原理
但是在騰訊會議的錄制場景下,非常很多表的數據量都是超級大表,而且一張表的字段有30+個。只考慮第一頁的情況下,需要在這樣的的大表中搜出來符合數據的用戶就是一件挺消耗性能的事情。即:select * from t_records where uid = '{my_uid}' limit 30;
第一步:在命中索引uid的情況下,先找到uid={my_uid}的索引葉子節點,找到對應表的主鍵id后,回表到主鍵索引中再找到對應id的葉子節點,讀出來足夠一頁的數據,并且把所有字段的內容回傳給業務。此過程大約如以下圖所示(圖片來源于網絡,以user_name作為索引,但原理是一樣的):
(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. 深分頁問題的解決思路
要解決深分頁的問題,其中一個思路是減少回表的損耗。網絡上有不少的分享了,總體歸結起來就是“延遲join”,和游標法。
(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) 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條數據。
要解決這個問題也有一些方法,筆者嘗試過的有:
- 主鍵字段設計上保證和排序字段的單調性一致。怎么說呢?例如我保證create_time越大的,id一定越大(例如使用雪花算法來計算出ID的值)。那么這樣就依舊可以使用ID字段作為游標來改寫SQL了
- 把<(順排就是>)改成<=/>=,這樣以后,數據就不會丟了,但是可能會重復。然后讓客戶端做去重。這樣做其實還有一個隱患,就是如果相同create_time的數據真的太多了,已經超過了一頁。那么可能永遠都翻不了頁了。
(3) 列表接口緩存設計挑戰
解決完深分頁的問題,不意味著我們的列表就能經得起高并發的沖擊,它最多意味著不會隨著翻頁的進行而性能斷崖式下降。但是,一旦請求量很大的話,很可能第一頁的請求不一定扛得住。為了提升整個列表的性能,肯定要做一定的緩存設計。下面來介紹一下一個最常見、最簡單的緩存手段。
方案一:列表結果緩存
緩存最簡單的就是緩存首頁/前幾頁的結果,因為大部分情況下列表80%產品都是只使用第一頁或者少數的前幾頁。以錄制列表為例,假設我們緩存第一頁的錄制結果,那么可以用List去緩存。如下圖所示:
首頁結果緩存的缺點:
- 一致性維護的困難。例如新增、刪除視頻的時候,固然需要維護這個List的一致性。你能想到可能是新增、刪除的時候,直接對list進行遍歷,找到對應的視頻進行新增或者刪除操作即可。但實際上在讀、寫并發場景下,動態維護緩存是很容易導致不一致的。如果為了更好的一致性考慮,可以考慮有變更的時候便刪除掉整個list緩存。
- 過于頻繁的維護緩存。無論走修改策略還是刪除策略,其維護的時機就是緩存的內容發生變動的時候。緩存整個結果意味著結果變動的內容可能性非常大。例如錄制的狀態是經常發生變更的:新建->錄制中、轉碼中、轉碼完成、完成等等。錄制的標題也是可能發生變動的,錄制的打擊狀態也是隨時可能變的,權限也是可能被管理員修改的。這些單一錄制的任意字段都可能需要對整個list緩存進行維護(修改/刪除),如果采取的是刪除策略,那么頻繁的維護動作會導致緩存經常失效而性能提升有限。如果采取更新策略則又維護困難且有一致性的問題。
- 緩存擴散維護的困難。這個在“我的錄制”里不存在這樣的問題,但是假設我們把“我的錄制”的功能范圍擴充為:屬于我的錄制以及我看過的錄制。這種情況下用首頁結果緩存就會有巨大挑戰。因為一個視頻X可能被N個人看到。但是每個人都有自己的首頁緩存(一個人一條list的redis結構),當X的某個字段(例如標題)發生變動的時候,我們需要找到這N個人的list結構。你可能會說,可以采取keys的操作找出來這些list去維護即可。但是keys操作是O(N)時間復雜度的操作,性能極差。哪怕我們采取scan去替換keys,在N極大的情況下,這里的損耗也是非常巨大的。
方案二:ID查詢+元素緩存
另外一個可行的方案是先查詢出這一頁的ID數據,然后再針對ID去查詢對應頁面所需要的其他詳情數據。如下圖所示:
這樣的好處是緩存設計可以不針對某個用戶的頁面結果去緩存,而是把元素信息緩存起來,這個方案有3個好處:
- 查詢數據庫只查詢ID的話,可以走聚簇索引,少一次回表。而且select 的字段數據也變少,查詢因為搜索的字段變少了,本身查詢的性能也會提升(網絡傳輸的數據變少了)。
- 緩存的維護很簡單。因為緩存的變化是單一數據結構的變化而不是一個集合的變化,維護起來會輕便很多。
- 沒有緩存擴散的問題。假設一個錄制被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 all
select * 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方程式跑車的區別。功能都是能跑,但是面臨的挑戰完全不一樣。這也是優秀后臺研發工程師的價值所在——同樣一模一樣的功能,要以一個高性能、高可用的標準去實現它,這兩者無論從設計還是實現上根本就不是同一個東西。
介紹完通用的方案和挑戰后,以下介紹騰訊會議的錄制列表是怎么設計的。
三、騰訊會議“我的錄制”列表優化實踐
1. 錄制列表的挑戰
“我的錄制”列表本來是騰訊會議一直存在的功能。功能背后的接口在現網也穩定運行了非常久的時間。雖然數據庫的數據量非常大,但因為這個接口的業務功能并不復雜(就只是查詢屬于自己的錄制文件),而且只開放給了企業的管理員,調用量很低,所以從實現上后臺老的實現就是直接使用數據庫的查詢來完成的。
但是隨著這次的錄制面板的在APP一級入口開放,會給這里接口性能提出更多地挑戰要求。由于錄制列表的數據目前存在于一個龐大的單表MySQL中,底層的壓力是非常巨大的。在我們第一輪的壓測中,在270qps的場景下,成功率僅有94.71%。
為了應對這個挑戰,在業務層,我們需要頂住流量的沖擊,我們的選擇是通過緩存保護好后面的數據接口。
2. 錄制列表的緩存設計
實際上,我的錄制列表是一個非常經典的業務場景:業務簡單(僅查自己的數據)但是要求的并發高,典型的場景如視頻網站中的我的視頻、微博中的我的微博、收件箱里我的消息等等。常用的方案前文已經介紹過了。以下介紹在騰訊會議的實踐方案。
(1) “我的錄制”的2層緩存設計
實際上針對這類型的查詢,如果最簡單粗暴的優化,是把數據全量緩存到Redis中。直接把Redis當存儲使用。這樣性能絕對扛扛的。(目前騰訊會議主面板的列表的存儲設計就是沒有DB只存Redis的)。但千萬級別的數據庫數據使用Redis重新緩存一輪需要非常高的成本(RMB成本),而且也會有Big Key的大集合問題(每個用戶的錄制視頻是持續性單調遞增,造成list中的數據太大)。考慮到用戶的查詢行為絕大部分的場景會集中于首頁,我們可以緩存首頁的數據來讓絕大部分的數據能命中緩存從而保護到后臺。
最簡單的思路當然是直接緩存某個用戶的首頁的結果(即前文的方案一),但是這個設計有以下幾個問題:
- 首頁返回的數據包是比較大的(接口40多個字段),這樣會導致每個用戶需要緩存的數據體積較大,所需要的緩存成本較多。
- 用戶的數據發生變動的時候,會驚擾不必要的緩存數據。例如一個用戶的首頁數據是id=1到id=30的數據,假設我們緩存了1-30這些數據的詳細內容,這時候當用戶新增了一個id=31的視頻的時候,或者id=5的這個視頻的標題發生變更的時候,我需要整體失效掉id=1-30這個緩存的結果。但實際上每次的變動其實只是一個數據,但是我們卻被迫驚擾了30個數據。這也是方案一最大的缺點。
最后我們采取了實現最為困難的方案三,但是列表的緩存我們簡化了只存第一頁的數據。所以緩存的首頁數據是帶緩存,也就是說用一個list緩存了id=1到id=30的id。然后需要查詢這些錄制詳情的時候,我們再走對應的批量接口獲取對應的詳情。最終形成一個二級的緩存結構。第一級是ID索引,第二級是錄制詳情緩存。
這樣當某個視頻的數據發生變更的時候,我只需要失效掉這個list的緩存,而每個背后詳情的緩存是不會失效的。
(2) 性能優化后的效果
在這樣的緩存設計下,我的錄制接口能經收住了700+qps的壓力:
同時平均耗時也從原來的308ms降低到了70ms,提升了4倍的性能!
“我的錄制”列表后續優化方向
目前我們實現的一級索引緩存(id緩存),是用list維護的。一旦有新增錄制、修改錄制,為了緩存的一致性維護方便,目前的手段都是直接清空緩存,等下次的查詢的時候再重新裝載。這樣的好處是肯定不會出現讀寫一致性的并發導致數據不一致的問題。后續如果有更高的性能要求,我們可以考慮新增緩存直接往list 中 push 數據,即有動態刪除數據的策略改成動態更新的策略。
四、“全部文件”架構實現
1. 多數據源查詢的挑戰
(1) 數據來源復雜,查找功能開發難度大
錄制全部文件的功能是包含多種數據來源的。如下圖所示,是筆者的一個面板數據,雖然都是錄制,但是其來源于非常多的可能,可能是自己創建的,也可能是自己瀏覽過的,也可能是自己申請查看的,也可能是別人邀請我看的。以下是測試環境的一張截圖,數據來源比較全,讀者可以從中看到其數據源是比較復雜的。
所以這肯定是一個多數據源的查詢場景。屬于前面我們提到的“混合數據源列表”的一個典型場景。要完成一個完整的全部文件需求,按照正常思路去開發,需要聚合查詢以下幾種數據源:
- 用戶本身的錄制數據。例如創建了一個錄制,就需要出現在面板中。本數據目前存儲于媒體應用組的云錄制系統后臺。
- 錄制的授權數據。例如一個錄制假設是參會者可見,那么我參加了這個會議也要出現在我們的面板中,例如一個錄制被指定給我可見,我也要馬上能看到這份數據。這里的數據存儲星環后臺二組的權限系統。
- 用戶的瀏覽記錄。如果一個視頻被我瀏覽過,就應該馬上能出現在列表中。哪怕這個視頻后續被刪除/移除了我的權限(后續權限被剝奪,封面圖、標題會降級顯示)。這是瀏覽記錄系統維護的獨立數據庫,由星環后臺一組維護。
- 用戶操作的刪除記錄。出現過的錄制,只要一個用戶不想看到,就可以移除,可以認為是一個黑名單工作。這部分工作是全新功能,開發這個需求前沒有這部分數據。
試想一下,如果要完成一個 my-all-records的接口開發,至少你需要做一下的工作:搜索基于uid=自己,搜索4個數據源的數據。其中1 2 3 數據求并集,最后再對數據4求差集。如下圖所示:
但這樣做雖然簡單,但是有幾個問題是極難克服的:
- 性能查詢負擔,當一個數據庫的數據量都是千萬-億級別的,本身查詢的耗時客觀擺在這里。做了重度工作之后還需要做各種集合運算,成本很高。最后出來的效果就是接口的耗時很高,反映到錄制面板體驗上就是用戶需要等待較高的時間才能看到數據,這對于用戶體驗追求極致的團隊而言是無法接受的。
- 穩定性挑戰。每次查詢海量數據數據源本身就是一個“高危動作”,如果一個海量數據庫查詢成功率是99%,那么四個數據庫都成功的概率就會下降到96%。
- 成本。為了實現困難且耗時高的查詢,我可以選擇購買足夠高配置的數據庫、優化數據庫的部分配置參數去抗住查詢數據的壓力,提升查詢成功率。同時在服務器中,通過代碼的一些優化,并發多協程地去并行化四個查詢的動作。最后部署足夠多的機器,應該也能使得整體的穩定性提升,但是這樣需要非常高的設備成本,在“降本增效”的大背景下是無法承受的。這是一種用戰術勤奮去掩蓋戰略勤奮的做法。
- 分頁挑戰。如果說通過內存聚合還能勉強做到功能的可用。但是引入分頁后這個問題變得幾乎無解,因為在一個分布式系統中,要聚合第N頁的數據需要合并所有系統的前N頁數據才能計算得出,注意是計算前N頁不是第N頁,相當于做一個多路歸并排序!也就是翻頁越深,查找量和計算量越大。而且我們這個場景更復雜,分頁后還需要剔除一部分刪除記錄,其挑戰如下圖所示:
(2) 查詢性能的挑戰
由于騰訊會議具有海量的C端請求,作為一級TAB的錄制面板,當他全量的開放之后講具有很大的查詢量。這對查詢系統的QPS提出了很大的要求。根據現在峰值的會議列表的QPS來折算,最后目標定在了錄制面板列表接口需要承接700qps的查詢的目標。按照一頁30個錄制數據返回來看,這個目標需要一秒返回21000個錄制的數據,其并發挑戰是不小的。同時錄制面板作為一個核心功能,我們希望能達到面板的秒開,那么對于接口耗時也同樣有著要求。我們認為,一個頁面要能秒開,接口耗時最多只能到500毫秒。
2. 全部文件的基本架構設計
為了應對以上兩個挑戰,我們選擇了計算機領域中典型的以空間換時間的思路。我們需要一個獨立的數據源,能提前計算好用戶所需的文件,然后搜索的時候只搜索該數據源就能得到所篩選的已排序的列表數據。設計圖如下:
(1) 存儲選型的述求
為此,我們需要評估這個數據庫的量級以便選擇合適的數據庫。(部分數據已模糊化處理)
錄制數據庫存量數據是約x000w;授權數據庫存量數據月x000w;瀏覽數據雖然一開始量級不大,但是隨著瀏覽留痕的上線將以極快的速度增長;刪除記錄表功能是新做的未來預計數據量也很少,估計在十萬級別。故合并數據庫的行數將達到一個很大的級別(x億)。每日新增的錄制數據約xw。按照一條數據被x人瀏覽/授權來初步計算,每天新增面板數據量將在x萬的數據。一年的增量x億附近,也就是說5年內,在業務體量沒有上升的前提下,數據量就將達到x億級別的量級。
對于數據庫要求就是三點:
- 橫向擴展能力好,能存儲x億級別的數據
- 在x億級別的數據量下能保持寫入、查詢的穩定
- 查詢性能高,寫入性能較好
- 成本低
(2) 存儲對比一覽
在此,我們考慮過幾個數據庫,考慮了存量量級、查詢性能、寫入性能、成本等因素下,大致對比因素如下表所示:
最終從業務適配、擴展性和成本等多種維度的考慮,我們選擇了MongoDB作為最終的選型數據庫。
(3) 數據擴展性設計
這里我們不討論具體的表設計、索引設計等的細節。撇開這些非常業務的設計外,在整體架構的擴展性、穩定性上,也有很多的挑戰點。下面拋出來其中的比較共性的3點,以便讀者參考。
① 如何讓數據存儲是平衡的,也就是說盡可能能讓數據均勻的擴散,而不是集中在極少數的分區,即數據的分片要具有區分度。如果某個分片鍵值的數據特別多,導致數據聚集,就會導致該chunk塊性能下降,即避免jumbo chunk。這是非常容易導致的。假設我們用用戶的UID作為分區鍵,那一旦用戶的錄制如果非常多,它的數據多到足以占據分區一半以上的量,那么這個用戶數據所在的分區就一定會有數據傾斜。這是架構師設計數據分片的時候特別需要避免的。
均勻分配的分區數據
部分分區存儲了過多的數據,即數據傾斜。
② 數據避免單調性。即如何讓寫入數據的性能能盡可能橫向擴展。因為每天新增的數據量極大,這意味著除了查詢性能,寫入性能的要求也很高,如果寫入一直是針對某個分片節點進行寫入,這是有寫入單點瓶頸的,也就是所謂的數據的增長要規避單調性。
③ 如何讓熱點查詢命中分片鍵。這個很好理解,如果不命中分片鍵,再好的存儲也無法提供足夠的查詢性能。
解決思路:
最直觀的解法肯定是基于UID做數據分區的,因為從數據查詢的角度看,肯定是需要基于用戶uid查詢的,這意味著核心的查詢肯定能命中uid這個分片鍵。其次uid分片也具有不錯的區分度,大部分情況下數據是比較均衡的。其次uid背后的數據并不是單調遞增的,因為全局范圍內,所有的用戶都在產生數據,也就是說寫入操作不會只在某個chunk。
但是uid分片有一個問題就是數據傾斜問題,例如有部分的用戶會特別活躍,導致某些用戶的留痕記錄會特別多。假設我們稱這些用戶叫大V用戶(類似微博的大V很多粉絲,他的粉絲數據也很容易造成傾斜)。如果使用uid分片,那么大V用戶的數據所處的chunk就可能是jumbo chunk。
為了解決這個問題,我們采取了uid+文件id作為聯合分片鍵,這樣可以實現大部分小用戶的數據只需要集中在同一個chunk種,而數據量過大的用戶則會劈開到多個chunk里,從而避免熱點的問題。最后三個挑戰均能解決。
3.數據一致性的挑戰
使用空間換時間的方法,最大的問題就是一致性。
一致性來源于兩個地方:
- 數據的數量是否是一致的。這個很好理解,例如新增了一個錄制,全部文件的數據庫需要有這個錄制;剛剛授權了一個人,全部文件的數據庫也需要有這個錄制。同樣道理,刪除錄制后也需要在全部文件數據庫中刪除此記錄。
- 數據字段是否是一致的。這個也好理解,因為數據本身是一直在發生變化的,例如錄制的狀態、權限、標題、封面乃至安全審批狀態,瀏覽/授權這個視頻的時間都會發生變化。
(1) 數據量一致性的解決方案
要解決一致性的問題,除了分布式事務這種很重的解決手段外,我們選擇了可靠消息+離線對賬混合處理的設計去解決。
① 消息可靠性消費
雖然我們有多個數據庫的數據源需要處理,但是好消息是一個錄制的生產和刪除從路徑上比較收斂的。所以我們可以在錄制的開始、錄制的刪除、權限點變更的時候,通過監聽外部系統的Kafka事件來做數據的同步變更。然后通過Kafka 消息的at-least-once特性來保證消費的最終成功。對于消費多次都不成功的消息,依賴消息做可靠消費最難解決的問題有兩點:
- 消息失敗導致消息丟失:雖然Kafka有at-least-once機制,如果一個消息一直消費失敗,是會阻塞該分區后面的消息消費的。所以我們實現了一套建議的死信通知的能力。在重試一定次數還是無法消費成功,我們會投遞這個消息到一個死信主題中,并且告警出來。一旦收到這個告警,就會人工介入去做數據的補償,保證數據最后能被人為干預修復成合適的狀態。
- 消息冪等:Kafka在非常多的環節都可能導致消息重復。例如消費者重啟、消費者擴容導致的Rebalance、Kafka的主備切換等等。為了保證數據不會被重復寫入,我們自研的一個消費的框架,通過封裝消費邏輯的流程,抽象出了一個冪等Key的概念由消費的代碼去實現,當發現相同的冪等Key已經存在于Redis則認為近期已經消費過,直接跳過。
② 對賬
雖然,消息消費一側我們有把握能保證消息最終能落庫,但是消息生產側和一些一些產品邏輯的遺漏,是會可能導致數據是有丟失的。對于一個擁有大型的數據的系統,我們必須對自己寫的代碼保持足夠的懷疑,無論是系統內部還是外部系統,沒有人能保證不寫出bug。
這時候就需要有人能發現這些數據的不一致,而不是等待用戶發現問題再投訴。為此,我們專門開發了一個復雜的對賬系統,用來把每個情況的數據進行增量、全量的對賬。這個過程中,我們也確實發現了不少問題,例如權限系統某些場景下會忘記發送Kafka事件、例如媒體中臺系統對于混合云的場景下會丟失webhook事件導致Kafka消息忘記發送,再例如產品的審批流設計中沒有很好的考慮錄制文件權限已經改變的場景,導致文件數據依然能被審批通過的場景等。
(2) 數據字段一致性的解決方案
如果說數據量的一致性還算好解決的話,數據字段的一致性則異常復雜。從理論上說,兩者的解決思路是一樣的,即:在數據發生變更的時候,可靠地消費這個變更消息,然后更新全部文件的數據記錄。
然而字段一致性的困難在于,這種變化點極多,例如標題會修改、安全的打擊會修改、權限值會修改、甚至還有云剪輯后時長都可能會修改。如果真的每個字段都要時刻保持一致,這里幾乎需要改動到原本云錄制系統、權限系統的方方面面,所有的更新時機都得對齊,而且還存在并發消費的問題導致字段準確性堪憂,極容易出現各種個月的bug。
“如無必要,勿增實體”,這是我們常說的奧卡姆剃刀定律。我們在其中得到了一些靈感。
如果說新增了全部文件表是因為沒辦法解決多數據庫的分頁問題,多數據庫的聚合計算問題,是用空間換時間的必要性。那么把詳情字段也冗余存儲就是增加了實體的設計。
我們最終的設計決定不冗余可能發生變更的字段。最終其設計類似于一個數據庫的索引樹一般。我們的全部文件數據庫存儲的僅僅是多個數據源的索引,數據發生增減,索引自然需要維護,但是數據表詳情字段的變更并不需要變更索引的。如下圖所示:
這樣設計后,整個業務系統的架構變得簡潔起來。我們僅需要花大力氣去維護索引的有效性和查詢性能即可。而數據的詳情例如標題、權限值、封面等都通過已有的批量接口獲取最真實的值即可,從而從根源上避免了維護十幾個字段的一致性問題。
最后,這套系統的設計如下:
(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串,緩存這一批的結果,下次再查詢的時候就能命中了。
但是這樣設計有兩個問題:
- 緩存利用效率很低。例如A用戶的首頁是id=1到id=30被我們緩存了,但是B用戶的首頁可能是id=1到id=29,這時候其實B用戶是無法利用之前緩存的視頻內容的,哪怕B用戶看到的數據實際上是A用戶的子集。這樣會很大的增加緩存的存儲量,從而提升系統的成本
- 緩存維護非常困難。例如id=3的視頻現在發生了標題的變更,這時候我們應該去通知A用戶和B用戶的緩存要刷新,否則他看到的就是變更前的臟數據,但是對于A用戶它的緩存key可能是1234567....30,你現在拿著id=3這個信息是沒辦法找到這個key去更新的,除非我們還要額外維護一套key和id的關系。
為此,我們的解決方案是,面對批量查詢接口,我們也會只緩存每一個具體的數據,一個數據一個key。然后把數據分為兩波,第一波是數據是否已經在緩存的,則直接查詢。第二波是緩存查詢不到的,則走批量接口。這樣一來,任何一個數據只要被其中一個用戶的查詢行為載入了緩存,它都能被其他用戶復用。同時,任何一個數據的更新都及時地刷新緩存。
當然因為我們需要調用的批量接口非常多,如果每個接口都需要做這樣的數據分拆邏輯,無疑是工作量巨大的。為此,我們星環后臺一組開發了一個FireCache組件,其中提供了類似的批量緩存能力,使得這一工作變得簡單。
性能優化后的效果
壓測數據:
- 在這樣大量緩存化的方案之后,我們大量的提升錄制面板獲取全部文件的接口性能,在壓測達到目標700qps時候,平均耗時僅96毫秒。
- 在并發提升了3倍壓力的背景下,平均耗時還提升了10倍!可見優化效果明顯。也能達到我們現網流量的壓力以及秒開的后臺性能要求。
現網數據:現網雖然沒有壓測的峰值數據,但是數據情況更為復雜,但是表現依舊非常理想。
五、小結
本文介紹了列表系統的性能設計遇到的一些挑戰點,以及在做緩存優化的時候可以采取的三個解決方案。最后,我們還花了很大的篇幅介紹了騰訊會議錄制系統的實踐思路,希望能幫助大家舉一反三,綜合自己業務的特點做出更好的設計。總的來說,為了應對錄制面板的高并發、海量數據的挑戰,騰訊會議的錄制系統總共采取了以下的幾個手段,最終達成了較好的效果。
- MongoDB的海量數據實踐設計
- 對賬系統
- 二級緩存設計
- 可靠消息的處理
- 緩存組件的開發