天天看點

Google Protocol Buffer使用經驗分享(一) C++動态消息與靜态消息的博弈寫在前面待解決問題的背景靜态消息 與 動态消息學以緻用結論

  相信正在浏覽這篇文章的同學,一定已經對pb(protocol buffer)有所了解,是以這裡不羅嗦何為pb了。

  我自己從去年年底開始對pb的使用逐漸有一些了解,直到在搜尋排序架構(irank)的重構中嘗試應用pb,希望能在“資料結構靈活增删改”和“高效的資料傳輸反序列化”之間求得平衡。

  在這過程之中,對pb 動态消息和靜态消息的c++使用方式進行了一些調研,對 動态消息 和 靜态消息 的優缺點有了進一步了解。通過閱讀源代碼和實際應用,總結出一些經驗,将 動态消息 與 靜态消息 進行有機的結合,可以在“資料結構靈活增删改”和“高效的資料傳輸反序列化”之間求得平衡。

  希望這些經驗,對其他正糾結于選擇 動态消息 與還是 靜态消息 的同學,能有一些幫助和啟發。

  俗話說,不存在無緣無故的愛,也不存在無緣無故的恨。這裡嘗試在irank的重構中應用pb,是為了解決新舊版本索引相容的問題,同時為線上提供高效的自反射機制,靈活讀取需要的字段,保證線上服務性能。

  具體的說,我們負責的irank子產品,是isearch引擎的一個排序算分子產品,對每一次搜尋請求,isearch需要調用irank子產品對每一個(query,doc)二進制關系對進行算分,根據算分結果對候選結果集進行排序。

  在算分過程中irank需要用到大量query、doc次元的特征資訊,考慮到線上實時抽取所有特征資訊的效率很低下(如抽取商品标題中的中心詞),是以真實的情況是dump中心會調用gaia子產品(負責doc次元特征抽取),将這些可線下預處理的特征抽取好并進行序列化,由isearch存儲到profile索引中。線上對(query, doc)二進制關系對算分時,直接從isearch提供的profile索引中取之前序列化産出的doc特征資訊,做相應反序列化操作後将其還原為可随機讀取的結構化資訊,進一步完成後續的排序算分操作。

  也就是說這裡存在一個資料格式,作為“線下預處理操作”和“線上随機讀取”的資訊載體,以往我們的資料格式的序列化、反序列化邏輯,是開發者按需要“自編碼”實作的,如:

  serialize和deserialize的實作在此忽略一萬字,主要方式還是開發者自己決定每個成員變量序列、反序列化的次序。顯然這種方式是原始的,新增字段資訊需要編碼的程式猿用kpi的“可用性得分”去保證索引的相容性,開發和測試成本都很高。

  不可否認,自編碼這種方式是最透明的,序列化與反序列化的效率也是最高的,在業務邏輯相對簡單的早期,增删字段的節奏并沒有這麼快,這種方式的優點大于缺點。

  随着業務的快速發展,isearch5上線後dump與isearch運維耦合度越來越低,“線下預處理操作”和“線上随機讀取”的強關聯關系已經保證,增删字段後的索引相容性面臨的空前的壓力,這種原始方式的缺點反而遠大于優點。

  在這樣的情況下,我們在尋求一種doc特征存儲的資料格式,既能靈活增删字段,也能提供自反射機制靈活讀取需要的字段,同時不能對線上服務性能産生影響。在這時候,protobuf消息進入了我們的調研範圍……

  靜态消息,是指消息的字段格式是在靜态編譯期間就決定好,系統調用期間生産/消費的pb消息的格式是确定不變的;動态消息,是指消息的字段格式是在運作時動态加載的,系統調用期間生産/消費的pb消息格式是不确定的。

  這裡概念不一定準确,叫法也不一定通用,如有班門弄斧之嫌,請見諒,文中暫且如上了解“靜态消息”和“動态消息”兩種使用方式。

  如大家所知,一般分為3個步驟:

step 1: 編碼消息描述檔案

step2:用protoc工作将消息描述檔案生成生産/消費消息的c++源代碼

  生成的c++源代碼如下(此處隻摘取.pb.h的關鍵代碼,對.pb.cc的解讀後文有詳細介紹):

step3:生産/消費

  一般情況下,我們在應用過程中要麼選擇靜态消息,要麼選擇動态消息。所謂學以緻用,上面調研了靜态消息的動态消息的使用方式,在前述描述的問題背景下,我們應該怎麼選擇是我們要解決的問題。

  首先我們要先對比一下兩種方式的優缺點:

靜态消息優點在于protoc生成的靜态c++代碼對消息對象的讀寫效率非常高(下面有詳細資料對比),缺點是在運作前需要靜态編譯生成,代碼邏輯一旦上線,新增字段需要重新編譯釋出上線,“傳統”的字段讀寫方式不是自反射的方式,無法在運作時根據實際情況靈活選擇讀寫的字段。

動态消息的優點在于可以在運作時動态編譯proto格式描述檔案,在運作時自反射式地根據實際情況靈活選擇所要讀寫的字段,缺點是讀寫的效率“相對”較差,尤其是反序列化的效率相對靜态消息要差很多。

  資料采用的是目前排序線上用的真實資料,共950,000+的doc,即對應950,000+的pb消息對象,每個doc循環做50次序列化反序列,最終把序列化的總耗時和反序列化的總耗時計算出來,除以(50*總doc數),得到平均每個doc的序列化、反序列化耗時。

