基於 Protobuf 共享字段的分包和透傳零拷貝技術,你瞭解嗎?
本文通過介紹實現 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)
** 作者簡介**
朱文傑
騰訊後臺開發工程師
騰訊後臺開發工程師,畢業於上海交通大學,知乎筆名朝聞君,目前負責微信公衆平臺推薦系統後臺的開發和優化。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/isOzeuwsn_-5TUqsLcgTnQ