基於Protobuf共享欄位的分包和透傳零拷貝技術,你瞭解嗎?

語言: CN / TW / HK

導語 |  本文通過介紹 實現Protobuf共享欄位Guard,並將其應用於中控/召回場景, 並獲得了顯著CPU/時延收益。 即使不使用Guard,希望本文的經驗和思路也能為讀者帶來一些幫助和參考。

引言

在推薦系統中,使用者級的欄位常常需要貫穿整條鏈路,例如,實驗引數,行為序列,使用者畫像等等。

召回/過濾/排序等模組都需要使用者特徵,此時最好的方法自然是從請求開始時一次性獲取,然後一路透傳下去。此前筆者的寫法常常是:

const GetRecommendReq & oReq;//from rpc
RankReq oRankReq;
oRankReq.mutable_user_portrait()->CopyFrom(oReq.user_portrait());

這樣的透傳自然有好處,例如,下游如果需要使用者特徵,不需要再每個請求去請求一次。尤其是上游發起分包時,透傳使用者級別特徵能夠顯著減少下游獲取使用者特徵的RPC開銷。

然而,RPC開銷減少了,再得隴望蜀想一想,是否能直接省去這個CopyFrom的開銷呢

我們知道,protobuf提供了Allocated/Release系列介面,通過直接轉移指標所有權的方式消除Copy或Swap的開銷。

換個思路,如果不是轉移指標所有權,而是借出指標所有權,就能夠實現共享欄位了。所謂借,其實就是在使用前把欄位指標轉移,但在使用結束後立刻收回(收回所有權以防被delete)。而這正是經典的Guard抽象。

當然,即使不使用Guard,相信上面這個思路已經足夠提供一些幫助了。我們可以直接使用pb的介面實現:

const GetRecommendReq & oReq;//from rpc
GetRecommendReq & oMutableReq = const_cast<GetRecommendReq &>(oReq);
RankReq oRankReq;
oRankReq.set_allocated_user_portrait(oMutableReq.mutable_user_portrait());
Client.Rank(oRankReq);
oRankReq.release_user_portrait();

對於一些更復雜的操作,例如我想要拷貝部分欄位,共享部分欄位,修改部分欄位(分包的場景),我們在下文給出了我們的解決方案。

設計

我們的Guard提供了兩個介面,分別是Attach和Detach,介面如下。實現通過pb的反射機制,使得release和set_allocated能夠相互繫結,實現Guard析構時回滾。

void AttachField(Message* pMessage, int iFieldId, Message* pFieldValue);
Message* DetachField(Message* pMessage, int iFieldId);
  • AttachField :先把欄位set_allocted借給pMesage,Guard析構後回滾釋放,以防雙重delete。

  • DetachField :先把pMessage的欄位release借出,Guard析構後回滾歸還,以防記憶體洩漏。

回滾的順序是FILO,也就是嚴格按照相反的順序(因為release和set_allocated並非嚴格對稱,如果在成環的情況下可能會有問題)。

由於C++的構造和析構也是 FILO (https://isocpp.org/wiki/faq/dtors#order-dtors-for-locals) 一定要在pb初始化後再初始化Guard

這兩個介面已經足夠滿足在我們的業務中存在的幾種抽象:

(一)主調透傳/分包

把上游傳遞的某個欄位,零拷貝傳入下游的請求。此時直接Attach欄位即可。

//usecase:
const AReq & oAReq;
BReq oBReq;
SharePbFieldGuard guard;
guard.AttachField(&oBReq, BReq::BigFieldId, const_cast<AReq &>(oAReq).mutable_bigfield());

(二)被調分包

控制某些欄位不同,而其他欄位共享/相同。 為了避免拷貝大欄位,我們可以在拷貝前先釋放這些重的欄位;拷貝結束後,把重欄位共享給所有的分包 使用CopyFrom好處在於,我們不需要為所有新增的欄位都手動判斷,只需要特殊處理重的欄位即可。

//usecase:
Req & oReq;
std::vector<Req> vecMultiReq(n);
SharePbFieldGuard guard;
auto* pField = guard.DetachField(&oReq, Req::BigFieldId);
for(auto && oSingleReq: multiReq)
{
oSingleReq.CopyFrom(oReq);
oSingleReq.set_field(...);
guard.AttachField(&oSingleReq, Req::BigFieldId, pField);
}

(三)多欄位共享寫法(以下是一段脫敏的實際程式碼)

由於操作的指標都是Message*型別,可以直接用容器儲存pb index到欄位指標的對映關係。通過迴圈即可共享所有重欄位。

        std::vector<uint32_t> vecHeavyField{};//初始化為一組fieldId
SharePbFieldGuard oGuard;
std::unordered_map<uint32_t, ::google::protobuf::Message*> mapIndex2Message;
for(auto uField: vecHeavyField)
{
mapIndex2Message[uField] = oGuard.DetachField(&oReq, uField);
}

for (auto && oSingleReq: vecReq)
{
oSingleReq.CopyFrom(oReq);
//shared filed
for(auto uField: vecHeavyField)
{
oGuard.AttachField(&oSingleRecallReq, uField, mapIndex2Message[uField]);
}
}

展望

安全性 :因為回滾時set_allocated會delete掉原本的欄位,假如成環可能會很危險,如何偵測這種情況。

效能 :是否存在不使用反射,就能自動繫結set_allocated和release的方法?

Repeated欄位支援 :怎樣處理Repeatd欄位不同的反射介面?

(https://developers.google.com/protocol-buffers/docs/reference/cpp/google.protobuf.message#repeated-field-getters)

作者簡介

朱文傑

騰訊後臺開發工程師

騰訊後臺開發工程師,畢業於上海交通大學,知乎筆名朝聞君,目前負責微信公眾平臺推薦系統後臺的開發和優化。

推薦閱讀

深度解讀!新一代大資料引擎Flink厲害在哪?(附實現原理細節)

終於!12年後Golang支援泛型了!(內含10個例項)

揭祕!用標準Go語言能寫指令碼嗎?

大咖共探萬物智聯時代風雲!Techo TVP物聯網開發者峰會圓滿落幕