祖傳代碼重構:從25萬行到5萬行的血淚史

語言: CN / TW / HK

點個關注👆跟騰訊工程師學技術

圖片

圖片

導語| 近期,我們接管並重構了十多年前的 Query 理解祖傳代碼,代碼量減少80%,性能、穩定性、可觀測性都得到大幅度提升。本文將介紹重構過程中系統實現、DIFF修復、coredump 修復等方面的優化經驗。

圖片

背景

一、接手

7 月份組織架構調整後,我們組接手了搜索鏈路中的 Query 理解基礎模塊,包括本次重構對象 Query Optimizer,負責 query 的分詞、詞權、緊密度、意圖識別。

二、為什麼重構

面對一份 10年+ 歷史包袱較重的代碼,大多數開發者認為“老項目和人有一個能跑就行”,不願意對其做較大的改動,而我們選擇重構,主要有這些原因:

1.生產工具落後,無法使用現代 C++,多項監控和 TRACE 能力缺失

2.單進程內存消耗巨大——114G

3.服務不定期出現耗時毛刺

4.進程啟動需要 18 分鐘

5.研效低下,一個簡單的功能需要開發 3 人天

基於上述原因,也緣於我們熱愛挑戰、勇於折騰,我們決定進行拆遷式的重構。

圖片

編碼實現

一、重寫與複用

我們對老 QO 的代碼做分析,綜合考慮三個因素:是否在使用、是否Query理解功能、是否高頻迭代,將代碼拆分為四種處理類型:1、刪除;2、lib庫引入;3、子倉庫引入;4、重寫引入。

圖片

二、整體架構

老服務代碼架構堪稱災難,整體遵守“想到哪就寫到哪,需要啥就拷貝啥”的設計原則,完全不考慮單一職責、接口隔離、最少知識、模塊化、封裝複用等。下圖介紹老服務的抽象架構:

圖片

請求進來先後執行 3 次分詞:

1.不帶標點符號的分詞結果,用於後續緊密度詞權算子的計算輸入;

2.帶標點符號的分詞結果,用於後續基於規則的意圖算子的計算輸入;

3.不帶標點符號的分詞結果,用於最終結果 XML queryTokens 字段的輸出。

1 和 3 的唯一區別,就是調用內核分詞的代碼位置不同。

下一個環節,請求 Query 分詞時,分詞接口中竟然包含了 RPC 請求下游 GPU 模型服務獲取意圖。這是此服務迭代最頻繁的功能塊,當想要實驗模型調整、增減意圖時,需要在 QO 倉庫進行實驗參數解析,將參數萬里長征傳遞到 word_segmentor 倉庫的分詞接口裏,再根據參數修改 RPC 意圖調用邏輯。一個簡單參數實驗,要修改 2個倉庫中的多個模塊。設計上不符合模塊內聚的設計原理,會造成霰彈式代碼修改,影響迭代效率,又因為 Query 分詞是處理鏈路中的耗時最長步驟,不必要的串行增加了服務耗時,可謂一舉三失。

除此之外,老服務還有其他各類問題:多個函數超過一千行,圈複雜度破百,接口定義 50 多個參數並且毫無註釋,代碼滿地隨意拷貝,從以下 CodeCC 掃描結果可見一斑:

圖片

圖片

新的服務求追架構合理性,確保:

1.類和函數實現遵守單一職責原則,功能內聚;

2.接口設計符合最少知識原則,只傳入所需數據;

3. 每個類、接口都附上功能註釋,可讀性高。

項目架構如下:

圖片

CodeCC 掃描結果:

圖片

三、核心實現

老服務的請求處理流程:

圖片

老服務採用的是原始的線程池模型。服務啟動時初始化 20 條線程,每條線程分別持有自身的分詞和意圖對象,監聽任務池中的任務。服務接口收到請求則投入任務池,等待任意一條線程處理。單個請求的處理基本是串行執行,只少量並行處理了幾類意圖計算。

新服務中,我們實現了一套基於 tRPC Fiber 的簡單 DAG 控制器:

1.用算子數初始化 FiberLatch,初始化算子任務間的依賴關係

2.StartFiberDetached 啟動無依賴的算子任務,FiberLatch Wait 等待全部算子完成

3.算子任務完成時,FiberLatch -1 並更新此算子的後置算子的前置依賴數

