Author:放翁(文初)
Email:fangweng@taobao.com
这里分享的是一个分布式分析系统的Master内存消耗状况的优化,有些比较特定的优化未必适用于其他系统,但是从这一系列优化过程中,应该能带给其他系统在做设计时提前考虑一点优化点。
下面先描述一下背景,看了背景可以对后续的优化点可以比较清楚一些,注意,部分设计仅适用于大量计算中,会牺牲可维护性来换取性能提升。最后一点优化应该是比较有通用性意义的。
背景:
开放平台每天产生大量的调用日志,希望能够从海量日志中即时的去分析业务指标和系统运行状况。当前实现的是类似MapReduce的设计,不过Master与Slave之间是松耦合的关系,比传统的MapReduce更利于扩展和即时分析(当然和Hadoop的目标是不一致的,规模量及作用也不同,主要用于统计规则易变,需要即时分析,数据量在T以内),同时内嵌了统计规则引擎,使得统计逻辑只需要通过配置即可实现分析定义。具体的部署图和流程图如下:
流程如下:
Master与Slave之间没有注册和管理的关系,Slave连接到Master请求任务,从任务中获取数据来源,分析规则配置,获取数据块大小的信息,然后将数据块拉下来分析,最后返回结果给Master。至此,Master与Slave的交互完成。
Master自身主要负责:
1. 任务列表创建和重置(根据配置信息创建任务列表,由于是增量对应用服务器分析,则定期将任务全部重置,可以让Slave增量的对应用服务器去拖取数据分析)。
2. 重置一些分配出去但是较久都没有执行的任务,防止Slave任务执行失败没有反馈而出现死等的现象。
3. 合并Slave传递过来已完成的任务结果集到主干结果集上。
4. 将主干结果集定时输出提供给第三方使用(告警,图形化等),导出中间结果,提供给Master异常重启后回复现场使用。
问题:
由于报表配置增多,单报表的结果数据量大,Master合并多个结果集内存吃紧,不断的GC,最后导致恶性循环。因此优化原先认为任务不重的Master迫在眉睫。
优化过程:
1. 合并过程中,主干结果和Slave结果都比较大,在操作后是否可以通过主动clear和set null来更快的清理释放资源。(基本没有效果,GC已经做了很多优化)
2. 分析器是基于定义去分析出<key,value>结果集合,然后根据配置将key相同的结果串联成为key,value1,value2,value3(这就是传统的报表结果)。发现有一些配置的<key,value>规则在实际输出报表的时候没有被使用,因此在构建分析规则的时候直接过滤这部分配置。(也就是在实现很多系统的时候,有些结果是中间结果,中间结果是否需要如果在系统启动时就能判断,就将这些中间结果计算的逻辑过滤掉,节省计算资源和内存资源,同时可以有一些提示,可能是系统配置中的错误导致这部分数据没有被用)
3. 系统中很多地方都用到Calendar来处理一些日期相关的内容,比如说想获取年月日时分秒的数据来做Action,比如通过格式化内容然后作为输出归类。由于Calendar是线程不安全的,因此不得不大规模的去构造和使用,其实内存消耗较大。
改造方式:能够用long的时候全部用long来处理,System.currentTimeMillis有消耗,但很小。如果要计算年月日时分秒可以用除法取余来做(注意计算天的时候要考虑中国时差8个小时)。同时如果是中间结果然后后续也要输出,由于输出需要便于用户查看,所以希望格式化,建议系统内部还是保持数字型,直到输出时做一次格式化处理。(不过这点取决于场景中是中间结果被输出和内部使用的频率,如果内部使用较少,有大量多次复用输出,则可以内部处理好,避免多次格式化)
4. 观察了一段时间,发现Slave处理结果在高峰期每次返回还有5-6M甚至更高,这样对于Master在并发处理多个Slave时开销很大(接收缓存区随着Slave的增多和内容返回的增多而不断地增大),因此出于优化网络和接收发送缓存,都要求将Map后的数据作压缩。
改造方式:考虑使用QuickLZ这个简单的开源类来做压缩,但是由于用到了对象的Outputstream,则直接使用了Output的管道化方式,后来比较了一下,压缩效果两者不相上下,速度到没有再去比较,因为Output管道化效果较好,代码如下:
ByteArrayOutputStream bout = new ByteArrayOutputStream();
Deflater def = new Deflater(Deflater.BEST_COMPRESSION,false);
DeflaterOutputStream deflaterOutputStream = new DeflaterOutputStream(bout,def);
ObjectOutputStream objOutputStream = new ObjectOutputStream(deflaterOutputStream);
最后的ByteArrayOutputStream将会成为ByteBuffer的数据源。(压缩后,网络传输和接收缓冲消耗将降低,但当时没有是考虑一来数据原来不大,二来压缩消耗CPU,但现在的场景发生变化,因此不得不消耗CPU来节省内存。所以大家根据不同场景来优化,得失自己权衡)
5. 下面是一段NIO接收业务数据后的代码,平时看来很干净正规,但是在高并发大量数据的情况下就是一段恶魔代码。
byte[] content = new byte[receivePacket.getByteBuffer().remaining()];
receivePacket.getByteBuffer().get(content);
log.error("package content size :" + content.length);
ByteArrayInputStream bin = new ByteArrayInputStream(content);
修改后:
ByteArrayInputStream bin = new ByteArrayInputStream(
receivePacket.getByteBuffer().array(),receivePacket.getByteBuffer().position()
,receivePacket.getByteBuffer().remaining());
让输入流直接基于ByteBuffer来处理数据,而不是重新申请内存来拷贝出数据。其实在NIO的Buffer和Channel出来以后,由于和Steam的操作没有桥接的方式,因此很多时候都倾向于自己申请内存去读取然后再作为Stream的输入输出。(Buffer内部的很多方法是支持做镜像,子集等操作来最大限度复用内部数据流,因此需要仔细的去权衡是否可以复用,但是要注意的是复用的模式需要考虑仔细,否则读取和写入数据的游标就会相互影响)
6. Merge(Reduce)的压力分散。当前如果有50个Job,那么50个job的所有结果都需要Master来合并,其压力和内存消耗肯定很大,如果可以将多个job的结果在Slave上合并,那么就可以缓解Master的压力。因此给每个Slave配置了一个系统级参数,每次请求Master分配的最大Job个数。修改了Master与Slave直接的获取任务协议,可以申请要求多个job,Master根据任务完成情况返回小于等于请求个数的任务。Slave这边并行执行然后合并结果的机制其实一早就有,只是从当年分析大文件转向基于Http数据流增量分析后,没有充分利用Slave的并行处理能力。(这种设计很多,其实在SD会议上我说了几个简单的场景,TOP需要将业务返回的对象在格式化为标准的xml或者json方式,一种是TOP自身包揽处理,一种是将部分业务逻辑外移,将计算和内存消耗分担到更多的应用节点上,带来的问题是,升级外移的逻辑成本较高。集中处理的好处在于逻辑维护方便,一次处理多次使用。分担处理的好处在于充分利用更多资源来解决规模化问题)
7. 通过jstat的gcutil观察,发现Heap增长除了merge以外在报表输出时也有不小的波动,发现为了保证系统异常退出时能够在再次启动继续增量统计,每次重置任务列表并输出报表时就会导出内存数据对象,便于下次载入。现在每隔3分钟是任务重置期,也就是每隔3分钟都会导出中间结果一次,这个频率过高,因此将导出动作设置扩大,毕竟异常退出不是经常发生,同时支持命令主动导出。另一方面也采用压缩的方式对输出内容作处理,减少内存消耗和导出时间。(很多时候,我们会设计一些异常保护的策略和检查,但是不要让这样的工作成为系统的负担,通过放大尺度和接受主动即时处理,可以得到一样的效果)
8. 这点优化看起来很傻,但是效果却是明显的,其实说明了一样问题,就是一点细节可以让你的程序有很大的改观。
当前Map后的结果集格式为:Map<EntryId,Map<key,value>>,Entry就代表了一个<key,value>计算的定义。那多个结果集合并的处理方式为(下面是伪代码)
Map<EntryId,Map<key,value>>[] needMergeResult;//这是外部传入的需要合并的结果数组
Map<EntryId,Map<key,value>> result = new Map<EntryId,Map<key,value>>;//构建一个合并后的结果集
for ( j = 0 ; j < needMergeResult.size; j++) //遍历所有的结果集
{
Map<EntryId,Map<key,value>> node = needMergeResult[j];
Loop:遍历node所有的EntryId
{
Loop:遍历EntryId对应的Map
{
根据规则将key对应的value与needMergeResult[j+1].get(EntryId).get(key)到needMergeResult[needMergeResult.size-1].get(EntryId).get(key)的value做合并计算,然后移除needMergeResult[j+1].get(EntryId).get(key)到needMergeResult[needMergeResult.size-1].get(EntryId).get(key)对应的数据,避免后续外部循环重复计算
}
}
}
写了一大堆,其实没有优化算法(大家觉得合并算法如果有更好的可以告知我),做的优化就是红色那句被去掉了,也就是原来是构建一个新的结果集作为基础结果集,现在的做法是合并前先选择Base最大的结果集作为基础结果集,然后后续处理同样,这样其实省略了内存申请,合理的利用了已有的内存空间,同时这步也为最后的并行合并结果作了优化。
9. 除了线上运行期GC的观察以外,本地数据量小,但是也跑了master和slave用jprofiler观察了一下,发现程序中有大量的对ConcurrentMap size的检查,来做保护,来做一些行为判断,对于一个长期高并发处理的系统来说,也是有不少损耗的(ConcurrentMap内部为了提高效率是分片存储的,因此size不是一个简单的计数器),因此采用Atomic类型的原子计数器来替代,代价是程序复杂度增加。(这点优化其实也是依据场景而定,如果程序在这方面操作不频繁,简单的使用size方法更靠谱。需要说明的就是Java很多并发组件的内部实现并不是简单的处理,因此如果调用次数很多很频繁,可以考虑其他方式去实现)
10. Master的主线程阻塞式合并结果集的改变。从最上面的设计图中可以看出,起始的设计我就考虑用单线程阻塞模式来处理结果集的合并,原因很简单,所有的结果最终都是要合并到“主干”,因此无论如何合并的动作都会加锁,也就是串行化,与其这样,还不如简单的用单线程阻塞式处理。
现象:Slave处理并合并后的多个结果会不定期的到来,由于Slave分析后的数据量呈几个数量级的增长,原先的Master阻塞式合并时间也变长,此时挂在合并列表上的结果集也会增多(中间结果的生命周期增加,直接导致内存消耗增加),需要做的就是尽可能的减少由于处理满导致内存堆积消耗,提高Master内存利用率。
下面是考虑优化的过程:
1. 主线程只负责需要合并结果的分发,执行合并的为外部线程池。(带来的问题:多个线程如何并发的去合并到主干?采用锁的方式那么依然只有一个线程可以执行合并,依然是串行化操作)
2. 设计采用两类合并的设计。设计如下图:
1. Master主线程负责获取需要合并的结果集(包括原始Slave提交的结果集和后面会提到的被合并过的结果集)
2. Master主线程分发合并任务给线程池。
3. 线程池的执行线程执行前尝试获取主干锁。
4. 如果获得主干锁,则将所有结果集合并到主干结果集上。
5. 如果没有获得主干锁,则将结果集内部合并,并且将合并后的结果集放到队列中,等待再次合并。
首先,依托于上面谈起过的合并方式是基于某一个被合并的结果集来做,因此多组合并在资源消耗上可以接受(只是在计算的时候有所消耗),大量原始结果的并存可以被少量中间结果并存替代。其次,任何一次合并都不在等待与主干合并,可以实现并行化,与主干合并的动作没有做太多特殊处理,工作线程逻辑统一,无差别对待,提升线程池利用率。
带来的问题:由于Slave原始结果很难预期到来时间,Master的频繁小规模合并反而会带来负面效果,同时也出现了中间结果被反复的多次合并,浪费计算资源。
3. 根据2提出的问题,做了一些改进。首先给Master增加了两个系统参数,任务批量执行的最小数目和任务堆积等待最大时间,当在任务堆积最大等待时间之内,必须达到批量执行最小数目才可以提交线程池执行。(当发现当前的合并结果集+已经合并的结果集=所有结果总数,此条件无效)。其次,中间结果不参与到非主干合并的计算中,除非中间结果是最后需要合并的结果。修改后的流程图如下:
线上做了初步配置测试后,效果明显,内存利用率提高,释放速度加快,整体执行时间缩短。
其实这个优化点总结一下背后的实质性特点:当所有操作最后还是需要锁在一个瓶颈点上来做串行化操作时,最简单的方式就是串行化处理。(简单即高效)但是,某些场景下可以做部分优化:
1. 节省资源。如果在串行化操作前,并行处理能够减少资源消耗,那么在整体事务处理时间不变的情况下,资源可以得到充分利用(反向资源充足时也许可以提速系统处理能力,间接帮助提高事务处理时间)。
2. 节省预处理计算时间。简单来说就是磨刀不误砍材功。在前一阵写着关于任务切割,然后事件驱动的模式里面说到,任务切割后,最大的好处就是可以加速不同阶段消耗的资源释放,即并行化可以并行的操作。如果有10本电话簿,1个电话厅,那么打电话的人可以在电话亭外面先查好电话,然后进入电话亭直接打电话,因为查电话簿是可以小规模并行的,可以提升串行化处理的效率。
补充最后一点,在并行计算中很重要,在分析器的Slave设计中,在多线程处理任务中,尽量让工作者的逻辑无差别话,任务都自包含描述,工作者逻辑是通用逻辑引擎,这样对于与线程池或者不同机器进程来说,任务调度将是很简单也是容易扩展的。
优化其实看似都是很简单的内容,但是如何去观察问题,分析问题,解决问题,总结问题都是有很多技巧的,也只有会做好这几步,才可能去做优化,否则就是空谈。