Protobuf使用不当导致的程序内存上涨问题
更新:HHH   时间:2023-1-7


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

 

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

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

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

5.   问题解决

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

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

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

6.   问题验证

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


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

下面是Clear操作的代码。

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

7.   总结

protobufcache机制

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

内存监控机制

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

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

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


百度MTC是业界领先的移动应用测试服务平台,为广大开发者在移动应用测试中面临的成本、技术和效率问题提供解决方案。同时分享行业领先的百度技术,作者来自百度员工和业界领袖等。

>>如有问题,欢迎与我沟通

返回开发技术教程...