消息使用方式

平均每個doc的序列化耗時(ms)

平均每個doc的反序列耗時(ms)

序列化後平均每個doc的消息長度(位元組)

自編碼

0.000316604

0.000286629

94

靜态消息

0.000688785

0.000537664

135

動态消息

0.00428008

  可以看到,靜态消息的序列化和反序列化耗時,是自編碼的2倍左右;而動态消息的序列化和反序列化耗時,是自編碼的14倍左右;動态消息序列化和反序列化的耗時是靜态消息的7倍。從這個對比來看,如果在一次服務請求過程中需要進行大量的消息反序列化的話,動态消息明顯不是一個理想的選擇。

  回到我們前面提到的問題場景,由于一次排序請求下,我們背景需要對海量的doc特征執行反序列化操作,是以線上上算分時,我們不能采用動态消息,不然搜尋請求的qps會下降非常明顯。這要求我們線上上算分時,doc特征的資訊載體隻能選擇靜态消息了……

  然而如果按正常思路,線下特征生成階段也用靜态消息,會帶來無法靈活增删改特征的問題。因為即使pb消息本身很好地解決了新舊消息相容問題,但我們線下想增删特征時,按靜态消息的使用方式,代碼需要重新靜态編譯,走漫長的釋出流程(dump中gaia子產品釋出為什麼這麼慢這裡不細表),總之,線下不能按正常思路與線上一樣同時使用靜态消息。所幸dump對gaia的性能要求不高,是以一個doc使用動态消息去序列化,即使要花4+微秒(即使将來漲到10微秒),也是完全可以接受的。

  so,我們最終的解決方案是線下gaia用動态pb消息去存儲、序列化doc資訊,線上irank用靜态pb消息去反序列、存儲doc資訊:

線下,我們可以通過每天更新proto消息描述檔案的方式,不用走繁瑣的釋出就能決定哪些特征需要建到引擎的索引裡,提供了靈活性;

線上,我們使用靜态消息反序列化、存儲特征資訊,解決了索引相容問題,性能也沒有受到比較大的影響(我們通過一些工程改造把每個doc反序列化時丢失的0.3微秒追回來)。

  不過……現在還不是慶祝的時候,因為我們線上上選擇了靜态消息,意味着即使線下新增了特征,線上要使用這些特征依然重新編譯irank插件并走釋出;同時,由于靜态消息是通過protoc生成.pb.h、.pb.cc提供給調用方做靜态編譯使用的,如果我們在讀取特征時隻是使用*.pb.h裡提供的set和get函數去讀取字段值(如上面所示的m_fprodbizscore()、set_m_fprodbizscore(...)函數),意味着線上部分是不能運作時決定使用哪個字段,隻能在編譯時寫死,可配置化是沒戲了……

  為了解決這個問題,我們的目光回到前面介紹靜态消息時protoc生成的.pb.h和.pb.cc身上去,我們仔細閱讀一下protoc生成的*.pb.cc源代碼

  我們注意一下protobuf_assigndesc_aedocument_2eproto、protobuf_adddesc_aedocument_2eproto這兩個函數的實作,實際上前者執行時調用的後者:

protobuf_adddesc_aedocument_2eproto裡代碼是不是很眼熟,跟*.proto檔案中的字段類型描述是不是很像,其實這也是一個“動态編譯”消息schema并注冊到全局空間的過程,這個schema與aedocunent是完全等價;

protobuf_assigndesc_aedocument_2eproto的作用,簡單來說,就是通過protobuf_adddesc_aedocument_2eproto在全局空間下注冊一個名為“aedocument.proto”的filedescriptor,并把類aedocument中各成員變量基于對象起始位址的偏移量存儲起來。

  通過這兩個步驟,實際上在全局空間存儲了一個與aedocument完全等價的動态消息的schema,protobuf_assigndesc_aedocument_2eproto函數裡::google::protobuf::descriptorpool::generated_pool()->findfilebyname("aedocument.proto")這個函數調用也暴露了,我們第三方調用方完全可以用同樣的方式獲得這個與aedocument等價的filedescriptor。

filedescriptor對應一個.proto檔案,裡面包含了.proto檔案裡定義的所有message的schema資訊(即descriptor);

descriptor對應一個mesaage的schema資訊,裡面包含了這個message裡的所有字段的schema資訊(即fielddescriptor);

有了fielddescriptor,通過reflection我們就能運作時決定要通路message裡的任意一個字段,真正實作自反射。

  so,在靜态消息上如何使用自反射機制的解決方法找到了!

  當然了,線下新增特征,線上要使用新特征還是要更新*.proto,并重新編譯走一次釋出。所幸目前的自動化回歸測試機制能解決很多線上irank子產品釋出的成本,這種schema的變更和其他配置更新一同上線,還是輕量級的操作,所增加的成本并不明顯。

為了性能,我們線上不能選擇動态消息,schema不能通過配置的方式去更新,隻能走靜态編譯的方式去更新上線,但pb本身在靜态消息的實作過程中并沒有抛棄動态消息的自反射機制,而是通過一種等價的方式在*.pb.cc裡實作了,我們通過這種新的途徑可以繼續享用自反射的便利,同時索引相容和反序列化的性能得到的保證;

線下我們在反序列化并不是瓶頸的地方,通過使用動态消息達到了靈活增删特征的效果。

  理想是豐滿的,現實是骨感的,在現實中我們總能找到一個平衡點~

參考:

繼續閱讀