祖傳程式碼重構:從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.如何成為優秀工程師之軟技能篇

閱讀原文