位元組跳動評論中臺重構一週年留念
〇、序章
網際網路使用者的評論發表和評論瀏覽這類UGC場景作為普通使用者的核心互動體驗已成為UGC產品的標配,從BBS/貼吧跟帖到微博的“圍觀改變中國” 概莫能外。 從公司第一款產品“搞笑囧圖”開始,評論服務就已經相伴而生。目前全公司已有X產品線接入評論服務,僅國內日增評論量X億+,評論列表介面晚高峰QPS可達Xk+。 為了給各業務場景提供更靈活的資料模型以及更高效更為穩定的服務能力,評論組從2018年四月上旬開始對評論服務從底層資料模型到整體服務架構進行了徹底的改造重構。從專案啟動至今剛好滿一年,現在回頭看有收益也有遺憾,在此簡述一二供大家參考。其中服務重構直接收益如下: 1. 資料模型抽象: 將評論(Comment)和回覆(Reply)抽象成統一的評論模型; 2. 儲存結構優化: 從之前Redis—MySQL(SpringDB)到現在的LocalCache—Redis—ABase+MySQL 三級儲存; 3. 服務效能優化: * 評論列表介面(GetComments)晚高峰pct99從100ms 降至60ms; * 評論發表介面(PostComment)晚高峰pct99從250ms 降至25ms; * 全服務佔用CPU 從7600核降至4800核; 4. 公共輪子產出: * 略
一、一年前的困境與挑戰
因歷史業務需求老評論服務資料模型在底層資料庫表結構劃分成了評論(Comment)和回覆(Reply)兩級,而資料結構反向作用於產品形態,導致限制公司各APP的評論都是兩級結構。唯一的區別就在於二級評論的展現形態是以“今日頭條”為代表的評論詳情頁樣式,或是“抖音”的樓中樓樣式。評論系統本身的資料結構不應成為束縛產品形態的瓶頸,因此提供更抽象的資料模型已支援未來更為豐富的業務場景是這次重構的首要目標。 老評論服務資料儲存中採用MySQL作為落盤儲存,除評論Meta欄位外還包括Text和Extra欄位,這兩個欄位容量佔據總容量的90%,造成了大量的無效IO開銷和DB儲存壓力。 老評論服務中對MySQL基於GroupId進行分庫鍵拆分成101個分庫,並通過SpringDB進行CommentId到GroupId的對映,當SpringDB(後期已不再開發迭代)出現問題時會出現嚴重的讀放大問題; 老評論服務資料層與業務層邏輯耦合嚴重,不利於保障針對各產品線業務邏輯迭代,以及後續的效能調優和運維保障;此外評論服務儲存元件訪問許可權不收斂,導致多次線上問題以及運維成本的增加; 老評論服務的評論計數依賴於CounterService,整體寫入鏈路中間環節過多(comment_post_service → MySQL → canal(binlog) → Kafka(upstream) → Flink → Kafka(downstream) → CounterService)。導致的問題有資料更新存在≈10s延時;鏈路過長導致穩定性下降以及監控缺失;資料校驗修復困難;資料讀取不收斂等一系列問題;
二、新服務架構
2.1 資料模型設計
評論服務資料模型演化經歷了三個階段: 1. 第一個階段是單拉鍊模式,將文章ID作為拉鍊連結串列的表頭以此拉取該文章下所有評論內容; 2. 第二個階段是多叉樹模式,對某條評論進行評論動作在資料結構上就是向下分叉出一個葉子節點。以老服務為例,資料採用分表儲存的方式分成group_comment和reply_comment表,分別對應評論(Comment)和回覆(Reply)兩級結構; 3. 第三個階段是森林模式,當多叉樹各子節點需要打標/染色時,根節點就會從GroupId泛化成為GroupId+Tags。 - 在新資料模型中,將評論和回覆兩級結構抽象成獨立的單行Comment結構,通過Level、GroupId、ParentId維護之前的樹形結構。其中文章(CommentId=GroupId, ParentId=0)為Level0,老評論(ParentId=GroupId)為Level1,老回覆(ParentId=Level1_CommentID)為Level2。新表字段及其與老表欄位對映邏輯可見附錄; - 同時為了將來各業務線的儲存隔離,以及資料隔離、資料單向隔離、資料互通做準備,在相同GroupId/ParentId前提下,通過AppId和ServiceId(未來將通過AppId中提取ProductId後進一步抽象一個SourceId)進行資料隔離。最終,評論資料模型的根節點將由GroupId+SourceId組成;
2.2 整體架構設計
在老服務中服務被拆分成comment_post_service(寫)和comment_service(讀)兩部分,兩個子服務以及評論推薦都能直接訪問包括MySQL在內的各儲存元件,此外包括推薦、稽核在內均可訪問線上MySQL庫。為此新評論服務將儲存元件全部收斂至全新的Data服務內部,整體架構拆分成Post(&PostSubsequent)、Pack和Data倒三角結構: - Post(&PostSubsequent): Post服務承擔包括髮表、更新、點贊在內所有寫入相關的業務邏輯; - Pack: Pack 服務承擔所有瀏覽相關的業務邏輯,包括評論列表中推薦Sort服務的呼叫以及相應打包操作; - Data: 其中Data服務剝離幾乎所有業務邏輯作為一個純粹的CURD層工作,同時也藉此機會收斂了所有基礎儲存元件的讀寫許可權;其中MySQL僅儲存Meta欄位作為拉鍊索引,ABase儲存全量資料供線上場景使用。
如前文所述,老服務核心儲存MySQL中所含Text、Extra欄位佔據較大的儲存空間,在可預見的未來將會成為嚴重的效能瓶頸。在重構服務中落盤儲存分成異構的MySQL+ABase兩部分,其中MySQL作為核心拉鍊場景僅儲存Meta欄位,而ABase中儲存Meta+Text+Extra三部分資料。因為對ABase儲存中採用Protobuf打包,再加上ABase內部的snappy壓縮策略,預計可節省30%+的頻寬及儲存開銷。 此外評論重構中提供comment_go_types公共庫,用於收斂三個服務的公共邏輯、公有資料結構以及業務欄位的統一管理;
2.3 Post服務簡述
在評論寫入場景中,Post服務拆分成Post和PostSubsequent兩個子服務。拆分後PostComment介面晚高峰延時從250ms降至25ms,既一個數量級的優化提升。 Post服務承擔包括引數校驗、文字檢查在內的前置業務邏輯以及呼叫Data服務寫入資料。在此前的業務需求迭代中老Post服務對外暴露了12個介面,新服務中在保持介面不變的前提下收斂邏輯為Post、Update、Action三大邏輯。因評論發表、評論更新以及評論點贊這類寫入邏輯流程類似,在Post服務中採用WorkFlow串聯各業務流程。
func PostComment(ctx context.Context, request *post.PostCommentRequest) (*post.PostCommentResponse, error) {
workFlow := postCommentWorkflow{
request: request,
response: rsp,
}
rsp, err := workFlow.init().check(ctx).process(ctx).persist(ctx).subsequent(ctx).finish()
return rsp, err.ToError()
}
PostSubsequent服務負責在評論成功落庫後其他的業務邏輯的處理。因為資料落庫後本次呼叫核心流程就已經結束,所以Post服務以OneWay的形式呼叫PostSubsequent後對上游返回成功。在PostSubsequent服務內部通過EventBus元件(EventBus元件於facility庫內提供)進行各子任務(Task)的分發管理;在EventBus中通過EventType註冊執行不同的Task,而Task本身分為Subscribe(同步序列任務)SubscribeAsync(非同步任務)SubscribeParallel(非同步並行任務)三類;
此外針對評論Emoji白名單檢測需求,新Post服務通過Trie樹結構實現評論文字的Emoji字元檢測。此需求延時從此前呼叫Antidirt服務的5ms降至1ms以內;
2.4 Pack服務簡述
Pack服務是承擔評論所有瀏覽相關的業務邏輯的打包服務,基本業務邏輯可以拆分成Load和Pack兩部分。Load部分負責從下游依賴中獲取本次服務呼叫中所需的原始資料,Pack部分將Load獲取的原始資料進行業務邏輯相關的打包操作。 Load部分通過ParallelLoader元件(ParallelLoader元件於facility庫內提供)進行依賴下游的併發呼叫。Load部分通過LoadManager進行打包管理,其中LoadManager內分成多級LoaderContainer,存在依賴關係的Loader之間由LoaderContainer保證Loader執行前已完成依賴的載入。在LoaderContainer內部所有的Loader均為並行執行,從而減少服務整體延時。為了保證並行操作的安全性,ParallelLoader利用Golang interface介面特性採用雙重註冊制公職資料流規範。首先各個子Loader需要通過各自LoaderParamer的interface註冊將會使用到的Get和Set方法,同時各LoadManager需要註冊各業務使用到的Loader,並註冊全部的Get及Set方法。 Pack這類高併發的打包服務一個典型特點是存在大量IRQ,而且小包較多。據架構同學測試,pack單例項(4核4G)OS內部中斷超過300k/s。針對這類情況優化分為兩部分,首先是由架構同學優化核心,提高處理PPS能力;其次當呼叫對實時性不高的下游服務時(ie. RtCounter),Pack服務內實現merge元件,將多個請求彙總後再呼叫下游服務。
2.5 Data服務簡述
Data服務作為一個純粹CURD服務收斂了所有對基礎儲存的讀寫控制不承擔業務邏輯,並且只為上層的Post、Data以及Comment-Sort服務使用。同時為了實現讀寫隔離及快取命中率等原因,將Data服務拆分成Post、Default和Offline三個叢集,分別承擔寫入、讀取以及離線拉取的職責。 在儲存架構方面LocalCache—Redis—ABase/MySQL 三級儲存架構。因MySQL僅儲存Meta欄位,因此MySQL設計職責可專注於優化評論列表拉取操作。 LocalCache使用的是開源的freecache,freecache通過控制物件指標數及分段保證了極小的記憶體開銷和高效的併發訪問能力。 評論主儲存Redis採用CommentId作為主key,因評論場景本身的離散性保證了沒有熱key傾斜的問題(可通過計算各ID切片後方差驗證);
對賬機
對賬機是獨立於評論三大服務元件之外獨立部署的一個監控保障服務,它採用ElasticSearch作為搜尋儲存引擎。該服務用於實現評論發表階段生命週期監控和海外雙機房同步資料一致性監控兩大目的,其中海外雙機房同步的實現是在此次評論重構海外對齊階段實現的。當時專案的背景是MALIVA機房的Musically和ALISG機房的Tiktok兩個APP要實現全內容互通,因種種原因評論沒有采用當時通用的底層儲存元件DRC同步的方案,而是選擇採用上游業務打標回放的方案。評論組利用異構儲存的ElasticSearch對海外雙機房抽樣進行資料一致性監控,進而保障了MT融合評論場景的資料穩定性。
三、遷移方案
在評論重構專案中因設計資料模型和儲存架構的修改,所以需要評論團隊自身完成包括資料遷移、服務遷移在內的遷移工作。整個過程完成了300億+資料的匯入,以及Post、Pack兩個服務的遷移工作。同時本遷移方案對上層業務全透明,在遷移過程中上游業務無任何感知。
3.1 資料遷移
資料遷移分成存量資料遷移和增量資料追平兩部分。評論選用的方案是存量資料由現有Hive dump 資料至HDFS後通過Spark任務完成新老資料的轉換;增量資料追平通過老MySQL binlog日誌回放方式完成。在遷移過程中遇到如下小坑特此記錄: - 在Spark Quota資源充足的情況下,需要靈活控制寫入QPS,否則會打垮下游儲存; - 因系統Quota管理的問題,會存在任務重啟,所以需要業務自己記錄遷移位點,或者通過切塊多個子檔案並行執行; - 因binlog日誌預設儲存7天,所以需要在7天內完成存量資料匯入並開始增量追平流程; - 現有Hive表採用增量更新的方式會存在資料丟失的問題,時間越久丟失越多;建議遷移前直接從MySQL dump一份至HDFS; - 向新MySQL庫匯入資料時因衝突問題,先導資料後建索引所用時長會比先建索引快; - canal傳送binlog日誌時會使用主鍵id作為Partition Key,從而保證了單行有序。如果遷移指令碼同步消費Worker速度不達預期需要通過非同步分發提速時,也需要注意通過主鍵id Hash來保證有序性。 - 因為binlog日誌本身的有序性,所以在binlog日誌追平增量資料時不需要記錄每個Partition的精確位點,只需要保證Offset提前於存量Hive最新Dump的日期即可; - 最後,監控無論多麼詳細都不過分;
3.2 服務遷移
服務遷移分成Pack服務遷移和Post服務遷移兩部分。
Pack遷移 其中Pack服務遷移重點是資料驗證及邏輯迴歸,評論老comment_service服務將上游讀請求除執行業務邏輯外,作為映象同步呼叫至新Pack服務。新老服務以LogID為Key將序列化後的Response存入Redis中,由線下Diff指令碼讀取Redis內資料進行欄位校驗。 Post遷移 Post服務邏輯較為複雜,大致流程簡述如下圖所示:
四、相伴而生的輪子
4.1 comments_build_tools工具
comments_build_tools是一套工程規範和實踐標準,能幫助開發者快速擴充套件功能,提升程式碼質量,提升開發速度。comments_build_tools的主要功能包涵: - 針對thrift的idl檔案裡面的每個方法定義生成好用,好擴充套件且標準的rpc程式碼。包括四個golang函式以及兩個鉤子函式; - 生成高度可讀,且無錯的資料庫程式碼,類似於java的jpa或者mybatis,生成ORM到物件的對映; - 標準化的測試框架,讓單元測試變得簡單,解脫服務端單元測試對環境的依賴; - 幫助你檢查程式碼質量,包括不安全的,可能產生bug的程式碼; - 生成標準高效的列舉程式碼,列舉提供string、equal等多種方法; - 輔助生成泛型庫: 具體可參考下文facility工具庫; - PS 截止至2019年4月1日該輪子已在GitLab收穫67個Star,有任何疑問和需求歡迎加入Lack "comments_build_tools使用群"騷擾輪子作者zhouqian.c;
4.2 facility工具庫
facility庫除了上文提到的EventBus、LoadParallel元件外還提供assert語句、型別安全轉換以及afunc等功能。 此外,因為Golang是一種強型別語言,不允許隱式轉換;同時Golang1.x 還不支援泛型;所以在開發過程中語言不如C++、Java精煉。facility 庫是利用comments_build_tools 模板程式設計生成的一個語法糖庫,提供各個資料型別的基礎功能,包括且不限於如下功能: - Set資料結構: 支援Exist、Add、Remove、Intersect、Union、Minus 等操作 - Map資料指定預設值; - 有序Map: 順序、逆序、指定序; - Slice 資料的查詢、型別轉換、消重、排序; - ?: 三元操作符;
五、尾聲
5.1 經驗與教訓
- 週會制度: 評論重構專案從啟動到完成整體遷移堅持週會制度,並且邀請了多位經驗豐富且深刻理解業務的同學一起參與。週會討論範圍涵蓋從架構設計到遷移方案討論甚至程式碼走讀,從而保證了方案到細節都經過了充分的討論和思辨;
- 快速迭代: 在重構過程中快速迭代勇於試錯。當時嘗試過但是最終未能上線的功能有:服務自身snappy打包、單機Redis程序進行LocalCache管理、基於gossip的LocalCache管理、基於kite client middleware的一致性雜湊LoadBlancer etc;
- 最大的前向相容性: 在評論重構至到服務最終上線,對上游業務方均是透明的;因為評論服務涉及數十個業務線、上百個上游PSM,如果涉及到上游業務改動,整個排期將不可控;
- 對既定目標沒有激進的拿結果: 為了保證排期和降低相容性風險,在方案設計和程式碼結構上存在妥協,導致一些工作延後到現在重新開始完成;
- 對MySQL表設計尤其是索引設計需要慎之又慎,作為業務場景相對固定的表,索引只需要最大限度的保證線上業務需求即可;比如評論為了可能的回掃場景設定了CreateTime索引,現在此類需求已完全由ElasticSearch完成,所以這個索引現在是廢棄狀態;
- 如果業務中存在某單表讀寫QPS特別高,可以針對性的採用單庫單表的模式,防止一主N從結構擴容時,其它次級表佔用儲存空間;
- 評論服務儲存元件繁多(ABase * 2、MySQL、Redis * N、ElasticSearch),但是對柔性事務保障能力不足;
5.2 開放討論
在服務重構過程中有很多激烈的討論,本文述而不論羅列出來供大家討論: 1. Data服務定位為純粹的CURD層還是可以承載業務邏輯; 2. 評論計數快取Redis、評論列表兜底Redis這類輕量級儲存元件應該由Data服務執行還是上拋至Pack服務直接訪問。比如自建於Redis之上的兜底索引的讀取是由Pack服務直接讀取,還是下沉至Data服務; 3. 在程式碼結構中配置檔案的定位是程式碼既配置還是配置為檔案;