4.計算前置依賴數規 0 的任務,StartFiberDetached 啟動任務

通過 DAG 調度,新服務的請求處理流程如下,最大化的提升了算子並行度,優化服務耗時:

圖片

圖片

DIFF 抹平

完成功能模塊遷移開發後,我們進入 DIFF 測試修復期,確保新老模塊產出的結果一致。原本預計一週的 DIFF 修復,實際花費三週。解決掉邏輯錯誤、功能缺失、字典遺漏、依賴版本不一致等問題。如何才能更快的修復 DIFF,我們總結了幾個方面:DIFF 對比工具、DIFF 定位方法、常見 DIFF 原因。

一、DIFF 比對工具

工欲善其事必先利其器,通過比對工具找出存在 DIFF 的字段,再針對性地解決。由於老服務對外接口使用 XML 協議,我們開發基於 XML 比對的 DIFF 工具,並根據排查時遇到的問題,為工具增加了一些個性選項:基於XML解析的DIFF工具。

我們根據排查時遇到的問題為工具增加了一些個性選項:

1.支持線程數量與 qps 設置(一些 DIFF 問題可能在多線程下才能復現);

2.支持單個 query 多輪比對(某些模塊結果存在一定波動,譬如下游超時了或者每次計算浮點數都有一定差值,初期排查對每個query可重複請求 3-5 輪,任意一輪對上則認為無 DIFF ,待大塊 DIFF 收斂後再執行單輪對比測試);

3.支持忽略浮點數漂移誤差;

4.在統計結果中打印出存在 DIFF 的字段名、字段值、原始 query 以便排查、手動跟蹤復現。

二、DIFF 定位方法

獲取 DIFF 工具輸出的統計結果後,接下來就是定位每個字段的 DIFF 原因。

  • 邏輯流梳理確認 ===========

梳理計算該字段的處理流,確認是否有缺少處理步驟。對流程的梳理也有利於下面的排查。

  • 對處理流的多階段查看輸入輸出 ==================

一個字段的計算在處理流中一定是由多個階段組成,檢查各階段的輸入輸出是否一致,以縮小排查範圍,再針對性地到不一致的階段排查細節。

例如原始的分詞結果在 QO 上是調用分詞庫獲得的,當發現最後返回的分詞結果不一致時,首先查看該接口的輸入與輸出是否一致,如果輸入輸出都有 DIFF,那説明是請求處理邏輯有誤,排查請求處理階段;如果輸出無 DIFF,但是最終結果有DIFF,那説明對結果的後處理中存在問題,再去排查後處理階段。以此類推,採用二分法思想縮小排查範圍,然後再到存在 DIFF 的階段細緻排查、檢查代碼。

查看 DIFF 常見有兩種方式:日誌打印比對, GDB 斷點跟蹤。採用日誌打印的話,需要在新老服務同時加日誌,發版啟動服務,而老服務啟動需要 18 分鐘,排查效率較低。因此我們在排查過程中主要使用 GDB 深入到 so 庫中打斷點,對比變量值。

三、常見 DIFF 原因

  • 外部庫的請求一致,輸出不一致 ==================

這是很頭疼的 case,明明調用外部庫接口輸入的請求與老模塊是完全一致的,但是從接口獲取到的結果卻是不一致,這種情況可能有以下原因:

1.初始化問題:遺漏關鍵變量初始化、遺漏字典加載、加載的字典有誤,都有可能會造成該類DIFF,因為外部庫不一定會因為遺漏初始化而返回錯誤,甚至外部庫的初始化函數加載錯字典都不一定會返回 false,所以對於依賴文件數據這塊需要細緻檢查,保證需要的初始化函數及對應字典都是正確的。

有時可能知道是初始化有問題,但找不到是哪裏初始化有誤,此時可以用 DIFF 的 query,深入到外部庫的代碼中去,新老兩模塊一起單步調試,看看結果從哪裏開始出現偏差,再根據那附近的代碼推測出可能原因。

2.環境依賴:外部庫往往也會有很多依賴庫,如果這些依賴庫版本有 DIFF,也有可能會造成計算結果 DIFF。

  • 外部庫的輸出一致,處理後結果不一致 =====================

