祖傳程式碼重構:從25萬行到5萬行的血淚史
點個關注👆跟騰訊工程師學技術
導語| 近期,我們接管並重構了十多年前的 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)
騰訊工程師技術乾貨直達:
- ChatGPT深度解析:GPT家族進化史
- 微信全文搜尋耗時降94%?我們用了這種方案
- 十問ChatGPT:一個新的時代正拉開序幕
- 對標ChatGPT,新AI助手Claude來了
- 國民應用QQ如何實現高可用的訂閱推送系統
- 一夜爆火的現象級產品ChatGPT,是AI突破還是曇花乍現?
- 元宇宙,會成為下一代網際網路的主場嗎?
- 7天DAU超億級,《羊了個羊》技術架構升級實戰
- 國民級應用:微信是如何防止崩潰的?
- 由淺入深讀透vue原始碼:diff演算法
- 3小時!開發ChatGPT微信小程式
- 祖傳程式碼重構:從25萬行到5萬行的血淚史
- H5開屏從龜速到閃電,企微是如何做到的
- AI繪畫火了!一文看懂背後技術原理
- 快收藏!超強圖解Docker常見命令與實戰!
- Cloud Studio高階玩家:強大的YAML模板
- C 20協程學習
- 打造更安全的視訊加密,雲點播版權保護實踐
- 深入淺出帶你走進Redis!
- 【技思廣益 · 騰訊技術人原創集】雙週優秀作品回顧vol.03