基於 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);

回滾的順序是 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