為了提升搜索推薦系統的整體工程效率和服務質量,搜索推薦工程團隊對系統架構進行了調整。將原本的單一服務架構拆分為多個專門化的獨立模塊,分別為中控服務、召回服務和排序服務。
在新的架構中,中控服務負責統籌協調請求的分發和流量控制,召回服務用于搜索意圖和用戶行為分析并計算返回搜索推薦候選商品集合,而排序服務則進一步對這些候選商品進行精排序,以達到更好的最終展示結果的相關性、點擊率、轉化率等目標。
在推薦系統接入新的排序服務過程中,發現與原有邏輯相比,微詳情頁場景的響應時間顯著增加了大約10毫秒,主要問題出現在接口請求和響應的環節上。
同樣,搜索系統在接入排序服務時也遇到了類似的情況。與原有邏輯相比,響應時間增加了近20毫秒,導致無法達到上線的性能標準。
例如,搜索排序服務在本地執行時的時間為60毫秒,但通過遠程調用時,執行時間卻增加到接近80毫秒,兩者之間的差異接近20毫秒。
為了解決這些性能問題,需要對這些延遲的原因進行深入分析和優化。
問題現象是調用方等待耗時和服務方執行耗時相差較大,所以問題主要出現在遠程調用過程,接下來就是分析這個過程
遠程調用可以理解為一種實現遠程代碼與本地接口調用相一致體驗的開發模式
遠程調用一般通過動態代理實現,通過調用動態方法將調用方法標識和調用參數序列化為字節碼,再通過通信協議請求服務端
服務端解析方法標識和反序列化參數字節碼到實體參數對象,再反射的方式調用方法標識對應的方法
該方法返回結果(即響應對象)序列化,并返回給調用方,調用房完成反序列化響應對象,返回給代理調用者
整個過程較為耗時的部分為序列化/反序列化、網絡IO、本地調用,一般為ms級別。
調用動態方法和反射耗時相對不高,一般為us級別。如下圖所示。
圖片
調用動態方法耗時相比于其他過程一般可忽略不計,服務端本地調用邏輯與原服務已經對齊,也不在本次考慮之內。
接下來就是要分析序列化和網絡IO請求開銷在各環節的占比。選擇skynet來定位序列化和網絡環節的耗時
skynet是公司架構組提供的分布式鏈路追蹤工具,通過鏈路中攜帶的上下文,可以記錄經過的服務接口的日志信息
skynet的基本概念是一次完整請求鏈路稱為trace,一次遠程調用過程稱為span
以推薦微詳情頁某次請求為例,查詢單次請求的調用過程,可以看到下圖所示,排序服務調用過程中的遠程耗時與本地耗時差值約為 4ms
圖片
通過span攜帶的日志,可以看到以下數據
指標 | 耗時 | 說明 |
scf.request.serialize.cost | 0.242ms | 請求序列化耗時 |
scf.request.deserialize.cost | 0.261ms | 請求反序列化耗時 |
scf.response.deserialize.cost | 0.624ms | 響應反序列化耗時 |
scf.response.serialize.cost | 約0.624ms | 響應序列化耗時,skynet未提供,可以認為與反序列化時間差異不大 |
可見,耗時問題主要集中在響應過程階段。如果要計算遠程耗時與本地耗時的差異為20毫秒的開銷情況,可以結合上述日志數據進行線性計算和整理,得出各個過程的近似耗時及其占比,如下圖所示。
搜索與推薦的區別在于,搜索的請求階段不傳輸特征,因此響應過程的耗時占比更高。因此,需要優先考慮減少響應過程的耗時。
圖片
在整個響應過程中,序列化和網絡 I/O 的耗時各占一半。影響 I/O 耗時的因素之一是網絡環境和機器配置,另一個因素是序列化后對象的長度。
通過與運維團隊溝通,我們了解到部分機器使用的是千兆網卡,而我們傳輸的對象長度通常都在 MB 級別,這對耗時有一定的影響。
網絡問題可以統一整理后提交給運維團隊調整機器配置來解決,而我們的主要精力應放在優化序列化過程上。
接下來是對響應對象的序列化過程分析
首先需要了解響應對象數據結構,響應對象是一個泛型類,以支持不同類型的ID,如下所示,包括:
主要存儲約500長度的RankResultItem列表,每個Item對象需要返回商品ID,并以Map的形式返回模型日志、模型打分結果及部分特征
請求攜帶的其他信息,如A/B測試實際命中分組名的集合等等
如以下代碼所示
class RankResponse<T> { int status; RankResult<T> result; String errorMsg;} class RankResult<T> { List<RankResultItem<T>> items; Map<String, Object> others;} class RankResultItem<T> { T id; Map<String, Object> features; // 回傳特征 Map<String, String> metric; // 模型日志 Map<String, Double> results; // 模型結果}
優化前,搜索排序服務采用了架構組提供的 SCFV4 序列化方法。
SCFV4 是一種最終輸出字節碼的序列化方式,它會對所有被 @SCFSerializable 注解標注的業務數據傳輸對象預編譯序列化和反序列化方法。對于 Java 的基礎類(如 List、Map 等)和基本類型(如 Integer 等),SCFV4 也預設了相應的序列化和反序列化方法。
在序列化執行時,SCFV4 根據對象的數據結構層次逐層遍歷,調用每個子成員對象的序列化方法,最終輸出字節碼。反序列化的過程與之類似。
SCFV4 序列化的特點如下:
下面回到排序響應對象,分析響應對象的序列化過程,將過程梳理到下圖:
圖片
可以看到,一次序列化過程可能需要對多達 500 次的商品日志 Map、商品得分 Map 和商品 ID 進行序列化。
由于日志對象的數據規模遠大于其他類型的對象,因此我們可以假設,序列化的主要開銷來自于序列化特征日志 Map 的耗時。
在相關的 MapSerializer 類中可以發現,序列化 Map 時不僅需要解析 Key-Value 的數據類型,還大量調用了 String.getBytes() 方法。
假設特征日志的總長度約為 1MB,在本地測試中,getBytes 方法的耗時大約為 10 毫秒,這與我們的預期一致。因此,我們的優化思路應重點放在優化特征日志的序列化過程中。
首先想到的優化方案是通過不傳輸日志來完全節省日志的序列化時間。針對這一思路,有兩種具體的實現方式:
如果直接打印日志,每個請求最多需要打印 1000 條日志。經過與數據團隊的討論,我們得出了以下結論:
經過分析,我們認為改用直接打印日志的方案并不是最優選擇,因此沒有實施。
如果將日志異步寫入Redis,并在重排序時從Redis中讀取,就能有效地優化性能。通過設置日志緩存的過期時間為 1 秒,可以滿足排序到重排序之間的時間間隔要求。
在這種情況下,Redis的預估使用量為:每請求日志大小 × QPS × 過期時間 = 2MB × 500 × 1秒 = 1GB。
由于成本相對可控,因此我們決定嘗試這一方法。
圖片
如上圖所示,本次方案的目標是將紅色部分的輸入特征日志處理邏輯提前到模型輸入特征處理之后,并引入 Redis 緩存邏輯,同時實現異步執行。
然而,這里遇到了一個挑戰:輸入特征集合是由預測框架生成的,其生成時間點只有框架內部知道。因此,日志處理過程必須在預測框架內部執行。
在深入討論之前,先介紹一下預測框架和排序框架之間的關系。預測框架的主要職責是管理和執行算法模型,它生成模型所需的輸入特征,并基于這些特征進行預測。排序框架則利用預測框架的輸出,對商品或內容進行排序,以優化最終展示給用戶的結果。
雖然預測框架和排序框架在功能上是相互獨立的,但它們之間密切合作。排序框架依賴預測框架提供的預測結果,而預測框架則處理來自排序框架的輸入特征。然而,預測框架的主要任務是執行算法模型,與具體的業務邏輯無關。因此,在預測框架中引入排序框架的業務邏輯會導致相互依賴,這違背了各自的設計初衷,也不利于系統的可維護性和擴展性。
因此,我們需要一種方式來解耦兩者,實現日志處理邏輯的同時,不破壞架構設計。
如果在預測框架內部執行日志處理,就需要將排序商品列表、排序上下文、日志處理插件、線程池、Redis 客戶端對象、過期時間配置等所有組件都傳遞到預測框架中。這將導致排序框架與預測框架的相互依賴,而這種雙向依賴是不合理的,因為預測框架不僅服務于排序框架,還用于通用推薦和定價框架。
為了解決這個問題,我們可以通過傳遞 Consumer 對象來實現解耦。預測框架作為模型輸入特征的生產者,排序框架作為消費者。具體來說,排序框架可以實現一個指定日志處理邏輯的 Consumer 接口。當預測框架生成輸入特征集合后,調用 accept 方法來完成日志處理。
為了便于異步處理,我們在排序框架中定義了一個 IFutureConsumer 接口,該接口支持獲取 Future 方法,用于在排序框架中等待日志處理完成。同時,預測框架接收到的仍然是一個標準的 Consumer 接口對象。
這種方法確保了預測框架和排序框架之間的解耦,明確了各自的職責,避免了雙向依賴,使系統更加靈活和可擴展。
interface IFutureConsumer<T> extend Consumer<T> { // accpet(T t) Future<?> getFuture();}
預測框架生成輸入特征并傳遞給排序框架。
// 預測框架生成輸入特征T inputFeatures = ...;// 調用排序框架的日志處理邏輯IFutureConsumer<T> consumer = ...; // 由排序框架提供consumer.accept(inputFeatures);
排序框架處理日志。
public class HandleLogConsumer<T> implements IFutureConsumer<T> { private Future<?> future; @Override public void accept(T t) { // 異步處理日志邏輯 this.future = executorService.submit(() -> { // 處理日志邏輯 }); } @Override public Future<?> getFuture() { return this.future; }}
整個過程如下圖所示:
另外本方案使用了Redis的哈希(hash)數據結構進行存儲,原因是在搜索服務中,每次請求都需要刷新結果緩存,這也意味著需要同時刷新相關的日志緩存。然而,搜索服務并不知道具體有哪些 infoid 已經被存儲,如果使用字符串(string)結構來存儲這些日志數據,很難做到全量刷新,因為無法有效地管理和定位所有存儲的鍵值對。
相比之下,使用哈希(hash)結構存儲日志信息有明顯的優勢。我們可以使用 ctr、cvr、info 等作為哈希表的關鍵字前綴,并以請求的 MD5 值作為后綴。這種方式只需要刷新 2~3 個哈希鍵(key),就可以覆蓋所有相關的日志數據。這種方法不僅簡化了緩存刷新操作,而且更高效,因為只需操作少量的哈希鍵即可完成全量刷新,適合搜索服務的需求。
推薦側在找靚機微詳情頁場景先行接入了此方案,額外耗時由14ms優化至4ms
但隨后發現了兩個問題:
隨著首頁推薦等主要場景的接入,后處理過程中獲取日志過程耗時達到了7ms以上,原因是寫qps較高(單redis-server節點近7w qps),日志數據又屬于bigkey(1k以上),redis-server極易發生阻塞,擴容后仍在3ms左右水平搜索測試耗時并未明顯下降,讀取和刷緩存過期時間也帶來了額外的成本。
總結
在本地打印日志和緩存日志的方案不可行后,我們只能重新考慮通過響應對象將日志數據傳回調用方的方案。以下是幾種可行的思路:
經過評估,前兩種方案雖然具有一定的可行性,但需要調用方進行較多的開發支持,實施周期較長。此外,這些方案還需考慮更復雜的容災處理設計,例如應對因重啟或超時導致的日志丟失,以及緩存引起的 GC 問題。為了規避這些風險,我們決定嘗試第三種方案。
為了能實現僅在需要時,即取商品列表topN并打印后端日志時,才將日志從bytes轉回String,在響應RankResultItem增加了LazyMetric數據類型,利用延遲加載機制,減少了不必要的數據處理和傳輸開銷,數據結構如下:
class RankResultItem { Map<String, LazyMetric> lazyMetricMap;} class LazyMetric { byte[] data; // 編碼后的字符串數據 byte compressMethodCode; // 壓縮類型 LazyMetric(String str){ // string2bytes } String toString() { // bytes2string }}
日志生產及獲取過程調整為:
圖片
可以看出,String 轉 bytes 的編碼過程耗時已經在模型執行時并行處理中被優化掉了。在從模型預測模塊到 topN 節點的整個執行過程中,系統始終攜帶的是 bytes 類型的數據。只有在 topN 節點完成了所有搜索推薦流程需要準備返回商品時,才會主動調用 toString 方法將 bytes 轉回 String,而其他商品的日志數據則會被直接丟棄。這樣一來,decode 的次數從 500 次減少到了 10 次(假設 N 一般為 10)。整個過程對業務側的集成并不復雜,開啟功能后,排序框架就會自動將日志數據轉存到 LazyMetricMap 中。中控服務隨后可以從每個 Item 的 LazyMetricMap 中取出 LazyMetric 對象,并在合適的時機調用 toString 方法,提升搜索推薦業務整體開發效率。壓縮過程選擇了java自帶的gzip和zlib兩種方法進行測試,測試結果如下:
方法 | 序列化時間 | 反序列化時間 | 總時間 | 數據大小 (bytes) | 壓縮比率 |
【SCFV4】原方法 | 1.70ms | 1.28ms | 2.98ms | 1,192,564 | 0.8336 |
【SCFV4】metric日志直接轉bytes傳輸 | 1.46ms | 0.74ms | 2.20ms | 1,216,565 | 0.8504 |
【SCFV4】metric日志zlib轉bytes傳輸 | 1.15ms | 0.73ms | 1.88ms | 472,065 | 0.3300 |
【SCFV4】metric日志gzip轉bytes傳輸 | 1.30ms | 0.77ms | 2.07ms | 490,065 | 0.3425 |
【Hessian】原方法 | 2.33ms | 3.45ms | 5.78ms | 1,165,830 | 0.8149 |
【Hessian】metric日志直接轉bytes傳輸 | 1.00ms | 3.80ms | 4.80ms | 1,168,143 | 0.8165 |
【Hessian】metric日志zlib轉bytes傳輸 | 0.61ms | 1.46ms | 2.07ms | 422,513 | 0.2953 |
【Hessian】metric日志gzip轉bytes傳輸 | 0.59ms | 1.44ms | 2.03ms | 440,509 | 0.3079 |
可以看到,在不同的序列化方法下,序列化耗時都有所減少,性能最高提升至原來的 35%,序列化后的數據量也減少到原來的 36%,這也預示著網絡 I/O 的開銷會有所下降。
接入情況:
總結:
根據對 V2 方案的總結,V3 方案的設計原則是:放棄使用 SCF 的通用對象序列化,RPC 層僅通過字節數組進行交互,而排序框架采用自定義的序列化方法。
思路一:繼續嘗試接入現有的開源序列化框架,并在此基礎上對排序響應對象進行定制化開發。常見的開源項目包括 protobuf、Kryo、Hessian 等。
思路二:自行開發專門適用于排序響應對象的序列化方法。
思路一的優勢在于安全性、通用性和高性能方面都表現良好,部分框架也提供一定的定制化能力。然而,這類框架通常為了適應多種業務場景,會包含大量通用代碼和復雜邏輯。以 Kryo 為例,其項目代碼行數超過 2 萬行,這使得短期內很難掌握所有細節,一旦出現問題可能會阻礙開發進度,并且不一定能按期解決序列化問題。不過,開源框架技術成熟,適合作為長期方案。
思路二的優勢在于既可以借鑒其他框架的優化策略,又可以低成本地針對特定對象進行定制優化,從而實現更高的序列化效率。雖然在安全性方面,需要通過單元測試來保障,但開發一個針對特定應用場景的序列化方法相對簡單??紤]到排序框架接口的參數對象不經常更改,這種方法可以做到一次開發、長期受益。因此,我們傾向于選擇思路二。
整理思路后,序列化開發可以按照以下步驟進行:
定義字節數組的序列化數據結構
定義序列化接口并實現具體的序列化類:
定義序列化過程的數據緩沖類:
實現各對象的具體序列化方法:
序列化結果最終要存儲在字節數組(byte[])中,因此定義如何存儲是我們的首要任務。
一個排序對象包含許多內容。為了簡化存儲過程并便于編寫代碼,我們采用了一種類似樹狀的存儲結構,與其他序列化方式大致相同。這種結構將排序對象的整體作為根節點,然后按照對象的層次結構逐級展開存儲。
與其他序列化方式不同的是,我們考慮到排序過程中對所有商品都會執行相同的操作,因此商品類的特征 Map、結果 Map 和日志 Map 的存儲鍵集合在實際應用中是保持一致的。由于這些鍵是可以復用的,我們將其提取出來并統一存儲在 items_common 中。這樣一來,Map 的值可以按照固定的順序進行鏈式存儲,這種方法不僅節省了空間,還提升了存儲效率。
為了進一步降低代碼復雜度,還需要定義統一的接口,再將各個成員序列化過程分解到多個具體實現類中
自定義序列化方法接口定義如下:
public interface IRankObjSerializer<T> { int estimateUsage(T obj, RankObjSerializeContext context); void serialize(T obj, RankObjSerializeContext context) throws Exception; T deserialize(RankObjDeserializeContext context) throws Exception;}
方法的含義如下:
estimateUsage:快速評估序列化對象的長度。
serialize:用于序列化對象。
deserialize:用于反序列化對象。
通過這些方法,可以更有效地管理對象的序列化和反序列化過程,提升整體性能和資源利用率。
根據響應對象的數據層次,序列化過程需要針對不同的類型進行拆解,并為每種具體類型設計相應的序列化類。以下是各類序列化器的設計:
GeneralObjSerializer:
GeneralMapSerializer(用于基本 Map 類型,按順序存儲鍵值對):
GeneralListSerializer(
GeneralSetSerializer:
RankResultSerializer:
RankResultItemSerializer:
RankResponseSerializer:
序列化過程中依次將寫入到一段足夠長的byte數組里,序列化完成時再一次性讀出所有寫入數據,定義Output類作為序列化過程中的數據緩沖(同樣有Input類作用于反序列化,實現類似)
class Output { byte[] data; int offset; Output(int estimateUsage) { data = new byte[estimateUsage]; offset = 0; } void writeInt(int); void writeLong(long); void writeFloat(float); void writeBytes(byte[]); ...}
data:作為序列化數據的緩沖區,為了寫入效率最高,緩沖區是連續且足夠長的byte數組,足夠長由入參estimateUsage來保證
offset:是下一個要寫入數據的位置,如果offset >= 數組長度,則需要擴容,擴容每次按兩倍擴容
estimateUsage的準確性影響了擴容次數,進而影響序列化效率,經測試從以32為起始容量初始化并逐漸擴容到所需容量與直接使用estimateUsage初始化,序列化耗時相差20%左右
writeInt、writeLong:整型和長整型的寫入是可變長的,雖然int和long分別使用了32bit和64bit的空間,但如1、2、8、64等較小的數字只是用了前8bit的空間,一般可變長序列化采取的做法是將每8bit為一組,低7位存儲真實數據,高位存儲標識符,表明更高位是否仍存在更多數據,可變長編碼下整型需要1~5byte,長整型則需要1~10byte,存儲數字值越小時,可變長的壓縮效果越好。讀取時再從低位依次向高位讀取,直到標識符表明數據讀取完畢,當緩沖區剩余長度不足可變長的最大長度時,需要調用readInt_slow或readLong_slow方法,逐個byte讀取并判斷是否越界
writeFloat、writeDouble:這兩種類型不能直接寫入,需要調用Float.floatToRawIntBits和Double.doubleToRawLongBits轉為Integer型和Long型。我們的特征由于特征默認值等原因存在大量0.0、-1.0、1.0等數值,但在可變長存儲下,轉int后實際占用位數很長,優化方式是轉換前先判斷了它是否為整型數字,如是整型就取整后直接存為整型,可將原本需要5~10位的存儲空間節省到1位,一個較為快速的判斷方式為:
void checkDoubleIsIntegerValue(double d) { return ((long)d == d);}
多數序列化實現按待序列化的各個成員類型依次調用對應序列化方法即可
Item間的共享數據處理,是本次序列化優化最核心的優化點,對序列化效率提升有決定性影響,如特征/結果/日志Map的keySet的存儲復用,具體做法是
讀取第一個Item的所有keySet并保存在序列化上下文中,作為基準數據,后續每個Item都與第一個的keySet判斷,完全相同就按第一個item的相同順序將values依次取出,按隊列存儲,快速的判斷方式如下:
private static boolean isNotEqualSet(Set<String> set1, Set<String> set2) { return set1 == null || set2 == null || (set1.size() != set2.size()) || !set1.containsAll(set2); }
當任意商品不滿足keySet一致性的要求時,Item序列化方法會向上拋出異常,排序框架會捕獲到該異常,并將返回的壓縮響應對象(CompressedRankResponse)退化為普通響應對象(RankResponse)
異常行為會根據用戶的選擇上報給監控平臺,或需要排查問題時選擇打印到本地文件
上游服務無需關心排序服務返回了哪種響應對象,這是因為普通響應對象和序列化后的壓縮響應對象實現了同一接口
即原RankResponse對象和新CompressedRankResponse對象實現了IRankResponse接口,CompressedRankResponse是RankResponse的裝飾器對象
CompressedRankResponse對象在用戶調用任意方法,且當內置RankResponse對象為空時完成反序列化,如下段代碼中的getStatus方式所示
后續再調用其他方法在使用體驗上是與未壓縮對象一致的,這種與直接返回byte數組相比,業務使用更友好,異常時可以快速降級,也沒有太多帶來額外成本
IRankResponse rank(RankRequest request); class RankResponse implements IRankResponse; class CompressedRankResponse implements IRankResponse { byte[] bytes; // 排序服務返回的數據 RankResponse response = null; // 調用任意方法后反序列化生成的數據 public int getStatus() { if(response == null) { // 執行反序列化 response = this.doDeserilize(); } return response.getStatus(); }}
模擬搜索500個商品,測試2000次,序列化前大小1430640
第一次測試:實驗組為V3優化,對照組為無優化
方法 | 序列化時間 | 反序列化時間 | 總時間 | 數據大小 (bytes) | 壓縮比率 |
SCFV4 序列化原方法 | 1.86ms | 1.19ms | 3.05ms | 1,188,961 | 0.8310 |
框架自定義序列化方法 | 0.32ms | 0.17ms | 0.49ms | 392,127 | 0.2741 |
優化降低 | 82.80% | 85.71% | 83.93% | 67.02% | 67.02% |
第二次測試:實驗組為V3優化,對照組為V2優化
方法 | 序列化時間 | 反序列化時間 | 總時間 | 數據大小 (bytes) | 壓縮比率 |
框架自定義序列化方法 | 0.41ms | 0.20ms | 0.61ms | 393,001 | 0.274 |
帶日志 byte 壓縮 + SCF 序列化方法 | 3.10ms | 1.00ms | 4.10ms | 503,961 | 0.35 |
優化降低 | - | - | 85.12% | 21.7% | 21.7% |
可見V3對序列化過程的執行效率提升明顯
以下是業務接入情況
搜索側接入:測試接入排序服務耗時于未服務化時持平,滿足上線要求
推薦測接入:序列化過程在2ms左右完成,場景接入后耗時均有明顯下降,符合預期
場景 | 優化前 | 優化后 | 提升時間 | 百分比 |
找靚機微詳情頁推薦 | 69ms | 64ms | 5ms | 7.24% |
轉轉 B2C 詳情頁 | 97ms | 94ms | 3ms | 3.09% |
轉轉 C2C 詳情頁 | 92ms | 87ms | 5ms | 5.43% |
轉轉首頁推薦 3C 頁 | 112ms | 106ms | 6ms | 5.36% |
轉轉首頁推薦默認 | 130ms | 128ms | 2ms | 1.54% |
本項目旨在解決搜索推薦服務化過程中因日志傳輸引起的序列化額外耗時問題。經過三次版本迭代和測試,最終方案成功落地。
結論
本地測試:
思考
從問題發現到解決上線,項目歷時近一個月。雖然問題定位較為迅速,但在確定最終方案和落地時經歷了較長的周期。方案設計過程中有兩點需要注意:
方案評估要更細致:
方案設計要更具全局性:
后續工作
廢棄遺留代碼:
召回框架的序列化優化:
本文鏈接:http://www.tebozhan.com/showinfo-26-112793-0.html轉轉搜推排序服務的響應對象序列化優化
聲明:本網頁內容旨在傳播知識,若有侵權等問題請及時與本網聯系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
上一篇: 我嘗試重現 React 的 useState() Hook 并失去了工作機會
下一篇: 微服務為什么要容器化?