Protobuf使用不当导致的程序内存上涨问题

标签: 专项测试 Debug | 发表时间:2014-11-17 12:15 | 作者:百度质量部
出处:http://qa.baidu.com/blog

作者:祝兴昌   百度质量部

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字段的用法。其主要的使用过程如下:

// in header file class ModuleA { public:      run_res_t reset();

run_res_t run();

 

private:

     proto::feature_t   _merged_data;    // contains the merged feature

}; // class ModuleA

 

//  in cpp file

run_res_t ModuleA::reset() {

    // the following reset op-s are for feature merging

    _merged_data.Clear();

}

 

run_res_t FeatureModule::run(){

    fc_list_t* p_mg_fc_list = _merged_data.mutable_fea_cluster_list();

    proto::fea_cluster_t* p_mg_fc = p_mg_fc_list->Add();

    p_mg_fc->set_fea_cluster_type(static_cast <

            proto::fea_cluster_t_fea_cluster_type_t >( DEMO_TYPE));

    demo_fea_list_t* p_mg_demo_fea_list = p_mg_fc->mutable_demo_fea_list();

    proto::demo_fea_t* p_mg_demo = p_mg_demo_fea_list->Add();

    p_mg_demo->set_type(DEMO_TYPE);

     // 此处省略50行

}

通过上面的代码,我们可以看到_merged_data字段,在run函数中会向里面插入数据,在reset函数中会调用Clear方法对数据进行清理。结果监控中发现的_merged_data占用的内存空间不断的变大。通过查阅protobuf clear函数的介绍,我们发现: protobuf 的message在执行clear操作时,是不会对其用到的空间进行回收的,只会对数据进行清理。这就导致线程占用的数据越来越大,直到出现理论上的最大数据后,其内存使用量才会保持稳定。

我们可以得到这样一个结论:protobuf的clear操作适合于清理那些数据量变化不大的数据,对于大小变化较大的数据是不适合的,需要定期(或每次)进行delete操作。

图1反映出模块中一些主要protobuf message的变化情况。baseline-old是程序启动后的内存情况。baseline-new是程序启动6小时后的内存情况,可以看到所有的数据结构内存占用量都有增加。并且大部分的数据都有大幅的增加。

图1 线程数据变化情况


5.   问题解决

在了解了问题的原因后,解决方案就比较简单了。代码如下:

// in header file class ModuleA { public:      run_res_t reset();

run_res_t run();

 

private:

      proto::feature_t         _merged_data;    // contains the merged feature

}; // class ModuleA

 

//  in cpp file

run_res_t ModuleA::reset() {

// the following reset op-s are for feature merging

  //_merged_data.Clear();

  _merged_data.reset(new(std::nothrow)proto::feature_t);

  if  (!_merged_data.get()) {

     FATAL_LOG("new fea_header_t failed");

     return  RUN_ERROR;

  }

}

优化的代码中,在每次reset的时候,都会调用scoped_ptr的reset操作,reset会delete指针指向的对象,然后用新的地址进行赋值。优化后的效果如图2所示。newversion-old是优化版本启动1小时候的数据,newversion-latest是优化版本启动6小时后的数据。可以看到从绝对值和上涨量上,优化效果都非常明显。

图2 优化版本线程数据使用对比

这个优化方法可能存在一个问题:那就是每次进行reset时,都会对数据进行析构,并重新申请内存,这个操作理论上是非常耗时的。内存优化后,可能会导致程序的CPU消耗增加。具体CPU的变化情况还需要在测试环境中验证。


6.   问题验证

优化版本的表现情况如图3。

图3 优化版本内存使用情况

图3中蓝色线条是优化版本,红色线条是基线版本。可以发现优化版本的内存上涨明显变慢。

图4 优化版本CPU使用情况

图4显示的是优化版本与基线版本的CPU IDLE对比情况。可以看到优化版本的CPU IDLE反而更高,CPU占用变少了。一个合理的解释是:当protobuf的messge数据量非常大时,其clear操作消耗的CPU比小message的析构和构造消耗的总的CPU还要多。

下面是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函数调用关系

通过上面的代码及图5可以看出,Clear操作采用了递归的方式对Message中的逐个字段都进行了处理。对于基础类型字段,代码会对每个字段都设置默认值。对于一个非常长大的Message来说,消耗的CPU会非常多。相对于这种情况,释放Message的内存并重新申请小的空间,所占用CPU资源反而更少一些。在这个Case中,经常出现Clear操作清理6、7M内存的情况。这样数据量的Clear操作与释放Message,再申请200K Message空间比起来,显然更消耗CPU资源。


7.   总结

protobuf的cache机制

protobuf message的clear()操作是存在cache机制的,它并不会释放申请的空间,这导致占用的空间越来越大。如果程序中protobuf message占用的空间变化很大,那么最好每次或定期进行清理。这样可以避免内存不断的上涨。这也是模块内存一直上涨的核心问题。

内存监控机制

需要对程序的各个模块添加合适的监控机制,这样当某个module的内存占用增加时,我们可以及时发现细节的问题,而不用从头排查。根据这次的排查经验,后面会主导在产品代码中添加线程/module级内存和cpu处理时间的监控,将监控再往”下”做一层。

UT在内存问题定位中的作用