這種情況即是對結果的後處理存在問題,如果確認已有邏輯無誤,那可能原因是老模塊本地會有一些調整邏輯 或 屏蔽邏輯,把從外部庫拿出來原始結果結合其他算子結果進行本地調整。例如老 QO 中的百科詞權,它的原始值是分詞庫出的詞權,結合老 QO 本地的老緊密度算子進行了 3 次結果調整才得到最終值。

  • 將老模塊代碼重寫後輸出不一致 ==================

重構過程中對大量的過時寫法做重寫,如果懷疑是重寫導致的 DIFF,可以將原始函數替代掉重寫的函數測一下,確認是重寫函數帶來的 DIFF 後,再細緻排查,實在看不出可以在原始函數上一小塊一小塊的重寫。

  • 請求輸入不一致 ===========

可能原因包括:

1.缺少 query 預處理邏輯:例如 QO 輸入分詞庫的 query 是將原始 query 的各短語經過空格分隔的,且去除了引號;

2.query 編碼有誤:例如 QO 輸入分詞庫的 query 的編碼流程經過了:utf16le → gb13080 → gchar_t (內部自定義類型) → utf16le → char16_t;

3.缺少接口請求參數。

  • 預期內的隨機 DIFF ===============

某些庫/業務邏輯自身存在預期內的不穩定,譬如排序時未使用 stable_sort,數組元素分數一致時,不能保證兩次計算得出的 Top1 是同一個元素。遇到 DIFF 率較低的字段,需根據最終結果的輸入值,結果計算邏輯排除業務邏輯預期內的 DIFF。

圖片

coredump 問題修復

在進行 DIFF 抹平測試時,我們的測試工具支持多線程併發請求測試,等於同時也在進行小規模穩定性測試。在這段期間,我們基本每天都能發現新的 coredump 問題,其中部分問題較為罕見。下面介紹我們遇到的一些典型 CASE。

一、棧內存被破壞,變量值隨機異常

如第 2 章所述,分詞庫屬於不涉及 RPC 且未來不迭代的模塊,我們將其在 GCC 8.3.1 下編譯成 so 引入。在穩定性測試時,進程會在此庫的多個不同代碼位置崩潰。沒有修改一行代碼掛載的 so,為什麼老 QO 能穩定運行,而我們會花式 coredump?本質上是因為此代碼歷史上未重視編譯告警,代碼存在潛藏漏洞,升級 GCC 後才暴露出來,主要是如下兩種漏洞:

1.定義了返回值的函數實際沒有 return,棧內存數據異常。

2.sprintf 越界,棧內存數據異常。

排查這類問題時,需要綜合上下文檢查。以下圖老 QO 代碼為例:

圖片

sprintf 將數字以 16 進制形式輸出到 buf_1 ,輸出內容佔 8 個字節,加上 '\0' 實際需 9 個字節,但 buf_1 和 buf_2 都只申請了 8 個字節的空間,此處將棧內存破壞,棧上的變量 query_words 值就異常了。

異常的表現形式為,while 循環的第一輪,query_words 的數組大小是 x,下一輪 while 循環時,還沒有 push 元素,數組大小就變成了 y,因內存被寫壞,導致異常新增了 y - x 個不明物體。在後續邏輯中,只要訪問到這幾個異常元素,就會發生崩潰。

光盯着 query_words 數組,發現不了問題,因為數組的變幻直接不符合基本法。解決此類問題,需聯繫上下文分析,最好是將代碼單獨提取出來,在單元測試/本地客户端測試復現,縮小代碼範圍,可以更快定位問題。而當代碼量較少,編譯器的 warning 提示也會更加明顯,輔助我們定位問題。

上段代碼的編譯器提示信息如下:(開啟了 -Werror 編譯選項)

圖片

二、請求處理中使用了線程不安全的對象

在代碼接手時,我們看到了老的分詞模塊“怪異”的初始化姿勢:一部分數據模型的初始化函數定義為 static 接口,在服務啟動時全局調用一次;另一部分則定義為類的 public 接口,每個處理線程中構造一個對象去初始化,為什麼不統一定義為 static,在服務啟動時進行初始化?每個線程都持有一個對象,不是會浪費內存嗎?沒有深究這些問題,我們也就錯過了問題的答案:因為老的分詞模塊是線程不安全的,一個分詞對象只能同時處理一個請求。

