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

被刻印的时光 ゝ 提交于 2019-11-30 08:26:54
       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还要多。

下面是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操作清理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验证的效果更加明显。

       百度MTC是业界领先的移动应用测试服务平台,为广大开发者在移动应用测试中面临的成本、技术和效率问题提供解决方案。同时分享行业领先的百度技术,作者来自百度员工和业界领袖等。
       >> 如有问题,欢迎与我沟通
标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!