在逐个对module进行排查时,UT验证比在测试环境中更高效,当然前提是这些module的UT能够比较容易的写出来。这也是使用先进框架的一个原因。对于验证环境代价高昂的模块,UT验证的效果更加明显。

 

【参考文献】

[1] http://code.google.com/p/protobuf

相关 [protobuf 程序 内存] 推荐:

Protobuf使用不当导致的程序内存上涨问题

- - 百度质量部 | 软件测试 | 测试技术 | 百度测试|QA
作者:祝兴昌   百度质量部. protocol buffers[1]是google提供的一种将结构化数据进行序列化和反序列化的方法,其优点是语言中立,平台中立,可扩展性好,目前在google内部大量用于数据存储,通讯协议等方面. PB在功能上类似XML,但是序列化后的数据更小,解析更快,使用上更简单.

jprotobuf 1.0.3发布,简化java程序员对google protobuf的应用

- - 开源软件 - ITeye博客
jprotobuf是针对Java程序开发一套简易类库,目的是简化java语言对protobuf类库的使用. 使用jprotobuf可以无需再去了解.proto文件操作与语法,直接使用java注解定义字段类型即可. JProtobuf官方网址: https://github.com/jhunters/jprotobuf .

集成libevent,google protobuf的RPC框架

- goodman - C++博客-那谁的技术博客
chenshuo的evproto同样也是集成libevent与google protobuf的RPC框架,不过在对libevent的使用上,这里的做法与他不尽相同:. 1) 他使用了libevent自带的RPC功能, 而这里只使用到libevent对网络I/O进行的封装的最基本的功能.. eventrpc项目目前是avidya下的一个子项目,avidya项目的定位是实现一些分布式的玩具系统(比如google已经公开论文的chubby,mapreduce,GFS等),也许以后不一定能被用上,但是也要实践做一把.由于有一个好用的RPC框架是做分布式的必需品,所有首先实现eventrpc这个子项目了,以后也许还会实现其他语言的版本,如python,java..

protobuf,json,xml,binary,Thrift之间的对比

- - 学习笔记
一条消息数据,用protobuf序列化后的大小是json的10分之一,xml格式的20分之一,是二进制序列化的10分之一,总体看来ProtoBuf的优势还是很明显的. protobuf是google提供的一个开源序列化框架,类似于XML,JSON这样的数据表示语言,详情访问protobuf的google官方网站 https://code.google.com/p/protobuf/.

通过netty提供Protobuf服务

- - ITeye博客
1、下载安装Protocol Buffer.     >下载地址: http://code.google.com/p/protobuf/.     >需要下载文件:protobuf-2.5.0.tar.gz(也可以直接下载protobuf-java-2.5.0.jar;这里通过maven生成);protoc-2.5.0-win32.zip(windows平台需要).

让protobuf生成器支持时间戳检查

- Ease - C++博客-首页原创精华区
使用protobuf的生成器可以对proto文件进行解析后生成指定的目标语言代码.随着项目的不断扩大, 协议修改变的非常频繁, 因此每次重编变的异常耗时. 模仿C/C++编译器的时间戳检查生成机制,我给protobuf生成器添加了时间戳检查功能. 此功能不是必须的, 可以通过命令行指定—timestampfile FILE 来指定要生成的proto文件对应的时间戳.

zmq-rpc:基于zeromq网络层编写的protobuf RPC框架

- Shengbin - codedump
阅读过zmq的代码之后,感觉这个网络层是我目前见过最高效的–线程之间使用lockfree的消息队列保存消息,可以启动多个I/O线程分担压力等等特性.于是决定基于它写一个protobuf RPC的框架.. 另外,这里使用的protobuf是旧版本2.3.0,新版本2.4.1的生成的RPC service接口跟原来不太一致,暂时还没有去研究它.BTW,升级版本之后导致原来的接口发生变化这是一个很操蛋的事情..

Protobuf在腾讯数据仓库TDW的使用

- - 标点符
protobuf是google提供的一个开源序列化框架,类似于XML、JSON这样的数据表示语言,其最大的特点是基于二进制,因此比传统的XML表示高效短小得多. 虽然是二进制数据格式,但并没有因此变得复杂,开发人员通过按照一定的语法定义结构化的消息格式,然后送给命令行工具,工具将自动生成相关的类,可以支持java、c++、python等语言环境.

数据交换格式protobuf/json/xml/binary/Thrift

- - 互联网旁观者
一条消息数据,用 protobuf序列化后的大小是 json的10分之一, xml格式的20分之一,是 二进制序列化的10分之一,总体看来ProtoBuf的优势还是很明显的. protobuf是google提供的一个开源序列化框架,类似于XML,JSON这样的数据表示语言,详情访问 protobuf的google官方网站.

Google 发布 Eclipse 编辑器的协议缓冲器插件 Protobuf-dt

- tinda - ITeye资讯频道
Protobuf-dt是Google针对Eclipse编辑器开发的协议缓冲器插件,提供所有IDE编辑器所具备的功能,包括语法高亮、大纲视图、内容协助和超链接. Protobuf-dt还可提供跟协议缓冲器有关的功能,包括自动生成数字标签、Java式的文档和protoc整合. Google员工之前已经在多个内部工程里测试并使用了Protobuf-dt,所以你大可放心.