6行代碼節省超千萬成本——記一次字段治理的“巧渡金沙江”
導讀:本文回顧了關于快手的核心數據對象“Photo”存儲系統的一次巧妙降本增效的故事。通過充足細致的前期調研分析,以極少的人力投入取得了相當可觀的收益。文中對有巨大UGC歷史數據存量的平臺型公司如何應對挑戰有一些思考和總結。
一、引言
Premature optimization is the root of all evil (or at least most of it) in programming. —— Donald Knuth
快手作為國民級短視頻平臺,歷史上短視頻的總量已達千億級,每日新增短視頻作品超過4000萬。本文的主角是快手短視頻在系統中的基礎數據結構——Photo對象。
為什么叫Photo而不是video?
快手的前身是2011年推出的“Gif快手”,當時的主要功能是將視頻在手機上方便高效地轉換為Gif動圖。由于Gif兼具視頻動效和圖片的輕量特性,適合在微博等平臺傳播,因此早期將Gif動圖命名為Photo并無不妥。隨著產品轉型為短視頻和直播社區,核心數據結構的名稱為沿用至今。
Photo對象是什么?
Photo對象是快手生態中短視頻的核心數據結構,包含除音視頻信息(通常在MB~GB級別)外的大部分屬性信息,如標題、碼率、封面、上傳時間、位置、商業化信息等。截至2023年初,Photo對象已包含200多個屬性,平均大小在3k~4k之間。本文的故事圍繞Photo對象的存儲優化展開。
為什么標題叫"巧渡金沙江"?
巧渡金沙江?和?強渡大渡河?是在1935年4、5月間發生在中央紅軍長征途中的兩場著名戰斗,前者僅用了7條小船經7天7夜實現了紅軍的戰略轉移意圖,后者經過激烈的浴血奮戰獲得勝利后成功打開了轉移的通道,兩者在長征史上具有同樣重要的戰略意義。本文對Photo對象的優化因其投入極小卻取得不小的收益,故而借用巧渡金沙江的典故。
?
二、治理優化的背景和收益
2.1 治理優化的項目背景
Photo對象存儲在關系數據庫中,在快手承載著有數以千計的上游服務調用,中間用緩存扛流量,緩存請求量QPS在億級別,并且緩存回源DB的請求也高達百萬次QPS。為了支撐如此龐大的數據庫請求,我們采取了數據庫分片加讀寫分離的多從庫策略,從而將單個數據庫分片從庫的請求壓力降低至每秒千次級別。然而,這種高可用性和擴展性的提升,是以數據存儲冗余度成倍增加為代價的。
在緩存與磁盤存儲空間成本相差懸殊的背景下,如何平衡成本與性能成為了選擇緩存空間規模的關鍵問題。快手Photo服務的緩存容量雖已達TB級別,但面對千億級的歷史冷數據存量,TB級的緩存資源仍然捉襟見肘,這一點集中體現在緩存命中率隨時間的持續下降趨勢上。在極高的請求量背景下,緩存命中率的每一個百分點下降都會轉化為回源服務對DB請求量的顯著增加。因此,我們追求的理想緩存命中率應盡可能接近100%。
下圖可以看出在22年時緩存平均命中率已經退化到了94%,這意味著著需要部署更多的回源服務和更高的DB請求量,而這一數值在2019年時曾維持在98%以上。
2.2 優化收益
本次photo優化治理取得了較為顯著的成效,具體體現以下三個方面:
1.存儲空間大幅縮減:成功將快手核心數據資產Photo的存量數據縮減約25%,累計節省數百TB,有效降低了存儲成本。
2.新增數據體積優化:優化后的新增Photo作品平均體積減少近1KB,結合每日數千萬的上傳量,每日新增DB空間減少數百GB。
3.緩存命中率提升:Photo緩存系統的命中率從95%提升至97%,在億級QPS的規模下,每提升一個百分點意味著年化成本節省達百萬級別,顯著提升了系統效率并降低了運營成本。
?
2.3 Photo架構簡圖
?
通過對DB單分庫的全量數據進行清洗,我們可觀察到存儲量的明顯拐點。存量數據清洗后,DB存儲總計下降了400T,清洗過程中還留下了許多“空洞”,隨后被新的增量數據優先填補,這引出存儲量曲線在一段時間內保持水平。此外,在增量優化邏輯上線后,新增Photo對象大小同樣減少了約25%。
三、Photo緩存命中率的意義
在快手這一大型內容平臺上,爆款作品往往會在極短時間內獲得海量的展現和播放,從而對熱點Photo的請求量極高。與此同時,對于數月甚至數年前的冷數據,其請求則具有較大的隨機性和不可預測性。
Photo緩存常年處于滿載狀態,這意味著每有一條新數據寫入緩存,就必須有一條舊數據被淘汰以騰出空間。熱點數據的高請求量決定了必須依賴緩存來應對,而冷數據的隨機訪問則導致緩存中持續進行數據的換入換出。下圖展示了歷史上某日Photo緩存(單個Memcached集群)的請求量級。從讀寫請求的角度來看,下圖綠色的讀緩存QPS穩定在約千萬級,而黃色的寫緩存QPS則保持在百萬級。這些寫請求主要源于讀緩存未命中后,服務回源DB并將數據回寫至緩存所產生的結果。這種讀寫請求的分布清晰地反映了緩存系統在應對熱點與冷數據訪問時的動態平衡。
當請求獲取Photo數據時,若命中緩存,則立即返回結果;若未命中,則會觸發一次DB讀請求并回寫緩存后再返回。在千萬QPS的緩存請求量級下,命中率每下降一個百分點,就意味著1%的緩存讀取請求會轉化為數十萬的數據庫讀取QPS和緩存寫入QPS。如果命中率下降4個百分點,數據庫將額外承受超百萬的讀取請求QPS,這也進一步解釋了為何數據庫需要采用分庫分表結合一主多從的設計架構。
此外,快手的回源服務采用獨立部署架構,緩存命中率每下降1個百分點,都會導致回源服務流量增加數十萬QPS。因此,緩存命中率提升2個百分點,大致可減少60萬的數據庫讀取QPS和回源服務QPS,相當于節省了3個數百TB級別的數據庫從庫以及數千個CPU核心資源。
由此我們可以得出結論:對于Photo這種承載巨量QPS和數據規模的緩存系統而言,命中率每提升1個百分點,就能為上下游系統帶來年化百萬元級別的成本節約收益。這一優化不僅顯著降低了數據庫和回源服務的壓力,也為整體系統的資源利用率帶來了大幅提升。
從下方兩張圖數據對比可以看出,本次治理確實顯著提升了緩存命中率,漲幅約為2個百分點。具體來看,上圖展示了治理期間緩存命中率變化趨勢。紅框內數據顯示,日均命中率隨時間呈現遞增趨勢,四周的時間上升達到了近1個百分點(圖例中的-1d代表前一天,-4w代表4周前的同一天)。作為對比,下圖呈現在未進行優化時的常規狀態下緩存命中率走勢,不難看出紅框內的數據隨時間呈現逐步下降的趨勢。
?
對照組數據(2024-8),紅框內命中率隨時間下降【從下往上看】
讀者可能會好奇,為什么Photo的緩存命中率會隨著時間的推移自然下降?
這是因為平臺每天新增的數千萬上傳作品會同步產生同樣數量的新增對象存入DB。隨著數據“與日俱增”,緩存的總容量卻保持不變,導致“緩存容量/DB總數據量”這一比值自然隨時間逐漸降低。那么,為什么在優化治理過程中命中率會有顯著提升呢?這是因為優化措施使每個Photo對象的體積縮小,相同容量的緩存能夠存儲的Photo數量隨之增加,從而提升了命中率。這一優化過程可以類比為“克服重力做功”,或者說系統外部的力量在對抗“系統的熵增”而做功。
值得注意的是,下圖展示2024年8月某日的平均命中率略高于治理期間的數據,這是因為2024年進行了一次緩存擴容,通過“硬拉升”的方式提升了命中率。事實上,隨著歷史數據的不斷積累,類似的緩存擴容在過去幾年中已經執行過多次。這也是快手這類擁有海量UGC內容增量的平臺所面臨的共同挑戰。從下方的趨勢圖可以看出,命中率提升的過程從22年12月初持續到23年2月,在過億QPS的訪問量下,平均命中率從95%穩步提升至97%,這一成果實屬不易。
四、Photo對象如何變小
4.1 Photo庫結構
Photo庫的表結構是一個典型的數據庫實體表,包含約20個字段,主要由作品ID、作者ID、時間戳以及若干狀態信息組成。其中,有一個JSON結構的擴展字段,存儲各業務線的業務屬性。這種設計的好處在于,當業務需要擴展時,無需頻繁變更庫表結構。然而,這種設計也并非沒有弊端。如果缺乏有效管控,隨著業務規模的擴大,擴展字段中的屬性數量會迅速膨脹。到2023年,Photo表的擴展字段中已經包含了超過200個屬性,這無疑增加了數據存儲和處理的復雜性。
?
統計時間 | 擴展字段內屬性數 |
19年5月 | 約60個 |
20年5月 | 約120個 |
21年5月 | 約160個 |
22年3月 | 約210個 |
23年5月 | 約230個 |
?
從上表可以看出,從2019年到2023年,擴展字段內的屬性數量呈現明顯的增長趨勢。但在2022年Q3之后,屬性數量的增速得到了顯著遏制,這主要得益于負責團隊的同學著手施行了較嚴格的字段管控,業務字段需業務自己維護,非必要不允許進Photo表。
?
4.2 治理思路
經過統計分析,我們發現200多個屬性中,TOP30的屬性占據了總空間的93%,而前6個屬性占據了總空間的一半以上。這些屬性多為復合結構,也即json嵌套子結構,且多數值為0、false、null等默認值。這一發現讓解決方案自然浮現——“把這些默認值拿掉可取得顯著收益”。
?
從下圖可以看出,除了videoId屬性外,其他屬性都被不必要地存儲在每個Photo對象中。無數個Photo對象疊加起來,這部分冗余存儲成本累積到了驚人的地步。
?
?
在JAVA生態下應對此類問題,只要在類中相應的字段上加寫一條@JsonInclude(Include.NON_DEFAULT)的注解語句,即可避免將這些冗余信息存儲到DB里了。隨后在占存儲大頭的6個復合結構上添加同樣的代碼,成功地將一個Photo元數據對象的尺寸縮減了25%。雖然單個Photo對象僅縮減了約1KB,但在千億量級的規模下,這一優化聚沙成塔,累計節省了數百TB的存儲空間。
經過測算,本次治理工作至今已累計節省了超千萬元的成本,且這一收益仍在持續增加中。此外,不可忽略的是Photo對象縮小25%后,億級QPS的流量在內外網傳輸時帶寬需求顯著降低。各請求上游服務處理的數據量也相應減少,甚至快手用戶在手機上刷短視頻時,每條視頻的流量也減少了若干KB——盡管如今流量成本已經很低,但積少成多,依然帶來了可觀的收益。總之,本次優化的收益涵蓋了多個環節,包括存儲、緩存、回源服務、網絡帶寬、客戶端流量以及所有在線和離線下游服務處理Photo時所節省的時間和空間等。
?
4.3 存量數據的清洗
?
存量數據清洗的挑戰與決策
坦率地講,對于仍處于藍海增長階段的互聯網大廠來說,如果沒有特別大的成本壓力,增量數據優化完成后,存量數據的清洗往往難以被視為高ROI的事項。坐等存量數據自然冷卻,也能被動取得一定的效果。反而要是清洗數以千億計的存量數據不僅耗時漫長,還蘊藏著不可忽視的穩定性風險。快手近兩年著重加強穩定性意識的建設,在當前業界穩定性事件頻發的背景下,決定是否清洗存量數據實屬不易。兩年前,我們以服務Owner的角色,秉持追求極致和最高標準的理念,做出了推進存量數據清洗的決定。這一決策的前提是通過審慎的正確性驗證過程和持續2個月的放量觀察階段,同時同事間對清洗代碼的每一行都進行了認真負責的評審。
清洗過程的技術細節與穩定性保障
整個數據清洗過程使用了一臺16核的容器實例,并開啟了數百個線程進行數據庫IO操作。除了確保數據正確性外,還需要特別關注穩定性風險。例如,單庫清洗速度不能過快,否則可能導致從庫同步時的binlog延遲,以及下游監聽消費服務的過載風險。Photo庫共有100個分庫,每個分庫中包含10張分表。在數據清洗的初期,我們像林黛玉初進大觀園一樣,“處處小心,時時注意”。從下圖可以看出,第202分庫的10張分表花費了數天時間,通過嚴格控制清洗速度逐步完成。在完成近10個分庫的清洗后,我們逐漸通過庫間并行提速推進,最終用了近兩個月的時間完成了全部清洗工作。
縮表操作的嘗試與發現
在清洗前幾個分庫時,我們曾考慮通過縮表操作(alter table xxx engine=innodb)來量化治理成果。初次嘗試時,驚喜地發現單個分庫縮小了17GB,但后續嘗試其他分庫時效果卻不盡如人意,部分分庫甚至出現了表空間不降反增的情況。這一現象令人困惑,最終我們放棄了縮表操作。盡管如此,清洗過程中在庫文件中產生的“空洞”為新插入的數據提供了填充空間,從而在下圖中形成了明顯的拐點。上方分庫的變化曲線更為平滑,這是因為在初期的小心求證階段,清洗速度控制得較低。
?
令人欣慰的是,困擾數日的縮表后空間不降反增的詭異問題在一年后被快手的MySQL內核研發組同事定位到了原因,且向mysql官方提交了bug并得到官方的確認復現。大致原因和上面的DDL語句執行過程中的增量數據優先插入有關,由于存量數據的主鍵id一般都小于縮表期間新增的增量數據主鍵id,后面的存量數據插入臨時影子表時可能會不斷地引發頁分裂并且挪移已插入的增量數據從而形成新的碎片。由于這部分浪費的比例和每行的尺寸大小有一定的相關性,單行在3~5kB時該不增反降現象會比較明顯,photo的單行大小又恰好處于這個區間,所以就出現了數據清洗完后縮表時概率性的不降反增現象。關于該問題的詳見描述有興趣的讀者可以參考快手KSQL團隊提交給mysql的Bug。
注:Bug鏈接:????https://bugs.mysql.com/bug.php?id=113733???
五、治理過程的一些思考
結合本次治理經驗,我們將經驗泛化到一般情況,從道法器術四個角度談談我們的思考。
?
1、業界對于龐大用戶的存量歷史數據有什么好的應對辦法?(道)
首先,要從商業模式角度思考。如何區分高價值用戶數據和低價值數據是關鍵。高價值數據應通過能夠維持其存儲成本的商業模式運行,例如會員制、續費制等,使存儲和傳輸成本不再完全由平臺單方承擔。這對于處于維護期的大型產品或對存儲成本敏感的垂類小平臺尤為重要,有效的商業模式是維持業務正常運轉的必要保障。快手目前所處的短視頻賽道仍處于較熱階段,加之公司也通過擁抱AI和探索新業態來努力做到不使用戶去分擔這部分成本 ,但不可否認的是,技術成本正在逐年快速上升的事實。
其次,從技術架構選型和迭代來看。互聯網行業“小步快跑”的特點決定了業務初期需要快速落地驗證商業模式,留給工程師仔細思考和分析架構特征的時間有限。當業務成熟、系統穩定后,架構師需要抓住時機進行重構和架構升級,以排除穩定性風險并提升系統效率。在存儲領域,每隔5-10年往往會有新的中間件出現,快手的工程團隊一直在探索更穩定高效的存儲架構,同時快手也成立了專門的架構治理團隊,橫向推動多個領域的平臺迭代和演化。
另一個切入點是數據結構和算法的優化。本文的巧妙之處在于以極低的人力成本取得了堪比架構迭代的收益。我們希望本文能對業務一線的工程師有所啟發,他們通常是組織內最貼近數據結構和高頻算法的人。如果能夠孜孜不倦地鉆研業務和代碼,即使面對接手過來“祖傳”的老代碼和算法,稍作調整也可能挖掘出巨大的價值。雖然不是大規模的架構升級,卻也是新質生產力的有力注解。
2、高QPS高容量的緩存集群命中率提升有哪些手段?(法)
從時間維度來看,縮短冷數據在緩存中的駐留時間是一個可行的方向。常用的LRU/LFU等緩存淘汰算法天然具備對冷熱數據分層的能力,但如果緩存集群的單個節點特別大,冷數據從加載到淘汰的最小逐出時間可能會變得不可忽略。通過預先設定分級的過期時間,可以有效縮短這一最小逐出時間,但這需要對具體的數據使用場景和頻率進行深入調研和分析。
從空間維度來看,減少冷數據在緩存中的占用空間是一個有效的方法。以Photo為例,某些冷數據召回場景僅需要用到Photo對象中的某幾個字節的屬性,但卻將整個幾KB的Photo對象加載到緩存中,并經歷完整的最小逐出時間周期,這降低了緩存的空間利用效率。快手直播在類似場景下,獨立部署了一套“高頻不可變屬性字段最小集”的架構設計方案,針對大量調用冷數據的主調方進行定制優化,有效降低了冷數據的緩存空間占用規模,從而提升了緩存命中率。這種方法雖然會使接入流程復雜化,但從長期來看,收益是值得的。本文正是從空間維度提升命中率的一次巧妙嘗試。
3、擴展字段為什么用json存儲, 用protobuf是不是更有空間效率? (器)
Protobuf的編碼效率遠高于JSON,但在并發修改時容易相互覆蓋,且MySQL對PB的支持不如JSON成熟。PB + MySQL的組合更適合結構性開銷比例大且編輯頻率低的場景。因此,盡管PB在空間效率上具有優勢,但在高并發和頻繁修改的場景下,JSON仍然是更穩妥的選擇。
4、Photo 的擴展字段這么大,可以在DB里壓縮存儲嗎?(術)
實際上,Photo數據庫已經在進行壓縮存儲,但由于早期MySQL版本不支持列壓縮,壓縮字典只能局限在行內,壓縮效率無法充分發揮。快手MySQL團隊在新引入的8.0版本中支持了列壓縮功能,在類似場景下已經能夠實現更高的壓縮效率。從架構選型的角度來看,初期也可以直接考慮使用HBase等具有原生列壓縮能力的存儲層,以進一步提升存儲效率。
?
六、總結
自移動互聯網和4G、5G網絡普及以來,已過去十多年,許多公司正面臨海量UGC歷史數據的挑戰。如何讓歷史數據變為資產而非技術負債,將會成為是一道日益凸顯的考題。盡管技術的進步會涌現出更高效的架構和產品,但對于龐大體量的業務進行架構遷移和升級本身的成本和風險也不可小覷,這就給貼近業務的工程師在現有的架構體系下發掘系統"低效率"的邏輯并加以優化提供了價值空間,操之得當便可有效延長現有系統的生命周期。
對于工程師來說,尋找系統中的“跑冒滴漏”可以像探礦一樣充滿樂趣。如果能被幸運之神眷顧,探到一座"金礦",不但能為公司帶來可觀收益,工程師個人也能收獲滿滿的成就感。但在探礦和開采過程中也要牢記本文開頭引用圖靈獎得主Donald Knuth的名言 ——"過早的優化是萬惡之源",優化時機選取也很重要,對線上系統的每一步優化操作都要抱有"戰戰兢兢,如臨深淵,如履薄冰"的心態,要時刻保持對系統穩定性的敬畏之心。
一個擁有龐大用戶量和訪問量的平臺能穩定運行多年,離不開優秀的架構設計,快手的架構設計便是如此。然而,如同飛機和汽車,再優秀的系統在長期運行后也會面臨“老化”和“熵增”問題——低效存儲、無用請求增多、緩存命中率下降、延遲上升等,都在消耗系統的生命力。本文描述的正是對抗熵增的過程,其巧妙之處在于通過細致的調研分析,以極低的人力投入,取得了顯著的收益。相比于跨部門大規模架構迭代的"強渡大渡河"來說,本文故事稱得上是一次"巧渡金沙江"。
本文作者:王興邦
