祖傳代碼重構:從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