1、項目背景
內容架構是 QQ 瀏覽器
搜索
的內容接入和計算層,主要負責騰訊域內的內容接入和處理,當前接入了多個合作方的上千類內容。
正如前面《
如何避免舊代碼成包袱?5步教你接手別人的系統》中提到,這是一套包含 93 個小服務的微服務架構。經過 23 年 Q1 的大力治理,讓我們穩住陣腳,進一步對老系統做深入的評估:
-
研發效率較低
:
新增一類數據需要在 3~4 個服務上做開發,代碼量不多,但很繁瑣。
-
系統性能較差
:
數據流經多個小服務,且服務內部的實現普遍較差。譬如:核心服務的 CPU 最高只能用到 40%、一條消息從進入到流出需要經過 20 多次的反復 JSON 解析、多處存在多余的字符串拷貝和查找…
從架構和代碼層面,我們看到系統存在較多的缺陷,同時我們也多次收到業務同學、上層領導對吞吐性能的投訴反饋,譬如:傳輸 6 億的文檔需要 12 天,太慢了;內容接入周期太長,成了某項目的瓶頸等等。
作為偏后方的基礎架構系統,可靠高效是基本要求, 我們決定對系統做徹底的改造,設計簡單的系統、寫清晰的代碼,提升系統性能和研發效率,為搜索業務提供穩定高效服務。
2、整體設計
內容架構主要做內容的接入和計算,支持的內容類型非常多,由于舊系統過度微服務化,且缺乏插件復用設計,使得需求開發人力較高,同時也存在性能缺陷、容災不足等嚴重架構缺陷。新系統基于“零基思考”,重新規劃設計,架構層面聚焦下面 5 個點:
??
微服務和單體服務:舊系統由多個細碎的小服務組成,RPC 交互消耗很大,結合“處理量大、計算量小、失敗容忍度低”的業務場景,新系統采用單體服務設計,數據在內存間流動,減少消耗。
??
插件系統:面對繁雜多樣的處理流程,舊系統沒有插件化設計,代碼里全是“if-else”邏輯;新系統我們使用插件化的設計,靈活支持業務需求。
??
兼顧增量和批量(
刷庫):老系統應對批量數據處理(
刷庫)流程非常乏力,沒有做流程拆分,使得刷庫性能較差;新系統可以為刷庫場景做定制化配置,大幅度提升刷庫性能。
??
故障容災:舊架構幾乎沒有考慮容器遷移時的數據保障,新架構結合消息中間件實現流量削峰和消息緩存,實現故障時數據不丟。
??
水平擴容
:老系統的消費和計算沒有分離,使得 CPU 最高只能用到 40%,且無法水平擴容;新系統將消費線程與處理線程分離,大幅提升單機處理性能,也能水平擴容。
新系統設計:
3、詳細設計
3.1 從微服務到單體服務
十多年來微服務在后臺系統大行其道,我們接手的老系統也是微服務設計,那么我們要繼續微服務嗎?
首先來看我們業務的特點:
-
處理量大
:
每天有幾十億次內容新增/更新。
-
計算量小
:
內容架構主要做接入和計算調度,計算量主要在下游的算子服務或者工廠。
-
失敗容忍度低
:
內容丟失便無法被搜索到,不能容忍內容丟失。
-
內容類別多
:
已有上千種類型,還在持續增加。
-
需求小且類別單一
:
所有的需求都是新內容源接入,需求類型較固定。
再來看老系統的設計,以接入系統為例,內網推送、公網推送、HTTP/Kafka 拉取這四類接入的實現,分散在四個服務上,再經過統一接入代理服務、數據處理服務、分發服務處理,一個條內容數據需 6 次 RPC 交互。在實踐中帶來這些問題:
-
需要更復雜的容錯處理
:首先微服務群需要考慮超時時間合理分配;然后每一個微服務都需要考慮失敗重試、重試雪崩等容錯處理,復雜度隨微服務個數成倍數增長。幾十億文檔處理疊加上多個微服務,稍有不慎就會導致海量告警轟炸,甚至出現數據丟失。
-
需求迭代慢
:一個需求一般由一個人承接,需要改動多個微服務,整體代碼量不多,但分散在多個服務中。
-
計算浪費
:內容數據在多個服務中流動,需要反復地做序列化和反序列化,而服務本身有價值的處理主要是字段轉換、簡單字符串處理等輕量計算,框架帶來的計算消耗比本職計算還高。
最后,我們的新架構采用單體服務設計,在容錯處理、迭代效率、計算量等方面都取得不錯的效果(
見文末數據指標
)。
(內容接入系統新老架構對比圖)
3.2 接入處理流程插件化
內容接入系統需要處理上千類內容,不同的內容通常來自不同的團隊,各個團隊都有一套對外輸出內容的標準協議,因此內容接入系統需要編寫大量的對接適配代碼,如何更輕便地實現新內容接入,是我們重點關注的。
如設計圖所示,我們的業務功能整體分為三層:接入層,處理層,分發層。
在接入層,我們需要處理多種途徑接入的多種數據格式。途徑包括:DB 定時拉取、Kafka 流式拉取、HTTP/COS 拉取、RPC 拉取等;數據格式也多種多樣,每個數據方提供的數據格式各不相同。以 Kafka 拉取類接入為例,小說業務推送的是 JSON 格式數據,而小程序業務推送的是 PB 序列化的二進制字節流。
在處理層,不同的業務我們要執行不同的格式校驗;有的業務收到數據后,需要再請求其他服務以補全特定屬性;有的業務需要我們執行一些字段格式轉換;有的業務需要我們對數據中的值進行定制化修改。
在分發層,每個業務要分發的目的地也不同:有的業務只需發往 Kafka,有的業務需要存入 DB、 Redis、DCache 等,有的業務需發送 HTTP / RPC 請求至特定服務通知更新。其中,Kafka 的 Topic、 DB 的存儲表、目標服務的地址、協議也各有不同。
面對這樣復雜的業務功能,老系統建設了一套數據處理流程,然后在主流程中通過 if-else 判斷來走不同的處理流程,可以明顯看到“堆代碼”的痕跡,其源碼組織的清晰度、功能的可插拔性都較差。
在新的接入系統中,我們將接入、處理、分發中的各個關鍵功能點實現為插件架構,每一個子功能都是一個插件,同時按照業務粒度的處理流配置組合使用插件。
例:批式接入任務執行流程
當有新增的定制化業務需求時,我們只需要在相關環節增加插件,開發插件時,只需實現關鍵函數,如拉取任務插件只需實現拉取和拉取任務是否結束這兩個接口。分發插件只需要實現分發邏輯;其余部分在框架層實現并統一調度,開發者無需了解。如果新業務只用到現有的功能,我們則只需要在 DB 中配置插件組合序列,無需代碼開發。
通過此插件化設計,讓業務接入更輕便,大幅降低業務需求的 LeadTime(
見文末數據指標
)。另外,老系統在各服務代碼中各種硬編碼 if 業務 ID == 指定 ID,則執行/不執行指定邏輯,排查業務問題時需要跨多個服務看代碼,效率極低。而新系統只看配置便可清楚了解一個業務的接入處理全流程執行過程,極大地提升了運維排查效率。
3.3 兼顧增量更新和批量刷庫
接入系統經常收到“刷庫”類的需求:將指定業務的全部數據經過某個處理后發給某個指定下游。因老系統沒有插件化設計,在組件組合使用上缺乏彈性,使得刷庫需求不得不通過增量更新流程滿足,因而做了大量無效計算。
新系統兼顧增量更新和批量刷庫。我們結合接入系統的輸入特點,將數據流配置分為了四種:數據源更新處理流、特征更新處理流、數據源刷庫處理流和特征刷庫處理流。
在數據源/特征更新的處理流中,我們需要配置業務線上數據處理的各類算子及分發算子。而在刷庫處理流中,數據來源于我們的底表 HBase ,實際未發生變更,不需要重新計算。并且,在常見的刷庫場景中,一個業務數據正常更新時需要分發給多個下游,刷庫時只有部分下游需要重刷,此時我們只需要配置目標地的分發算子即可。
通過區分四類處理場景的數據處理配置,同一個業務在正常處理時和刷庫時,新接入系統可執行不同的數據處理流,進而移除了刷庫場景下的不必要計算和分發邏輯,單核刷庫 QPS 提升了 16 倍。
3.4 數據接入服務故障容災
數據不丟是內容架構的核心指標,無論數據是怎么來的,只要進入了我們系統,就應該保證不丟失。
接入系統的各類接入方式可歸為三類:接口推送類、Kafka 通道類和定時任務批式拉取類。這三類接入方式中,Kafka 通道類自帶數據備份,數據未處理完時不執行 Offset Commit,即可保證該數據不會丟失;批式定時拉取類的任務是可重入的,若拉取任務運行過程中進程退出,新節點重啟任務即可恢復,數據不會丟失;只有接口推送類的數據可能在進程退出時未處理完,導致丟數據。
老系統對接口推送類數據沒有做任何的保護,也就意味著進程異常退出、容器故障遷移等接入服務故障場景沒有有效處理,數據可能丟失。
我們在新架構上增加了消息中間件 Kafka 實現數據容災。對于 HTTP / trpc 接口推送進來的更新數據,接口層直接將其發進 Kafka,并返回給業務成功。此中間 Kafka 由指定的分區 (set) 進行異步消費處理,消息處理完成后才會執行 Offset Commit。如在消費處理過程中,部分節點進程崩潰/退出,其他健康節點會通過接手消費處理對應分區的文檔消息,最大限度保證數據不會丟失,同時消息中間件也帶來削峰的效果。
3.5 消費與處理線程分離
老接入系統處理性能較差的重要原因在于:未將 Kafka 消費和文檔處理線程分離。某業務配置 N 個線程處理,則這些線程先從 Kafka 拉取文檔,再按照配置執行各環節的處理,處理完一批消息再去 Kafka 拉取,消費線程同時是處理線程,重計算的業務無法充分利用 CPU。同時,一個 Kafka 分區最多只能被一個線程消費,集群最大處理并發數受限于 Kafka 總分區數,無法實現水平擴容。
新系統設計了一個基于無鎖隊列的文檔計算工作線程池,每個 Kafka 分區可以被一個線程消費,并被多個計算線程處理。通過消費和計算線程分離,充分利用 CPU,大幅提高了 CPU 利用率和處理性能。同時,計算線程數量不再局限于 Kafka 總分區數量,可以水平擴容。
4、新老系統 diff 校驗
整個系統有 15 種分發出口,這些出口分散在老系統的多個服務。如果基于機器本地日志去比較 diff,顯然零散且費力。因此,我們搭建了一個 diff 校驗服務。同時,在多個服務的分發出口進行埋點,并上報分發內容至 diff 校驗服務,從而對分散的 diff 日志進行統一收集并分析。整個數據流如下所示:
比較 diff 的過程中,我們發現分發數據格式復雜,存在多種類型。例如,分發數據 Json Member Value 為一個 JSON 字符串,而 JSON 字符串 Member 的順序是不固定的。為解決該問題,我們實現了一個遞歸的 JSON 對比工具,來校驗多種類型數據的 diff。
5、編碼細節
5.1 更少的代碼
表驅動編程。如下圖所示,重構后使用數據遍歷替代冗長的 if 判斷。
針對數據動態加載,使用 C++20 的std::atomic<std::shared_ptr
>替代原來雙 buffer 設計,如下圖所示。</std::shared_ptr
5.2 更高的性能
用迭代器代替查找和括號取值。RapidJSON 的查找和中括號取值都需要遍歷 member list,對于先查找后中括號取值的場景,可以先保存查找 member 獲得的迭代器,然后通過迭代器來獲取 member value,減少一次 member list 的遍歷。
減少 JSON 反序列化。老代碼的函數參數是 JSON 序列化后的 string, JSON 對象需要反復的反序列化和序列化,存在性能浪費。
我們重構后,將需要多輪處理的 JSON 數據定義成 rapidjson::Document 對象并置于上下文中,消除了反復的序列化和反序列化。這不僅能提升數據處理的性能,還能減少重復的解析 JSON 代碼片段。
5.3 更好的基礎庫
修復 rapidjson::Document 引發的內存泄漏假象,降低內存使用。為了減少重復解析,我們在 DB 拉取模塊拉取到字符串后,就將其解析為 rapidjson::Document,然后存起來。
然而,執行上述優化后,我們發現 DB 每加載一輪,容器的內存就會顯著上漲一截,加載 5-6 輪后,進程內存用滿,發生 OOM。
經過 Valgrind 工具分析和本地多種測試,我們確定實際內存未泄露,內存不斷上漲是因為:使用 RapidJSON 基于內存池 MemoryPoolAllocator 分配器構造 Document 對象,在對象釋放后,空閑內存不會立刻歸還給操作系統。
系統分析后發現這和 RapidJSON 沒有關系,是操作系統的內存策略設計如此。對此類內存釋放不及時的問題,我們調研發現有兩種解決方案:
-
在服務啟動時用 mallocopt(M_TRIM_THRESHOLD) 調低內存釋放閾值,并在對象釋放后,調用 malloc_trim(0) 強制其釋放內存;
-
通過過引入 jemalloc 等內存分配器。
本項目采用鏈接 jemalloc 庫解決。
此外,我們還引入開源的 Sonic-JSON 庫。基于我們內容數據的評測,Sonic-JSON 比 RapidJSON 快 40%,因此我們引入了 Sonic-JSON 代替 RapidJSON ,在新接入系統的壓測中顯示,Sonic-JSON 可以提升 15% 的吞吐,或者降低 17% 的 CPU 開銷。
5.4 更好的可讀性
函數遵循單一職責原則。如下圖所示,針對不同的訂閱類型,老代碼中職責不清晰,在函數中通過 if 判斷來使得不同的訂閱類型走不同的特殊處理邏輯。重構后,我們使用多態設計,不同的訂閱類型派生類繼承基礎類,并針對自己的特殊邏輯進行泛化,從而使得每一個類只處理一種訂閱類型。
將 switch-case 轉換為工廠。如下圖所示,應用插件設計和查表法,提高代碼的可維護性和擴展性。
插件化和配置化。功能組件可以自由組合,從而避免頻繁出現 trick 代碼。如下圖所示,在老代碼中,通過硬編碼實現對指定資源類型做指定的處理。重構后,不同資源可配置不同的處理流程,實現功能熱插拔和組件復用。
6、研發流程
6.1 整體流程
研發流程上,我們沿用開發搜索中臺技術產品時積累的 CICD 建設經驗,包括以下措施:
??
需求確認和啟動,約定 TAPD 必填字段、TAPD 扭轉流程。
??開發者資質,只有獲得開發者資質認證,才能輸出生產線代碼。
??編碼和注釋規范,統一采用騰訊編碼規范和 doxygen 注釋。
??代碼評審,制定可按步驟執行的流程,并提供學習案例。
??基礎庫規則,統一第三方庫、工具庫使用規范,消除項目依賴混亂。
??流水線,統一 MR 模板,嚴格約束靜態代碼質量檢查紅線、單測覆蓋紅線等。
??版本規范,統一版本命名和使用規范:MAJOR.MINOR.PATCH。
??發布流程,騰訊域采用 XAC 發布。
需求管理
在需求規劃時,我們按大模塊(
或功能)劃分大需求(
EPIC),并把大需求分發給不同的開發人員。開發人員在梳理出模塊的詳細實現后,再自行劃分出不同的小需求(
feature),并調整對應的開發耗時。開發過程中使用甘特圖,可以方便確定項目開發進度。
多人協作,難免會出現工作量分布不均勻或者需要延長工期,所以我們在每周三早上有一個十幾分鐘的晨會:確定需求進展,可能風險則及時調整開發人力,保障團隊目標達成。
6.2 代碼評審
代碼質量對項目的長期發展有至關重要。我們團隊要求每位開發者都必須通過代碼安全考試和規范考試,生產線的每一行代碼都需要經過 CR,同時鼓勵全員提升代碼品味,寫出一手好代碼。這里推薦一篇騰訊技術 Leader 總結的 Code Review 指南,
非常有參考性:
《
騰訊 13 年,我所總結的 Code Review 終極》
6.3 文檔協同
文檔可以跨越時間限制,是一種高效的異步溝通工具。在接手內容架構系統后,我們補充了大量文檔,包括資源接入現狀、系統鏈路、日常運維和各種排查文檔,為穩定性維護提供了重要保障。
在系統重構過程中,我們也積累了各類文檔,存放在小組各個方向目錄中。同時在代碼倉庫里,一些復雜的業務邏輯或者復雜的模塊,目錄下維護著 README.md,說明模塊功能、設計、實現和使用方法。
6.4 流水線加速
藍盾流水線是實現 CICD (
持續集成持續部署
) 的核心工具,我們在代碼發起 MR 后設置了MR流水線,代碼合入主干后設置了主干構建流水線。
MR 流水線是代碼開始 CR 前必須通過的紅線,所以 MR 流水線的執行耗時會影響到整個 MR 耗時和需求開發耗時。針對重構期間多人協作出現大量并發檢查任務,以及對流水線關鍵路徑的耗時分析,我們做了如下優化。
MR 流水線包含了代碼安全掃描、代碼規范掃描、單元測試、接口測試等多個步驟。接口測試需要共享特性環境作為部署和測試環境,存在資源競爭。之前限制整個流水線只能有一個構建在執行,其他都要等待。
通過配置藍盾流水線模板的互斥組,可以實現 stage 級別的鎖,多個構建可以并行執行,僅接口測試 stage 互斥,使得流水線構建可以加快 25% 以上 。
我們有一個公共倉庫專門存放各類外部依賴,通過 genrule 生成可被 bazel 直接導入的規則,外部依賴需要通過 tar 或者 git 獲取源碼數據。在實際執行過程中,發現部分外部依賴拉取異常緩慢,卡在 analyzing 步驟,甚至造成編譯失敗。
在分析 log 后發現部分含有二進制依賴的第三方庫,直接從 GitHub 拉取會 QPS 出現卡頓,因此我們修改了 bazel genrule 的生成規則,全部使用鏡像代理。
實測中,發現部分任務卡頓會超過 3 分鐘,優化后不再卡頓。
7、業務效果
7.1 性能收益
內容接入系統:
內容計算系統:
新系統單核性能從 13 QPS 提升到 172 QPS,處理性能提升了 13 倍。
以視頻業務為例,舊接入系統處理峰值為 33465/min,總核數為 40 核,平均單核處理 QPS 為 13。
![微服務回歸單體?代碼行數減少75%,性能還提升了1300%…… 微服務回歸單體?代碼行數減少75%,性能還提升了1300%……]()
遷移到新接入系統后,處理峰值為 32119/min,總核數 6 核,平均單核處理 QPS 為 90。下圖可以看到調大并發處理的線程數后,處理性能會等比例提升。當 CPU 壓到 100% 時處理 QPS 峰值可達 162。
![微服務回歸單體?代碼行數減少75%,性能還提升了1300%…… 微服務回歸單體?代碼行數減少75%,性能還提升了1300%……]()
通過拆分增量數據更新、批量刷庫的處理流,我們為刷庫場景做定制化配置,大幅度提升刷庫性能,集群刷庫性能從 1000QPS 提升到 10000QPS(
受限于外部存儲性能),提升 10 倍。性能對比如下圖所示:
![微服務回歸單體?代碼行數減少75%,性能還提升了1300%…… 微服務回歸單體?代碼行數減少75%,性能還提升了1300%……]()
![微服務回歸單體?代碼行數減少75%,性能還提升了1300%…… 微服務回歸單體?代碼行數減少75%,性能還提升了1300%……]()
平均處理延時從 2.7 秒降低到 0.8 秒。以視頻業務為例,舊接入系統處理一條消息需要經過 5 個系統。每個子系統的性能又較差,p999 處理延遲達到十幾秒。
![微服務回歸單體?代碼行數減少75%,性能還提升了1300%…… 微服務回歸單體?代碼行數減少75%,性能還提升了1300%……]()
新接入系統處理一條消息僅需經過 3 個,且系統性能較高,p999 處理延遲為秒級。
![微服務回歸單體?代碼行數減少75%,性能還提升了1300%…… 微服務回歸單體?代碼行數減少75%,性能還提升了1300%……]()
7.2 研發效率收益
![微服務回歸單體?代碼行數減少75%,性能還提升了1300%…… 微服務回歸單體?代碼行數減少75%,性能還提升了1300%……]()
得益于代碼質量提升、單測覆蓋率提升、微服務合并為單體服務、插件化的設計,在新接入系統下開發新功能或者業務定制化功能,開發難度和開發成本大幅下降,從 5.72 天降低到 1 天。
重構后,業務代碼量從 11.3 萬行降低到 2.8 萬行,下降 75%。主要由下面幾點帶來:
??
微服務合并為單體服務。多個微服務小倉合并成大倉后,消除重復的功能代碼。例如舊系統不同業務 Kafka 接入時,都拷貝了相同的一套實現。
??
優雅的系統設計。譬如:插件化設計,消除大量的 if-else;序列化對象傳參代替字符串傳參,消除大量的 JSON 解析。
??
現代 C++語法的大規模使用,讓代碼更精簡,譬如:必要的 auto、for-range、emplace 等。
-End-
原創作者|QQ 瀏覽器搜索-基礎架構組
來源:本文轉自公眾號“騰訊云開發者”,
點擊查看原文。