新服務的請求處理實現是,定義全局管理器,管理器內掛載一個唯一分詞對象;請求進來後統一調用此分詞對象執行分詞接口。當 QPS 稍高,兩個請求同時進入到線程不安全的函數內部時,就可能把內存數據寫壞,進而發生 coredump。

為解決此問題,我們引入了 tRPC 內支持任務竊取的 MQ 線程池,利用 c++11 的 thread_local 特性,為線程池中的每個線程都創建線程私有的分詞對象。請求進入後,往線程池內拋入分詞任務,單個線程同時只處理一個請求,解決了線程安全問題。

三、tRPC 框架使用問題

  • 函數內局部變量較大 && v0.13.3 版 tRPC 無法正確設置棧大小 =========================================

穩定性測試過程中,我們發現服務會概率性的 coredump 在老朋友分詞 so 裏,20 個字以內的 Query 可以穩定運行,超過 20 個字則有可能會崩潰,但老服務的 Query 最大長度是 40 個字。從代碼來看,函數中根據 Query 長度定義了不同長度的字節數組,Query 越長,臨時變量佔據內存越大,那麼可能是棧空間不足,引發的 coredump。

根據這個分析,我們首先嚐試使用 ulimit -s 命令調整系統棧大小限制,毫無效果。經過在碼客上搜尋,瞭解到 tRPC Fiber 模型有獨立的 stack size 參數,我們又滿懷希望的給框架配置加上了 fiber stack size 屬性,然而還是毫無效果。

無計可施之下,我們將崩潰處相關的函數提取到本地,分別用純粹客户端(不使用 tRPC), tRPC Future 模型, tRPC Fiber 模型承載這段代碼邏輯,循環測試。結果只有 Fiber 模型的測試程序會崩潰,而 Future / 本地客户端的都可以穩定運行。

最後通過在碼客諮詢,得知我們選用的框架版本 Fiber Stack Size 設置功能恰好有問題,無法正確設置為業務配置值,升級版本後,問題解決。

  • Redis 連接池模式,不能同時使用一應一答和單向調用的接口 ==================================

我們嘗試打開結果緩存開關後,“驚喜”的發現新的 coredump,並且是 core 在了 tRPC 框架層。與 tRPC 框架開發同事協作排查,發現原因是 Redis 採取連接池模式連接時,不可同時使用一應一答接口和單向調用接口。而我們為了極致性能,在讀取緩存執行 Get 命令時使用的是一應一答接口,在緩存更新執行 Set 命令時,採用的是單向調用方式,引發了 coredump。

快速解決此問題,我們將緩存更新執行 Set 命令也改為了應答調用,後續調優再改為異步 Detach 任務方式。

圖片

重構效果

最終,我們的成果如下:

【DIFF】

- 算子功能結果無 DIFF

【性能】

- 平均耗時:優化 28.4% (13.01 ms -> 9.31 ms)

- P99 耗時:優化 16.7%(30ms -> 25ms)

- 吞吐率:優化 12%(728qps—>832qps)

【穩定性】

- 上游主調成功率從 99.7% 提升至 99.99% ,消除不定期的 P99 毛刺問題

- 服務啟動速度從 18 分鐘 優化至 5 分鐘

- 可觀察可跟蹤性提升:建設服務主調監控,緩存命中率監控,支持 trace

- 規範研發流程:單元測試覆蓋率從 0% 提升至 60%+,建設完整的 CICD 流程

【成本】

- 內存使用下降 40 G(114 GB -> 76 GB)

- CPU 使用率:基本持平

- 代碼量:減少 80%(25 萬行—> 5萬行)

【研發效率】

- 需求 LeadTime 由 3 天降低至 1 天內

附-性能壓測:

(1)不帶cache:新 QO 優化平均耗時 26%(13.199ms->9.71ms),優化內存 32%(114.47G->76.7G),提高吞吐率 10%(695qps->775qps)

圖片

(2)帶cache:新 QO 優化平均耗時 28%(11.15ms->8.03ms),優化內存 33%(114G->76G),提高吞吐率 12%(728qps->832qps)

圖片

騰訊工程師技術乾貨直達:

1.超強總結!GPU 渲染管線和硬件架構

2.從鵝廠實例出發!分析Go Channel底層原理

3.快收藏!最全GO語言實現設計模式【下】

4.如何成為優秀工程師之軟技能篇

閲讀原文