protocol buffers[1]是google提供的一種將結構化數據進行序列化和反序列化的方法,其優勢是語言中立,平臺中立,可擴展性好,目前在google內部大量用於數據存儲,通信協議等方面。PB在功能上相似XML,可是序列化後的數據更小,解析更快,使用上更簡單。用戶只要按照proto語法在.proto文件中定義好數據的結構,就可使用PB提供的工具(protoc)自動生成處理數據的代碼,使用這些代碼就能在程序中方便的經過各類數據流讀寫數據。PB目前支持Java, C++和Python3種語言。另外,PB還提供了很好的向後兼容,即舊版本的程序能夠正常處理新版本的數據,新版本的程序也能正常處理舊版本的數據。
筆者在項目的測試過程當中,遇到了一個protocal buffer使用不當卻是的模塊內存不斷上漲的問題。這裏和你們分享一下問題的定位、分析以及解決過程。
1. 問題現象
5月,出現問題的模塊(如下成爲模塊)內存有泄露的嫌疑,表現爲程序在啓動後內存一直在緩慢的上漲。因爲該模塊天天都存在重啓的操做,所以沒有帶來較大的影響。
8月,發現線上模塊的內存上漲速度加快。
9月,模塊線上出現內存報警。內存使用量從啓動時的40G,在70小時左右上漲到50G,因爲會出現OOM的風險,模塊不得不頻繁重啓。
9月底,模塊的某個版本上線後,因爲內存使用量稍有增長,致使程序在啓動後不到24小時內就出現內存報警,線上程序的穩定受到很是大的影響。線上程序回滾,而且中止該模塊的全部功能迭代,直到內存問題解決爲止。
模塊是整個系統最核心的模塊,業務的中止迭代對產品的研發效率影響巨大。問題亟需解決!
2. 問題復現
出現這種問題後,首先要作的就是在線下復現問題,這樣才能更好的定位問題,而且可以快速的驗證問題修復的效果。可是通過多天的嘗試,在QA的測試環境中,模塊的內存表現狀況均與線上不一致。具體表現爲:
1)線上模塊的內存一直在上漲,直到機器內存耗盡,模塊重啓;線下模塊的內存在壓力持續若干小時後就趨於穩定,再也不上漲。
2)線下環境中,模塊的內存上漲速度沒有線上快。
出現這兩種狀況的緣由後面再解釋。線上線下表現的不一致給問題的復現和效果驗證帶來了必定的困難。但好在在線下環境中內存使用量依然是上漲的,能夠用來定位問題。
3. 模塊定位
小版本間升級點排查。對於這個內存上漲已存在數月的模塊來講,要直接定位問題的難度是很是大的,並且投入會十分巨大。爲了使模塊的功能迭代儘快開始,最初咱們將定位的焦點聚焦於近期模塊上線的功能排查。寄但願於經過排查這些數量較少的升級,發現對內存的影響。通過2天的排查,沒有任何的發現。
結合該模塊內存的歷史表現和近期升級功能的排查結果,咱們認爲模塊的內存增加極可能不是泄露,而是某些數據在不斷的調用過程當中不斷的增大,從而致使內存不斷的上漲。理論上,通過足夠長的時間後程序的內存使用是能夠穩定的。可是受限於程序的物理內存,咱們沒法觀察到內存穩定的那一刻。
排除數據熱加載致使的內存泄露。在線下環境中,全部的數據文件都沒有更新,所以排除了數據熱加載致使的內存泄露。
各模塊逐步排查。小版本間的升級點排查無果後,咱們將排查的方法調整爲對程序內的各個子模塊(簡稱module)逐個排除的方法。模塊的module共有13個,若是逐個查,那麼消耗的時間會特別多。在實施的過程當中採用了二分法進行分析。具體的是 某個module爲中間點,將該module及之後的模塊去掉,來觀察模塊的內存變化狀況。在去掉中間module(含)以後的模塊後,發現內存的上漲速度降低了30%,說明該module以前的模塊存在70%的泄露。經過分析這些模塊,發現某個module (簡稱module A) 的嫌疑最大。
經過UT驗證內存上漲狀況。在以前肯定主要泄露module的過程當中,咱們採用在真實環境中進行驗證的方法。這個方法的缺點是時間消耗巨大。啓動程序,觀察都須要消耗很長的時間,一天只能驗證一個版本。爲了加快問題的驗證速度,並結合模塊的特色,咱們採用了寫UT調用module的方法進行驗證。每次驗證的時間只須要30分鐘,使得問題驗證速度大大加快。
部署監控,定位問題。經過寫UT,咱們排除了module A中的兩個子module。而且,咱們發現module A單線程的內存上漲速度佔線上單線程上漲量的30%,這個地方極可能存在着嚴重的問題。在UT中,咱們對這個module中最主要的數據結構merged_data(存儲其包含的子module的特徵數據)進行了監控。咱們發現,merged_data這個數據結構的內存一直上漲,上漲量與module A總體的量一致。到此,咱們確認了merged_data這種類型的結構存在內存上漲。而這種類型的數據結構在模塊中還有不少,咱們合理的懷疑整個模塊的內存上漲都是這種狀況致使的。
4. 問題分析
咱們先看下module A中merged_data字段的用法。其主要的使用過程以下:
經過上面的代碼,咱們能夠看到_merged_data字段,在run函數中會向裏面插入數據,在reset函數中會調用Clear方法對數據進行清理。結果監控中發現的_merged_data佔用的內存空間不斷的變大。經過查閱protobuf clear函數的介紹,咱們發現:protobuf的message在執行clear操做時,是不會對其用到的空間進行回收的,只會對數據進行清理。這就致使線程佔用的數據愈來愈大,直到出現理論上的最大數據後,其內存使用量纔會保持穩定。
咱們能夠獲得這樣一個結論:protobuf的clear操做適合於清理那些數據量變化不大的數據,對於大小變化較大的數據是不適合的,須要按期(或每次)進行delete操做。
圖1反映出模塊中一些主要protobuf message的變化狀況。baseline-old是程序啓動後的內存狀況。baseline-new是程序啓動6小時後的內存狀況,能夠看到全部的數據結構內存佔用量都有增長。而且大部分的數據都有大幅的增長。
5. 問題解決
在瞭解了問題的緣由後,解決方案就比較簡單了。代碼以下:
優化的代碼中,在每次reset的時候,都會調用scoped_ptr的reset操做,reset會delete指針指向的對象,而後用新的地址進行賦值。優化後的效果如圖2所示。newversion-old是優化版本啓動1小時候的數據,newversion-latest是優化版本啓動6小時後的數據。能夠看到從絕對值和上漲量上,優化效果都很是明顯。
這個優化方法可能存在一個問題:那就是每次進行reset時,都會對數據進行析構,並從新申請內存,這個操做理論上是很是耗時的。內存優化後,可能會致使程序的CPU消耗增長。具體CPU的變化狀況還須要在測試環境中驗證。
6. 問題驗證
優化版本的表現狀況如圖3。
圖4顯示的是優化版本與基線版本的CPU IDLE對比狀況。能夠看到優化版本的CPU IDLE反而更高,CPU佔用變少了。一個合理的解釋是:當protobuf的messge數據量很是大時,其clear操做消耗的CPU比小message的析構和構造消耗的總的CPU還要多。 c#
下面是Clear操做的代碼。 數組
void ReflectionOps::Clear(Message* message) { const Reflection* reflection = message->GetReflection(); vector<const FieldDescriptor*> fields; reflection->ListFields(*message, &fields);
for (int i = 0; i < fields.size(); i++) {
reflection->ClearField(message, fields[i]);
}
reflection->MutableUnknownFields(message)->Clear();
}
//ClearField函數的實現
void GeneratedMessageReflection::ClearField(
Message* message, const FieldDescriptor* field) const {
USAGE_CHECK_MESSAGE_TYPE(ClearField);
if (field->is_extension()) {
MutableExtensionSet(message)->ClearExtension(field->number());
} else if (!field->is_repeated()) { // 若是不是數組,也就是基礎類型
if (HasBit(*message, field)) {
ClearBit(message, field);
// We need to set the field back to its default value.
switch (field->cpp_type()) {
#define CLEAR_TYPE(CPPTYPE, TYPE)
case FieldDescriptor::CPPTYPE_##CPPTYPE:
*MutableRaw<TYPE>(message, field) =
field->default_value_##TYPE();
break;
CLEAR_TYPE(INT32 , int32 ); // 對基礎類型設置爲默認值
CLEAR_TYPE(INT64 , int64 );
CLEAR_TYPE(UINT32, uint32);
CLEAR_TYPE(UINT64, uint64);
CLEAR_TYPE(FLOAT , float );
CLEAR_TYPE(DOUBLE, double);
CLEAR_TYPE(BOOL , bool );
#undef CLEAR_TYPE
case FieldDescriptor::CPPTYPE_ENUM: // 處理枚舉類型
*MutableRaw<int>(message, field) =
field->default_value_enum()->number();
break;
case FieldDescriptor::CPPTYPE_STRING: {
switch (field->options().ctype()) {
default: // TODO(kenton): Support other string reps.
case FieldOptions::STRING:
const string* default_ptr = DefaultRaw<const string*>(field);
string** value = MutableRaw<string*>(message, field);
if (*value != default_ptr) {
if (field->has_default_value()) { // 若是有默認值,則設置爲默認值
(*value)->assign(field->default_value_string());
} else {
(*value)->clear(); // 不然設置清理數據
}
}
break;
}
break;
}
case FieldDescriptor::CPPTYPE_MESSAGE:
(*MutableRaw<Message*>(message, field))->Clear();
break;
}
}
} else {
switch (field->cpp_type()) {
#define HANDLE_TYPE(UPPERCASE, LOWERCASE)
case FieldDescriptor::CPPTYPE_##UPPERCASE :
MutableRaw<RepeatedField<LOWERCASE> >(message, field)->Clear();
break
HANDLE_TYPE( INT32, int32);
HANDLE_TYPE( INT64, int64);
HANDLE_TYPE(UINT32, uint32);
HANDLE_TYPE(UINT64, uint64);
HANDLE_TYPE(DOUBLE, double);
HANDLE_TYPE( FLOAT, float);
HANDLE_TYPE( BOOL, bool);
HANDLE_TYPE( ENUM, int);
#undef HANDLE_TYPE
case FieldDescriptor::CPPTYPE_STRING: {
switch (field->options().ctype()) {
default: // TODO(kenton): Support other string reps.
case FieldOptions::STRING:
MutableRaw<RepeatedPtrField<string> >(message, field)->Clear();
break;
}
break;
}
case FieldDescriptor::CPPTYPE_MESSAGE: {
// We don't know which subclass of RepeatedPtrFieldBase the type is,
// so we use RepeatedPtrFieldBase directly.
MutableRaw<RepeatedPtrFieldBase>(message, field)
->Clear<GenericTypeHandler<Message> >();
break;
}
}
}
}
經過上面的代碼及圖5能夠看出,Clear操做採用了遞歸的方式對Message中的逐個字段都進行了處理。對於基礎類型字段,代碼會對每一個字段都設置默認值。對於一個很是長大的Message來講,消耗的CPU會很是多。相對於這種狀況,釋放Message的內存並從新申請小的空間,所佔用CPU資源反而更少一些。在這個Case中,常常出現Clear操做清理六、7M內存的狀況。這樣數據量的Clear操做與釋放Message,再申請200K Message空間比起來,顯然更消耗CPU資源。 數據結構
7. 總結
protobuf的cache機制
protobuf message的clear()操做是存在cache機制的,它並不會釋放申請的空間,這致使佔用的空間愈來愈大。若是程序中protobuf message佔用的空間變化很大,那麼最好每次或按期進行清理。這樣能夠避免內存不斷的上漲。這也是模塊內存一直上漲的核心問題。
內存監控機制
須要對程序的各個模塊添加合適的監控機制,這樣當某個module的內存佔用增長時,咱們能夠及時發現細節的問題,而不用從頭排查。根據此次的排查經驗,後面會主導在產品代碼中添加線程/module級內存和cpu處理時間的監控,將監控再往」下」作一層。
UT在內存問題定位中的做用
在逐個對module進行排查時,UT驗證比在測試環境中更高效,固然前提是這些module的UT可以比較容易的寫出來。這也是使用先進框架的一個緣由。對於驗證環境代價高昂的模塊,UT驗證的效果更加明顯。
百度MTC是業界領先的移動應用測試服務平臺,爲廣大開發者在移動應用測試中面臨的成本、技術和效率問題提供解決方案。同時分享行業領先的百度技術,做者來自百度員工和業界領袖等。
>>
若有問題,歡迎與我溝通