轉(zhuǎn)轉(zhuǎn)搜推排序服務(wù)的響應(yīng)對(duì)象序列化優(yōu)化
1 優(yōu)化背景
為了提升搜索推薦系統(tǒng)的整體工程效率和服務(wù)質(zhì)量,搜索推薦工程團(tuán)隊(duì)對(duì)系統(tǒng)架構(gòu)進(jìn)行了調(diào)整。將原本的單一服務(wù)架構(gòu)拆分為多個(gè)專門化的獨(dú)立模塊,分別為中控服務(wù)、召回服務(wù)和排序服務(wù)。
在新的架構(gòu)中,中控服務(wù)負(fù)責(zé)統(tǒng)籌協(xié)調(diào)請(qǐng)求的分發(fā)和流量控制,召回服務(wù)用于搜索意圖和用戶行為分析并計(jì)算返回搜索推薦候選商品集合,而排序服務(wù)則進(jìn)一步對(duì)這些候選商品進(jìn)行精排序,以達(dá)到更好的最終展示結(jié)果的相關(guān)性、點(diǎn)擊率、轉(zhuǎn)化率等目標(biāo)。
在推薦系統(tǒng)接入新的排序服務(wù)過(guò)程中,發(fā)現(xiàn)與原有邏輯相比,微詳情頁(yè)場(chǎng)景的響應(yīng)時(shí)間顯著增加了大約10毫秒,主要問(wèn)題出現(xiàn)在接口請(qǐng)求和響應(yīng)的環(huán)節(jié)上。
同樣,搜索系統(tǒng)在接入排序服務(wù)時(shí)也遇到了類似的情況。與原有邏輯相比,響應(yīng)時(shí)間增加了近20毫秒,導(dǎo)致無(wú)法達(dá)到上線的性能標(biāo)準(zhǔn)。
例如,搜索排序服務(wù)在本地執(zhí)行時(shí)的時(shí)間為60毫秒,但通過(guò)遠(yuǎn)程調(diào)用時(shí),執(zhí)行時(shí)間卻增加到接近80毫秒,兩者之間的差異接近20毫秒。
為了解決這些性能問(wèn)題,需要對(duì)這些延遲的原因進(jìn)行深入分析和優(yōu)化。
2 問(wèn)題分析
問(wèn)題現(xiàn)象是調(diào)用方等待耗時(shí)和服務(wù)方執(zhí)行耗時(shí)相差較大,所以問(wèn)題主要出現(xiàn)在遠(yuǎn)程調(diào)用過(guò)程,接下來(lái)就是分析這個(gè)過(guò)程
遠(yuǎn)程調(diào)用可以理解為一種實(shí)現(xiàn)遠(yuǎn)程代碼與本地接口調(diào)用相一致體驗(yàn)的開(kāi)發(fā)模式
遠(yuǎn)程調(diào)用一般通過(guò)動(dòng)態(tài)代理實(shí)現(xiàn),通過(guò)調(diào)用動(dòng)態(tài)方法將調(diào)用方法標(biāo)識(shí)和調(diào)用參數(shù)序列化為字節(jié)碼,再通過(guò)通信協(xié)議請(qǐng)求服務(wù)端
服務(wù)端解析方法標(biāo)識(shí)和反序列化參數(shù)字節(jié)碼到實(shí)體參數(shù)對(duì)象,再反射的方式調(diào)用方法標(biāo)識(shí)對(duì)應(yīng)的方法
該方法返回結(jié)果(即響應(yīng)對(duì)象)序列化,并返回給調(diào)用方,調(diào)用房完成反序列化響應(yīng)對(duì)象,返回給代理調(diào)用者
整個(gè)過(guò)程較為耗時(shí)的部分為序列化/反序列化、網(wǎng)絡(luò)IO、本地調(diào)用,一般為ms級(jí)別。
調(diào)用動(dòng)態(tài)方法和反射耗時(shí)相對(duì)不高,一般為us級(jí)別。如下圖所示。
圖片
調(diào)用動(dòng)態(tài)方法耗時(shí)相比于其他過(guò)程一般可忽略不計(jì),服務(wù)端本地調(diào)用邏輯與原服務(wù)已經(jīng)對(duì)齊,也不在本次考慮之內(nèi)。
接下來(lái)就是要分析序列化和網(wǎng)絡(luò)IO請(qǐng)求開(kāi)銷在各環(huán)節(jié)的占比。選擇skynet來(lái)定位序列化和網(wǎng)絡(luò)環(huán)節(jié)的耗時(shí)
skynet是公司架構(gòu)組提供的分布式鏈路追蹤工具,通過(guò)鏈路中攜帶的上下文,可以記錄經(jīng)過(guò)的服務(wù)接口的日志信息
skynet的基本概念是一次完整請(qǐng)求鏈路稱為trace,一次遠(yuǎn)程調(diào)用過(guò)程稱為span
以推薦微詳情頁(yè)某次請(qǐng)求為例,查詢單次請(qǐng)求的調(diào)用過(guò)程,可以看到下圖所示,排序服務(wù)調(diào)用過(guò)程中的遠(yuǎn)程耗時(shí)與本地耗時(shí)差值約為 4ms
圖片
通過(guò)span攜帶的日志,可以看到以下數(shù)據(jù)
指標(biāo) | 耗時(shí) | 說(shuō)明 |
scf.request.serialize.cost | 0.242ms | 請(qǐng)求序列化耗時(shí) |
scf.request.deserialize.cost | 0.261ms | 請(qǐng)求反序列化耗時(shí) |
scf.response.deserialize.cost | 0.624ms | 響應(yīng)反序列化耗時(shí) |
scf.response.serialize.cost | 約0.624ms | 響應(yīng)序列化耗時(shí),skynet未提供,可以認(rèn)為與反序列化時(shí)間差異不大 |
可見(jiàn),耗時(shí)問(wèn)題主要集中在響應(yīng)過(guò)程階段。如果要計(jì)算遠(yuǎn)程耗時(shí)與本地耗時(shí)的差異為20毫秒的開(kāi)銷情況,可以結(jié)合上述日志數(shù)據(jù)進(jìn)行線性計(jì)算和整理,得出各個(gè)過(guò)程的近似耗時(shí)及其占比,如下圖所示。
搜索與推薦的區(qū)別在于,搜索的請(qǐng)求階段不傳輸特征,因此響應(yīng)過(guò)程的耗時(shí)占比更高。因此,需要優(yōu)先考慮減少響應(yīng)過(guò)程的耗時(shí)。
圖片
在整個(gè)響應(yīng)過(guò)程中,序列化和網(wǎng)絡(luò) I/O 的耗時(shí)各占一半。影響 I/O 耗時(shí)的因素之一是網(wǎng)絡(luò)環(huán)境和機(jī)器配置,另一個(gè)因素是序列化后對(duì)象的長(zhǎng)度。
通過(guò)與運(yùn)維團(tuán)隊(duì)溝通,我們了解到部分機(jī)器使用的是千兆網(wǎng)卡,而我們傳輸?shù)膶?duì)象長(zhǎng)度通常都在 MB 級(jí)別,這對(duì)耗時(shí)有一定的影響。
網(wǎng)絡(luò)問(wèn)題可以統(tǒng)一整理后提交給運(yùn)維團(tuán)隊(duì)調(diào)整機(jī)器配置來(lái)解決,而我們的主要精力應(yīng)放在優(yōu)化序列化過(guò)程上。
接下來(lái)是對(duì)響應(yīng)對(duì)象的序列化過(guò)程分析
首先需要了解響應(yīng)對(duì)象數(shù)據(jù)結(jié)構(gòu),響應(yīng)對(duì)象是一個(gè)泛型類,以支持不同類型的ID,如下所示,包括:
- 狀態(tài)值,判斷結(jié)果正?;虍惓?/li>
- RankResult對(duì)象
主要存儲(chǔ)約500長(zhǎng)度的RankResultItem列表,每個(gè)Item對(duì)象需要返回商品ID,并以Map的形式返回模型日志、模型打分結(jié)果及部分特征
請(qǐng)求攜帶的其他信息,如A/B測(cè)試實(shí)際命中分組名的集合等等
- 異常日志
如以下代碼所示
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; // 模型結(jié)果
}
優(yōu)化前,搜索排序服務(wù)采用了架構(gòu)組提供的 SCFV4 序列化方法。
SCFV4 是一種最終輸出字節(jié)碼的序列化方式,它會(huì)對(duì)所有被 @SCFSerializable 注解標(biāo)注的業(yè)務(wù)數(shù)據(jù)傳輸對(duì)象預(yù)編譯序列化和反序列化方法。對(duì)于 Java 的基礎(chǔ)類(如 List、Map 等)和基本類型(如 Integer 等),SCFV4 也預(yù)設(shè)了相應(yīng)的序列化和反序列化方法。
在序列化執(zhí)行時(shí),SCFV4 根據(jù)對(duì)象的數(shù)據(jù)結(jié)構(gòu)層次逐層遍歷,調(diào)用每個(gè)子成員對(duì)象的序列化方法,最終輸出字節(jié)碼。反序列化的過(guò)程與之類似。
SCFV4 序列化的特點(diǎn)如下:
- 與 JSON 相比,反序列化后的類型較為安全,但不能完全保證類型一致性。例如,不論輸入的 Map 是何種類型,反序列化后都會(huì)變?yōu)?HashMap。
- 對(duì)泛型類型和基本類型的序列化過(guò)程進(jìn)行了優(yōu)化,對(duì)于數(shù)據(jù)大小在 KB 級(jí)別的對(duì)象,性能表現(xiàn)良好。
- 作為一種與SCF框架緊密集成的序列化方法,能夠與框架中的其他模塊無(wú)縫協(xié)作,從而簡(jiǎn)化開(kāi)發(fā)流程,減少開(kāi)發(fā)者的工作量。
- 在代碼編寫(xiě)時(shí),需要通過(guò)注解的方式對(duì)序列化對(duì)象類進(jìn)行預(yù)設(shè)定,版本只能向后兼容。
- 有時(shí)存在異常處理不夠友好的問(wèn)題,異常信息無(wú)法直接反映業(yè)務(wù)代碼中的問(wèn)題,定位序列化問(wèn)題時(shí),往往需要依賴經(jīng)驗(yàn)進(jìn)行判斷。
下面回到排序響應(yīng)對(duì)象,分析響應(yīng)對(duì)象的序列化過(guò)程,將過(guò)程梳理到下圖:
圖片
可以看到,一次序列化過(guò)程可能需要對(duì)多達(dá) 500 次的商品日志 Map、商品得分 Map 和商品 ID 進(jìn)行序列化。
由于日志對(duì)象的數(shù)據(jù)規(guī)模遠(yuǎn)大于其他類型的對(duì)象,因此我們可以假設(shè),序列化的主要開(kāi)銷來(lái)自于序列化特征日志 Map 的耗時(shí)。
在相關(guān)的 MapSerializer 類中可以發(fā)現(xiàn),序列化 Map 時(shí)不僅需要解析 Key-Value 的數(shù)據(jù)類型,還大量調(diào)用了 String.getBytes() 方法。
假設(shè)特征日志的總長(zhǎng)度約為 1MB,在本地測(cè)試中,getBytes 方法的耗時(shí)大約為 10 毫秒,這與我們的預(yù)期一致。因此,我們的優(yōu)化思路應(yīng)重點(diǎn)放在優(yōu)化特征日志的序列化過(guò)程中。
3 設(shè)計(jì)方案
3.1 優(yōu)化方案一
首先想到的優(yōu)化方案是通過(guò)不傳輸日志來(lái)完全節(jié)省日志的序列化時(shí)間。針對(duì)這一思路,有兩種具體的實(shí)現(xiàn)方式:
- 在排序服務(wù)中直接打印日志,并由排序服務(wù)直接將日志上報(bào)到 Kafka。
- 使用 Redis 緩存日志,從而減少序列化和傳輸?shù)拈_(kāi)銷。
如果直接打印日志,每個(gè)請(qǐng)求最多需要打印 1000 條日志。經(jīng)過(guò)與數(shù)據(jù)團(tuán)隊(duì)的討論,我們得出了以下結(jié)論:
- 數(shù)據(jù)采集:直接打印日志將導(dǎo)致每天的日志量達(dá)到約 15TB。以 15 臺(tái)機(jī)器的集群計(jì)算,每分鐘需要采集 1GB 的數(shù)據(jù),這會(huì)給日志采集系統(tǒng)和我們的服務(wù) I/O 帶來(lái)巨大壓力。
- 數(shù)據(jù)存儲(chǔ):每天新增的數(shù)據(jù)量將達(dá)到 50-60TB。由于每天生成的 15TB 數(shù)據(jù)需要先采集再清洗,這樣就會(huì)產(chǎn)生兩份數(shù)據(jù)。每份數(shù)據(jù)有 3 個(gè)副本,總共是 6 份數(shù)據(jù)。最終在 Hadoop 集群中還會(huì)有 2 份備份,約 45TB。即使使用 Hive 表和 Parquet+GZ 格式壓縮,數(shù)據(jù)量也大約在 5-10TB 之間。
經(jīng)過(guò)分析,我們認(rèn)為改用直接打印日志的方案并不是最優(yōu)選擇,因此沒(méi)有實(shí)施。
如果將日志異步寫(xiě)入Redis,并在重排序時(shí)從Redis中讀取,就能有效地優(yōu)化性能。通過(guò)設(shè)置日志緩存的過(guò)期時(shí)間為 1 秒,可以滿足排序到重排序之間的時(shí)間間隔要求。
在這種情況下,Redis的預(yù)估使用量為:每請(qǐng)求日志大小 × QPS × 過(guò)期時(shí)間 = 2MB × 500 × 1秒 = 1GB。
由于成本相對(duì)可控,因此我們決定嘗試這一方法。
圖片
如上圖所示,本次方案的目標(biāo)是將紅色部分的輸入特征日志處理邏輯提前到模型輸入特征處理之后,并引入 Redis 緩存邏輯,同時(shí)實(shí)現(xiàn)異步執(zhí)行。
然而,這里遇到了一個(gè)挑戰(zhàn):輸入特征集合是由預(yù)測(cè)框架生成的,其生成時(shí)間點(diǎn)只有框架內(nèi)部知道。因此,日志處理過(guò)程必須在預(yù)測(cè)框架內(nèi)部執(zhí)行。
在深入討論之前,先介紹一下預(yù)測(cè)框架和排序框架之間的關(guān)系。預(yù)測(cè)框架的主要職責(zé)是管理和執(zhí)行算法模型,它生成模型所需的輸入特征,并基于這些特征進(jìn)行預(yù)測(cè)。排序框架則利用預(yù)測(cè)框架的輸出,對(duì)商品或內(nèi)容進(jìn)行排序,以優(yōu)化最終展示給用戶的結(jié)果。
雖然預(yù)測(cè)框架和排序框架在功能上是相互獨(dú)立的,但它們之間密切合作。排序框架依賴預(yù)測(cè)框架提供的預(yù)測(cè)結(jié)果,而預(yù)測(cè)框架則處理來(lái)自排序框架的輸入特征。然而,預(yù)測(cè)框架的主要任務(wù)是執(zhí)行算法模型,與具體的業(yè)務(wù)邏輯無(wú)關(guān)。因此,在預(yù)測(cè)框架中引入排序框架的業(yè)務(wù)邏輯會(huì)導(dǎo)致相互依賴,這違背了各自的設(shè)計(jì)初衷,也不利于系統(tǒng)的可維護(hù)性和擴(kuò)展性。
因此,我們需要一種方式來(lái)解耦兩者,實(shí)現(xiàn)日志處理邏輯的同時(shí),不破壞架構(gòu)設(shè)計(jì)。
如果在預(yù)測(cè)框架內(nèi)部執(zhí)行日志處理,就需要將排序商品列表、排序上下文、日志處理插件、線程池、Redis 客戶端對(duì)象、過(guò)期時(shí)間配置等所有組件都傳遞到預(yù)測(cè)框架中。這將導(dǎo)致排序框架與預(yù)測(cè)框架的相互依賴,而這種雙向依賴是不合理的,因?yàn)轭A(yù)測(cè)框架不僅服務(wù)于排序框架,還用于通用推薦和定價(jià)框架。
為了解決這個(gè)問(wèn)題,我們可以通過(guò)傳遞 Consumer 對(duì)象來(lái)實(shí)現(xiàn)解耦。預(yù)測(cè)框架作為模型輸入特征的生產(chǎn)者,排序框架作為消費(fèi)者。具體來(lái)說(shuō),排序框架可以實(shí)現(xiàn)一個(gè)指定日志處理邏輯的 Consumer 接口。當(dāng)預(yù)測(cè)框架生成輸入特征集合后,調(diào)用 accept 方法來(lái)完成日志處理。
為了便于異步處理,我們?cè)谂判蚩蚣苤卸x了一個(gè) IFutureConsumer 接口,該接口支持獲取 Future 方法,用于在排序框架中等待日志處理完成。同時(shí),預(yù)測(cè)框架接收到的仍然是一個(gè)標(biāo)準(zhǔn)的 Consumer 接口對(duì)象。
這種方法確保了預(yù)測(cè)框架和排序框架之間的解耦,明確了各自的職責(zé),避免了雙向依賴,使系統(tǒng)更加靈活和可擴(kuò)展。
interface IFutureConsumer<T> extend Consumer<T> {
// accpet(T t)
Future<?> getFuture();
}
預(yù)測(cè)框架生成輸入特征并傳遞給排序框架。
// 預(yù)測(cè)框架生成輸入特征
T inputFeatures = ...;
// 調(diào)用排序框架的日志處理邏輯
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;
}
}
整個(gè)過(guò)程如下圖所示:
另外本方案使用了Redis的哈希(hash)數(shù)據(jù)結(jié)構(gòu)進(jìn)行存儲(chǔ),原因是在搜索服務(wù)中,每次請(qǐng)求都需要刷新結(jié)果緩存,這也意味著需要同時(shí)刷新相關(guān)的日志緩存。然而,搜索服務(wù)并不知道具體有哪些 infoid 已經(jīng)被存儲(chǔ),如果使用字符串(string)結(jié)構(gòu)來(lái)存儲(chǔ)這些日志數(shù)據(jù),很難做到全量刷新,因?yàn)闊o(wú)法有效地管理和定位所有存儲(chǔ)的鍵值對(duì)。
相比之下,使用哈希(hash)結(jié)構(gòu)存儲(chǔ)日志信息有明顯的優(yōu)勢(shì)。我們可以使用 ctr、cvr、info 等作為哈希表的關(guān)鍵字前綴,并以請(qǐng)求的 MD5 值作為后綴。這種方式只需要刷新 2~3 個(gè)哈希鍵(key),就可以覆蓋所有相關(guān)的日志數(shù)據(jù)。這種方法不僅簡(jiǎn)化了緩存刷新操作,而且更高效,因?yàn)橹恍璨僮魃倭康墓fI即可完成全量刷新,適合搜索服務(wù)的需求。
推薦側(cè)在找靚機(jī)微詳情頁(yè)場(chǎng)景先行接入了此方案,額外耗時(shí)由14ms優(yōu)化至4ms
但隨后發(fā)現(xiàn)了兩個(gè)問(wèn)題:
隨著首頁(yè)推薦等主要場(chǎng)景的接入,后處理過(guò)程中獲取日志過(guò)程耗時(shí)達(dá)到了7ms以上,原因是寫(xiě)qps較高(單redis-server節(jié)點(diǎn)近7w qps),日志數(shù)據(jù)又屬于bigkey(1k以上),redis-server極易發(fā)生阻塞,擴(kuò)容后仍在3ms左右水平 搜索測(cè)試耗時(shí)并未明顯下降,讀取和刷緩存過(guò)期時(shí)間也帶來(lái)了額外的成本。
總結(jié)
- 此方案是對(duì)Redis性能過(guò)于樂(lè)觀,在處理高qps + bigkey的場(chǎng)景性能無(wú)法滿足要求,后續(xù)需要經(jīng)常關(guān)注單點(diǎn)qps并評(píng)估擴(kuò)容,維護(hù)成本也高
- 業(yè)務(wù)過(guò)多的感知框架實(shí)現(xiàn)邏輯,有些操作甚至需要業(yè)務(wù)干預(yù),如手動(dòng)刷新過(guò)期時(shí)間等,接入過(guò)程體驗(yàn)不夠友好,負(fù)擔(dān)過(guò)重
3.2 優(yōu)化方案二
在本地打印日志和緩存日志的方案不可行后,我們只能重新考慮通過(guò)響應(yīng)對(duì)象將日志數(shù)據(jù)傳回調(diào)用方的方案。以下是幾種可行的思路:
- 本地緩存日志數(shù)據(jù):將日志數(shù)據(jù)暫時(shí)緩存到本地,等待重排序完成后,再請(qǐng)求同一臺(tái)機(jī)器取出所需的日志數(shù)據(jù)。
- 分步返回?cái)?shù)據(jù):先返回排序后的模型得分部分,再異步返回日志數(shù)據(jù)部分。
- 日志異步轉(zhuǎn)換和壓縮:在預(yù)測(cè)階段,將日志數(shù)據(jù)異步轉(zhuǎn)換為字節(jié)數(shù)組并進(jìn)行壓縮,序列化時(shí)直接返回這些字節(jié)數(shù)據(jù)。在整個(gè)搜索推薦流程完成后,再將要下發(fā)的topN商品的日志數(shù)據(jù)解壓并轉(zhuǎn)換回字符串。
經(jīng)過(guò)評(píng)估,前兩種方案雖然具有一定的可行性,但需要調(diào)用方進(jìn)行較多的開(kāi)發(fā)支持,實(shí)施周期較長(zhǎng)。此外,這些方案還需考慮更復(fù)雜的容災(zāi)處理設(shè)計(jì),例如應(yīng)對(duì)因重啟或超時(shí)導(dǎo)致的日志丟失,以及緩存引起的 GC 問(wèn)題。為了規(guī)避這些風(fēng)險(xiǎn),我們決定嘗試第三種方案。
為了能實(shí)現(xiàn)僅在需要時(shí),即取商品列表topN并打印后端日志時(shí),才將日志從bytes轉(zhuǎn)回String,在響應(yīng)RankResultItem增加了LazyMetric數(shù)據(jù)類型,利用延遲加載機(jī)制,減少了不必要的數(shù)據(jù)處理和傳輸開(kāi)銷,數(shù)據(jù)結(jié)構(gòu)如下:
class RankResultItem {
Map<String, LazyMetric> lazyMetricMap;
}
class LazyMetric {
byte[] data; // 編碼后的字符串?dāng)?shù)據(jù)
byte compressMethodCode; // 壓縮類型
LazyMetric(String str){
// string2bytes
}
String toString() {
// bytes2string
}
}
日志生產(chǎn)及獲取過(guò)程調(diào)整為:
圖片
可以看出,String 轉(zhuǎn) bytes 的編碼過(guò)程耗時(shí)已經(jīng)在模型執(zhí)行時(shí)并行處理中被優(yōu)化掉了。在從模型預(yù)測(cè)模塊到 topN 節(jié)點(diǎn)的整個(gè)執(zhí)行過(guò)程中,系統(tǒng)始終攜帶的是 bytes 類型的數(shù)據(jù)。只有在 topN 節(jié)點(diǎn)完成了所有搜索推薦流程需要準(zhǔn)備返回商品時(shí),才會(huì)主動(dòng)調(diào)用 toString 方法將 bytes 轉(zhuǎn)回 String,而其他商品的日志數(shù)據(jù)則會(huì)被直接丟棄。這樣一來(lái),decode 的次數(shù)從 500 次減少到了 10 次(假設(shè) N 一般為 10)。整個(gè)過(guò)程對(duì)業(yè)務(wù)側(cè)的集成并不復(fù)雜,開(kāi)啟功能后,排序框架就會(huì)自動(dòng)將日志數(shù)據(jù)轉(zhuǎn)存到 LazyMetricMap 中。中控服務(wù)隨后可以從每個(gè) Item 的 LazyMetricMap 中取出 LazyMetric 對(duì)象,并在合適的時(shí)機(jī)調(diào)用 toString 方法,提升搜索推薦業(yè)務(wù)整體開(kāi)發(fā)效率。壓縮過(guò)程選擇了java自帶的gzip和zlib兩種方法進(jìn)行測(cè)試,測(cè)試結(jié)果如下:
方法 | 序列化時(shí)間 | 反序列化時(shí)間 | 總時(shí)間 | 數(shù)據(jù)大小 (bytes) | 壓縮比率 |
【SCFV4】原方法 | 1.70ms | 1.28ms | 2.98ms | 1,192,564 | 0.8336 |
【SCFV4】metric日志直接轉(zhuǎn)bytes傳輸 | 1.46ms | 0.74ms | 2.20ms | 1,216,565 | 0.8504 |
【SCFV4】metric日志zlib轉(zhuǎn)bytes傳輸 | 1.15ms | 0.73ms | 1.88ms | 472,065 | 0.3300 |
【SCFV4】metric日志gzip轉(zhuǎn)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日志直接轉(zhuǎn)bytes傳輸 | 1.00ms | 3.80ms | 4.80ms | 1,168,143 | 0.8165 |
【Hessian】metric日志zlib轉(zhuǎn)bytes傳輸 | 0.61ms | 1.46ms | 2.07ms | 422,513 | 0.2953 |
【Hessian】metric日志gzip轉(zhuǎn)bytes傳輸 | 0.59ms | 1.44ms | 2.03ms | 440,509 | 0.3079 |
可以看到,在不同的序列化方法下,序列化耗時(shí)都有所減少,性能最高提升至原來(lái)的 35%,序列化后的數(shù)據(jù)量也減少到原來(lái)的 36%,這也預(yù)示著網(wǎng)絡(luò) I/O 的開(kāi)銷會(huì)有所下降。
接入情況:
- 推薦系統(tǒng):在轉(zhuǎn)轉(zhuǎn)首頁(yè)推薦場(chǎng)景中接入后,與原 Redis 方案相比,性能沒(méi)有顯著提升,但減少了 Redis 中間存儲(chǔ)環(huán)節(jié),從而降低了特征數(shù)據(jù)丟失的風(fēng)險(xiǎn)。
- 搜索系統(tǒng):在接入并進(jìn)行壓測(cè)后,雖然整體耗時(shí)有所減少,但仍然存在大約 13 毫秒的額外耗時(shí)(71ms 對(duì)比 58ms),尚未完全解決性能問(wèn)題。
總結(jié):
- 盡管方案在性能上有所提升,但這僅是對(duì)原有序列化框架的修補(bǔ),核心問(wèn)題在于 SCF 序列化對(duì)特定對(duì)象的執(zhí)行效率仍然不高,因此問(wèn)題并未徹底解決。初步分析表明,可能的原因在于 Map 的序列化過(guò)程本身依然較為耗時(shí),并且在反序列化時(shí),需要為 LazyMetric 的字節(jié)數(shù)組(搜索約 1500 個(gè),推薦約 800 個(gè))分配大量碎片化的內(nèi)存空間,這導(dǎo)致了額外的耗時(shí)。
3.2 優(yōu)化方案三
根據(jù)對(duì) V2 方案的總結(jié),V3 方案的設(shè)計(jì)原則是:放棄使用 SCF 的通用對(duì)象序列化,RPC 層僅通過(guò)字節(jié)數(shù)組進(jìn)行交互,而排序框架采用自定義的序列化方法。
思路一:繼續(xù)嘗試接入現(xiàn)有的開(kāi)源序列化框架,并在此基礎(chǔ)上對(duì)排序響應(yīng)對(duì)象進(jìn)行定制化開(kāi)發(fā)。常見(jiàn)的開(kāi)源項(xiàng)目包括 protobuf、Kryo、Hessian 等。
思路二:自行開(kāi)發(fā)專門適用于排序響應(yīng)對(duì)象的序列化方法。
思路一的優(yōu)勢(shì)在于安全性、通用性和高性能方面都表現(xiàn)良好,部分框架也提供一定的定制化能力。然而,這類框架通常為了適應(yīng)多種業(yè)務(wù)場(chǎng)景,會(huì)包含大量通用代碼和復(fù)雜邏輯。以 Kryo 為例,其項(xiàng)目代碼行數(shù)超過(guò) 2 萬(wàn)行,這使得短期內(nèi)很難掌握所有細(xì)節(jié),一旦出現(xiàn)問(wèn)題可能會(huì)阻礙開(kāi)發(fā)進(jìn)度,并且不一定能按期解決序列化問(wèn)題。不過(guò),開(kāi)源框架技術(shù)成熟,適合作為長(zhǎng)期方案。
思路二的優(yōu)勢(shì)在于既可以借鑒其他框架的優(yōu)化策略,又可以低成本地針對(duì)特定對(duì)象進(jìn)行定制優(yōu)化,從而實(shí)現(xiàn)更高的序列化效率。雖然在安全性方面,需要通過(guò)單元測(cè)試來(lái)保障,但開(kāi)發(fā)一個(gè)針對(duì)特定應(yīng)用場(chǎng)景的序列化方法相對(duì)簡(jiǎn)單??紤]到排序框架接口的參數(shù)對(duì)象不經(jīng)常更改,這種方法可以做到一次開(kāi)發(fā)、長(zhǎng)期受益。因此,我們傾向于選擇思路二。
整理思路后,序列化開(kāi)發(fā)可以按照以下步驟進(jìn)行:
定義字節(jié)數(shù)組的序列化數(shù)據(jù)結(jié)構(gòu)
- 確定如何將對(duì)象數(shù)據(jù)映射到字節(jié)數(shù)組的格式中,包括字段的順序、類型,以及如何處理可變長(zhǎng)度數(shù)據(jù)。
定義序列化接口并實(shí)現(xiàn)具體的序列化類:
- 創(chuàng)建一個(gè)通用的序列化接口,用于定義序列化和反序列化的方法。
- 為每個(gè)需要序列化的對(duì)象類型實(shí)現(xiàn)具體的序列化類,確保符合接口的要求。
定義序列化過(guò)程的數(shù)據(jù)緩沖類:
- 開(kāi)發(fā)一個(gè)用于在序列化和反序列化過(guò)程中暫存數(shù)據(jù)的緩沖類,以便有效管理字節(jié)數(shù)組的讀寫(xiě)操作。
實(shí)現(xiàn)各對(duì)象的具體序列化方法:
- 為每個(gè)對(duì)象類型實(shí)現(xiàn)具體的序列化和反序列化方法,將對(duì)象數(shù)據(jù)轉(zhuǎn)換為字節(jié)數(shù)組或從字節(jié)數(shù)組重構(gòu)對(duì)象。
序列化結(jié)果最終要存儲(chǔ)在字節(jié)數(shù)組(byte[])中,因此定義如何存儲(chǔ)是我們的首要任務(wù)。
一個(gè)排序?qū)ο蟀S多內(nèi)容。為了簡(jiǎn)化存儲(chǔ)過(guò)程并便于編寫(xiě)代碼,我們采用了一種類似樹(shù)狀的存儲(chǔ)結(jié)構(gòu),與其他序列化方式大致相同。這種結(jié)構(gòu)將排序?qū)ο蟮恼w作為根節(jié)點(diǎn),然后按照對(duì)象的層次結(jié)構(gòu)逐級(jí)展開(kāi)存儲(chǔ)。
與其他序列化方式不同的是,我們考慮到排序過(guò)程中對(duì)所有商品都會(huì)執(zhí)行相同的操作,因此商品類的特征 Map、結(jié)果 Map 和日志 Map 的存儲(chǔ)鍵集合在實(shí)際應(yīng)用中是保持一致的。由于這些鍵是可以復(fù)用的,我們將其提取出來(lái)并統(tǒng)一存儲(chǔ)在 items_common 中。這樣一來(lái),Map 的值可以按照固定的順序進(jìn)行鏈?zhǔn)酱鎯?chǔ),這種方法不僅節(jié)省了空間,還提升了存儲(chǔ)效率。
為了進(jìn)一步降低代碼復(fù)雜度,還需要定義統(tǒng)一的接口,再將各個(gè)成員序列化過(guò)程分解到多個(gè)具體實(shí)現(xiàn)類中
自定義序列化方法接口定義如下:
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:快速評(píng)估序列化對(duì)象的長(zhǎng)度。
- 在序列化過(guò)程中,如果字節(jié)數(shù)組的容量不足,就需要?jiǎng)?chuàng)建一個(gè)新數(shù)組,其大小為當(dāng)前大小的兩倍,并復(fù)制已有數(shù)據(jù)。這一過(guò)程需要進(jìn)行 log(最終大小) - log(初始大小) 次擴(kuò)容操作。estimateUsage 方法通過(guò)快速評(píng)估一個(gè)稍大于或接近最終大小的初始容量,來(lái)減少擴(kuò)容次數(shù),提高效率。
serialize:用于序列化對(duì)象。
- 序列化過(guò)程中,context 包含一個(gè) Output 對(duì)象,用于處理字節(jié)數(shù)據(jù)的輸出,同時(shí)還包括 metricMap、resultMap 等公共的 keySet,這些 keySet 用于統(tǒng)一管理序列化過(guò)程中的鍵集合。
deserialize:用于反序列化對(duì)象。
- 反序列化過(guò)程中,context 包含一個(gè) Input 對(duì)象,用于處理字節(jié)數(shù)據(jù)的輸入,此外,還包括 metricMap、resultMap 等公共的 keySet,以確保反序列化時(shí)使用的鍵集合與序列化時(shí)一致。
通過(guò)這些方法,可以更有效地管理對(duì)象的序列化和反序列化過(guò)程,提升整體性能和資源利用率。
根據(jù)響應(yīng)對(duì)象的數(shù)據(jù)層次,序列化過(guò)程需要針對(duì)不同的類型進(jìn)行拆解,并為每種具體類型設(shè)計(jì)相應(yīng)的序列化類。以下是各類序列化器的設(shè)計(jì):
GeneralObjSerializer:
- 負(fù)責(zé)序列化和反序列化 Java 的基本數(shù)據(jù)類型(如 int、float、double 等)以及字符串 (String) 類型。
- 提供方法將基本數(shù)據(jù)類型和字符串轉(zhuǎn)換為字節(jié)數(shù)組,并在反序列化時(shí)將字節(jié)數(shù)組轉(zhuǎn)換回相應(yīng)的基本類型或字符串。
GeneralMapSerializer(用于基本 Map 類型,按順序存儲(chǔ)鍵值對(duì)):
- 負(fù)責(zé)序列化和反序列化 Map 對(duì)象。該類按特定順序存儲(chǔ) Map 的鍵值對(duì)(key-value),確保在反序列化時(shí)可以恢復(fù) Map 的原始狀態(tài)。
- 支持常見(jiàn)的 Map 實(shí)現(xiàn)(如 HashMap、TreeMap 等),并處理可能的空鍵或空值。
GeneralListSerializer(
- 負(fù)責(zé)序列化和反序列化 List 對(duì)象,能夠?qū)?List 轉(zhuǎn)換為字節(jié)數(shù)組,并在反序列化時(shí)恢復(fù) List 的原始結(jié)構(gòu)和內(nèi)容。
- 適用于各種 List 實(shí)現(xiàn)(如 ArrayList、LinkedList 等),并處理列表中的空元素。
GeneralSetSerializer:
- 負(fù)責(zé)序列化和反序列化 Set 對(duì)象,將 Set 中的元素序列化為字節(jié)數(shù)組并按序恢復(fù)。
- 支持常見(jiàn)的 Set 實(shí)現(xiàn)(如 HashSet、TreeSet 等),并確保在反序列化后保持 Set 的無(wú)序性和唯一性。
RankResultSerializer:
- 負(fù)責(zé)序列化和反序列化 RankResult 對(duì)象,處理與排序結(jié)果相關(guān)的數(shù)據(jù)結(jié)構(gòu)和字段。
- 該類將 RankResult 的復(fù)雜對(duì)象和嵌套結(jié)構(gòu)序列化為字節(jié)數(shù)組,并在反序列化時(shí)重構(gòu)完整的 RankResult 對(duì)象。
RankResultItemSerializer:
- 專門用于序列化和反序列化 RankResultItem 對(duì)象,處理單個(gè)排序結(jié)果項(xiàng)的序列化。
- 負(fù)責(zé)將每個(gè) RankResultItem 的各個(gè)字段(包括特征、結(jié)果和日志等)轉(zhuǎn)換為字節(jié)數(shù)組,并在反序列化時(shí)恢復(fù)其內(nèi)容和結(jié)構(gòu)。
RankResponseSerializer:
- 用于序列化和反序列化 RankResponse 對(duì)象,管理整個(gè)排序響應(yīng)的序列化過(guò)程。
- 該類負(fù)責(zé)將完整的響應(yīng)數(shù)據(jù),包括所有 RankResultItem,序列化為字節(jié)數(shù)組,并在反序列化時(shí)重建整個(gè) RankResponse 對(duì)象。
序列化過(guò)程中依次將寫(xiě)入到一段足夠長(zhǎng)的byte數(shù)組里,序列化完成時(shí)再一次性讀出所有寫(xiě)入數(shù)據(jù),定義Output類作為序列化過(guò)程中的數(shù)據(jù)緩沖(同樣有Input類作用于反序列化,實(shí)現(xiàn)類似)
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:作為序列化數(shù)據(jù)的緩沖區(qū),為了寫(xiě)入效率最高,緩沖區(qū)是連續(xù)且足夠長(zhǎng)的byte數(shù)組,足夠長(zhǎng)由入?yún)stimateUsage來(lái)保證
offset:是下一個(gè)要寫(xiě)入數(shù)據(jù)的位置,如果offset >= 數(shù)組長(zhǎng)度,則需要擴(kuò)容,擴(kuò)容每次按兩倍擴(kuò)容
estimateUsage的準(zhǔn)確性影響了擴(kuò)容次數(shù),進(jìn)而影響序列化效率,經(jīng)測(cè)試從以32為起始容量初始化并逐漸擴(kuò)容到所需容量與直接使用estimateUsage初始化,序列化耗時(shí)相差20%左右
writeInt、writeLong:整型和長(zhǎng)整型的寫(xiě)入是可變長(zhǎng)的,雖然int和long分別使用了32bit和64bit的空間,但如1、2、8、64等較小的數(shù)字只是用了前8bit的空間,一般可變長(zhǎng)序列化采取的做法是將每8bit為一組,低7位存儲(chǔ)真實(shí)數(shù)據(jù),高位存儲(chǔ)標(biāo)識(shí)符,表明更高位是否仍存在更多數(shù)據(jù),可變長(zhǎng)編碼下整型需要1~5byte,長(zhǎng)整型則需要1~10byte,存儲(chǔ)數(shù)字值越小時(shí),可變長(zhǎng)的壓縮效果越好。讀取時(shí)再?gòu)牡臀灰来蜗蚋呶蛔x取,直到標(biāo)識(shí)符表明數(shù)據(jù)讀取完畢,當(dāng)緩沖區(qū)剩余長(zhǎng)度不足可變長(zhǎng)的最大長(zhǎng)度時(shí),需要調(diào)用readInt_slow或readLong_slow方法,逐個(gè)byte讀取并判斷是否越界
writeFloat、writeDouble:這兩種類型不能直接寫(xiě)入,需要調(diào)用Float.floatToRawIntBits和Double.doubleToRawLongBits轉(zhuǎn)為Integer型和Long型。我們的特征由于特征默認(rèn)值等原因存在大量0.0、-1.0、1.0等數(shù)值,但在可變長(zhǎng)存儲(chǔ)下,轉(zhuǎn)int后實(shí)際占用位數(shù)很長(zhǎng),優(yōu)化方式是轉(zhuǎn)換前先判斷了它是否為整型數(shù)字,如是整型就取整后直接存為整型,可將原本需要5~10位的存儲(chǔ)空間節(jié)省到1位,一個(gè)較為快速的判斷方式為:
void checkDoubleIsIntegerValue(double d) {
return ((long)d == d);
}
多數(shù)序列化實(shí)現(xiàn)按待序列化的各個(gè)成員類型依次調(diào)用對(duì)應(yīng)序列化方法即可
Item間的共享數(shù)據(jù)處理,是本次序列化優(yōu)化最核心的優(yōu)化點(diǎn),對(duì)序列化效率提升有決定性影響,如特征/結(jié)果/日志Map的keySet的存儲(chǔ)復(fù)用,具體做法是
讀取第一個(gè)Item的所有keySet并保存在序列化上下文中,作為基準(zhǔn)數(shù)據(jù),后續(xù)每個(gè)Item都與第一個(gè)的keySet判斷,完全相同就按第一個(gè)item的相同順序?qū)alues依次取出,按隊(duì)列存儲(chǔ),快速的判斷方式如下:
private static boolean isNotEqualSet(Set<String> set1, Set<String> set2) {
return set1 == null || set2 == null || (set1.size() != set2.size()) || !set1.containsAll(set2);
}
當(dāng)任意商品不滿足keySet一致性的要求時(shí),Item序列化方法會(huì)向上拋出異常,排序框架會(huì)捕獲到該異常,并將返回的壓縮響應(yīng)對(duì)象(CompressedRankResponse)退化為普通響應(yīng)對(duì)象(RankResponse)
異常行為會(huì)根據(jù)用戶的選擇上報(bào)給監(jiān)控平臺(tái),或需要排查問(wèn)題時(shí)選擇打印到本地文件
上游服務(wù)無(wú)需關(guān)心排序服務(wù)返回了哪種響應(yīng)對(duì)象,這是因?yàn)槠胀憫?yīng)對(duì)象和序列化后的壓縮響應(yīng)對(duì)象實(shí)現(xiàn)了同一接口
即原RankResponse對(duì)象和新CompressedRankResponse對(duì)象實(shí)現(xiàn)了IRankResponse接口,CompressedRankResponse是RankResponse的裝飾器對(duì)象
CompressedRankResponse對(duì)象在用戶調(diào)用任意方法,且當(dāng)內(nèi)置RankResponse對(duì)象為空時(shí)完成反序列化,如下段代碼中的getStatus方式所示
后續(xù)再調(diào)用其他方法在使用體驗(yàn)上是與未壓縮對(duì)象一致的,這種與直接返回byte數(shù)組相比,業(yè)務(wù)使用更友好,異常時(shí)可以快速降級(jí),也沒(méi)有太多帶來(lái)額外成本
IRankResponse rank(RankRequest request);
class RankResponse implements IRankResponse;
class CompressedRankResponse implements IRankResponse {
byte[] bytes; // 排序服務(wù)返回的數(shù)據(jù)
RankResponse response = null; // 調(diào)用任意方法后反序列化生成的數(shù)據(jù)
public int getStatus() {
if(response == null) {
// 執(zhí)行反序列化
response = this.doDeserilize();
}
return response.getStatus();
}
}
模擬搜索500個(gè)商品,測(cè)試2000次,序列化前大小1430640
第一次測(cè)試:實(shí)驗(yàn)組為V3優(yōu)化,對(duì)照組為無(wú)優(yōu)化
方法 | 序列化時(shí)間 | 反序列化時(shí)間 | 總時(shí)間 | 數(shù)據(jù)大小 (bytes) | 壓縮比率 |
SCFV4 序列化原方法 | 1.86ms | 1.19ms | 3.05ms | 1,188,961 | 0.8310 |
框架自定義序列化方法 | 0.32ms | 0.17ms | 0.49ms | 392,127 | 0.2741 |
優(yōu)化降低 | 82.80% | 85.71% | 83.93% | 67.02% | 67.02% |
第二次測(cè)試:實(shí)驗(yàn)組為V3優(yōu)化,對(duì)照組為V2優(yōu)化
方法 | 序列化時(shí)間 | 反序列化時(shí)間 | 總時(shí)間 | 數(shù)據(jù)大小 (bytes) | 壓縮比率 |
框架自定義序列化方法 | 0.41ms | 0.20ms | 0.61ms | 393,001 | 0.274 |
帶日志 byte 壓縮 + SCF 序列化方法 | 3.10ms | 1.00ms | 4.10ms | 503,961 | 0.35 |
優(yōu)化降低 | - | - | 85.12% | 21.7% | 21.7% |
可見(jiàn)V3對(duì)序列化過(guò)程的執(zhí)行效率提升明顯
以下是業(yè)務(wù)接入情況
搜索側(cè)接入:測(cè)試接入排序服務(wù)耗時(shí)于未服務(wù)化時(shí)持平,滿足上線要求
推薦測(cè)接入:序列化過(guò)程在2ms左右完成,場(chǎng)景接入后耗時(shí)均有明顯下降,符合預(yù)期
場(chǎng)景 | 優(yōu)化前 | 優(yōu)化后 | 提升時(shí)間 | 百分比 |
找靚機(jī)微詳情頁(yè)推薦 | 69ms | 64ms | 5ms | 7.24% |
轉(zhuǎn)轉(zhuǎn) B2C 詳情頁(yè) | 97ms | 94ms | 3ms | 3.09% |
轉(zhuǎn)轉(zhuǎn) C2C 詳情頁(yè) | 92ms | 87ms | 5ms | 5.43% |
轉(zhuǎn)轉(zhuǎn)首頁(yè)推薦 3C 頁(yè) | 112ms | 106ms | 6ms | 5.36% |
轉(zhuǎn)轉(zhuǎn)首頁(yè)推薦默認(rèn) | 130ms | 128ms | 2ms | 1.54% |
4 總結(jié)
本項(xiàng)目旨在解決搜索推薦服務(wù)化過(guò)程中因日志傳輸引起的序列化額外耗時(shí)問(wèn)題。經(jīng)過(guò)三次版本迭代和測(cè)試,最終方案成功落地。
結(jié)論
本地測(cè)試:
- 與優(yōu)化前相比,排序響應(yīng)對(duì)象的序列化過(guò)程節(jié)省了約 83% 的序列化開(kāi)銷,網(wǎng)絡(luò)開(kāi)銷減少了約 67%。搜索:
- 有效降低了排序服務(wù)響應(yīng)中的序列化過(guò)程對(duì)搜索接口整體耗時(shí)的影響,使得新的搜索排序服務(wù)在性能上達(dá)到了上線要求。推薦:
- 在推薦排序服務(wù)化后,接入本項(xiàng)目方案,在多個(gè)展位實(shí)現(xiàn)了接口整體耗時(shí)絕對(duì)值降低 2ms 到 6ms 的性能提升。
思考
從問(wèn)題發(fā)現(xiàn)到解決上線,項(xiàng)目歷時(shí)近一個(gè)月。雖然問(wèn)題定位較為迅速,但在確定最終方案和落地時(shí)經(jīng)歷了較長(zhǎng)的周期。方案設(shè)計(jì)過(guò)程中有兩點(diǎn)需要注意:
方案評(píng)估要更細(xì)致:
- 不要急于實(shí)施方案,要及時(shí)暴露性能瓶頸。例如,前期如果發(fā)現(xiàn)日志上報(bào)超出了 Redis 單節(jié)點(diǎn) 10 萬(wàn) QPS 的瓶頸,在考慮實(shí)施 V1 方案時(shí)會(huì)更加謹(jǐn)慎。
方案設(shè)計(jì)要更具全局性:
- 不應(yīng)僅將視角局限于字符串序列化過(guò)程,而是從整個(gè)序列化過(guò)程的角度出發(fā)。這樣可以更快地跳過(guò) V2 方案,直接進(jìn)入更高效的 V3 方案。
后續(xù)工作
廢棄遺留代碼:
- 遺留代碼增加了開(kāi)發(fā)的不確定性和風(fēng)險(xiǎn)性,因此需要廢棄 SCF 原生序列化方法和 V1 方法。
召回框架的序列化優(yōu)化:
- 召回服務(wù)框架的序列化優(yōu)化尚未啟動(dòng),預(yù)計(jì)也能獲得顯著的性能提升。然而,與排序框架相比,召回框架涉及的傳輸對(duì)象類型更多,優(yōu)化難度更高,因此方案需要在排序優(yōu)化的基礎(chǔ)上進(jìn)一步調(diào)整。