字節跳動評論中台重構一週年留念

語言: CN / TW / HK

〇、序章

  互聯網用户的評論發表和評論瀏覽這類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的主要功能包涵: - 針對thriftidl文件裏面的每個方法定義生成好用,好擴展且標準的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 經驗與教訓

  1. 週會制度: 評論重構項目從啟動到完成整體遷移堅持週會制度,並且邀請了多位經驗豐富且深刻理解業務的同學一起參與。週會討論範圍涵蓋從架構設計到遷移方案討論甚至代碼走讀,從而保證了方案到細節都經過了充分的討論和思辨;
  2. 快速迭代: 在重構過程中快速迭代勇於試錯。當時嘗試過但是最終未能上線的功能有:服務自身snappy打包、單機Redis進程進行LocalCache管理、基於gossip的LocalCache管理、基於kite client middleware的一致性哈希LoadBlancer etc;
  3. 最大的前向兼容性: 在評論重構至到服務最終上線,對上游業務方均是透明的;因為評論服務涉及數十個業務線、上百個上游PSM,如果涉及到上游業務改動,整個排期將不可控;
  4. 對既定目標沒有激進的拿結果: 為了保證排期和降低兼容性風險,在方案設計和代碼結構上存在妥協,導致一些工作延後到現在重新開始完成;
  5. 對MySQL表設計尤其是索引設計需要慎之又慎,作為業務場景相對固定的表,索引只需要最大限度的保證在線業務需求即可;比如評論為了可能的回掃場景設置了CreateTime索引,現在此類需求已完全由ElasticSearch完成,所以這個索引現在是廢棄狀態;
  6. 如果業務中存在某單表讀寫QPS特別高,可以針對性的採用單庫單表的模式,防止一主N從結構擴容時,其它次級表佔用存儲空間;
  7. 評論服務存儲組件繁多(ABase * 2、MySQL、Redis * N、ElasticSearch),但是對柔性事務保障能力不足;

5.2 開放討論

  在服務重構過程中有很多激烈的討論,本文述而不論羅列出來供大家討論: 1. Data服務定位為純粹的CURD層還是可以承載業務邏輯; 2. 評論計數緩存Redis、評論列表兜底Redis這類輕量級存儲組件應該由Data服務執行還是上拋至Pack服務直接訪問。比如自建於Redis之上的兜底索引的讀取是由Pack服務直接讀取,還是下沉至Data服務; 3. 在代碼結構中配置文件的定位是代碼既配置還是配置為文件;