JVM频繁GC会严重占用CPU资源,延长响应时间,用户体验极差,甚至系统崩溃。这里记录一下之前我司线上系统出现过的真实案例。
首先我们公司主营业务是对某个市的各个企业进行污染监控,一个服务于政府的BI系统。
所谓BI系统,就是商业智能(Business Infomation),当时将商业智能定义为一类由数据仓库(或数据集市)、查询报表、数据分析、数据挖掘、数据备份和恢复等部分组成的、以帮助企业决策为目的技术及其应用。
简单来说,就是一个数据可视化平台,需要定时把上千个企业的污染监控数据接入数据库,基于这些数据我们需要为政府提供数据报表,如每天有多少过往车辆?每天车辆排污报警有多少?企业的监控设备在线率?
当然实际情况会复杂很多,需要将污染数据源进行二次计算才能统计,让政府进行更好的环境管控,以此提升市的形象。
Garbage Collection ,Java虚拟机中堆分为新生代、老年代、永久代(1.8及以后已经变为元空间,抽取到了直接内存的一部分),其中新生代又分为Eden、S0、S1三个区域。
假设当前的Survivor对象都在S0,在新生代进行垃圾回收,如果Eden区域的对象能够存活就会进入S1,并且存活年限设置为1,并且S0中的对象年限也++,同时把S0中的所有都移动到S1,当然此时S0中的对象年限如果超过了某个阈值就会被直接移动到
老年代。
对整个堆进行垃圾回收
如果Eden区域剩余空间足够,那么对象优先会在Eden区进行分配;否则会比较之前的平均晋升大小与当前老年代的剩余空间,如果平均晋升大小都大于当前老年代的剩余空间,就直接触发Full GC,否则会触发Minor GC。
这里要讲述的系统就是一个BI系统,刚开始系统设计的时候,企业很少,数据量不大,大概也就200家企业接入到系统当中,所以系统设计很简单,通过前端调用getAllData(),后端将数据库查询之后的数据在Service层进行计算,最后返回给前台。
一个getAllData()接口对数据返回,不仅该接口功能冗余,需要统计很多小模块的数据,同时一个模块在处理的过程中出现了问题就可能导致整个接口调用失败,导致整个面板所有小模块都没有数据,这显然不可以接受
一个接口进行了很多的计算,虽然通过CompletableFuture进行了计算的异步编排,但是最后的响应时间还是取决于最慢的计算。
我将getAllData()接口进行拆分,拆分出7个子面板接口,每个子面板都通过ajax调用后台不同的接口,这样一来,即使有的计算很慢,也不会影响其他数据的返回,可以优先进行前端展示。
上面进行简单重构之后,由于刚开始系统数据量不大,所以问题没有展现出来。但是一星期后企业录入1000多家之后,数据库中污染数据三天内就来到了20-30W后,在查询当天的数据的时候,尤其是在频繁刷新页面的情况下,系统就开始了卡顿。立即对生产日志进行了排查,发现如下所示:
通过jstat -gc pid
查看该应用程序中JVM中堆的垃圾收集情况的统计,发现YGC、FGC高达300次左右,这时候突然意识到之前的版本迭代之后,7个接口拆分之后,原本10W多的数据读入内存7次都需要70多万,加上途中计算的时候产生的对象以及页面的频繁刷新,显然会导致频繁的GC。
- S0C:年轻代中S0的容量 (字节)
- S1C:年轻代中S1的容量 (字节)
- S0U:年轻代中S0目前已使用空间 (字节)
- S1U:年轻代中S1目前已使用空间 (字节)
- EC:年轻代中Eden的容量 (字节)
- EU:年轻代中Eden目前已使用空间 (字节)
- OC:Old代的容量 (字节)
- OU:Old代目前已使用空间 (字节)
- MC:metaspace(元空间)的容量 (字节)
- MU:metaspace(元空间)目前已使用空间 (字节)
- CCSC:当前压缩类空间的容量 (字节)
- CCSU:当前压缩类空间目前已使用空间 (字节)
- YGC:从应用程序启动到采样时Minor GC次数
- YGCT:从应用程序启动到采样时Minor GC所用时间(s)
- FGC:从应用程序启动到采样时发生Full GC次数
- FGCT:从应用程序启动到采样时Full GC所用时间(s)
- GCT:从应用程序启动到采样时gc用的总时间(s)
先-Xms2G -Xmx2G 调大堆内存大小暂时让系统能运行,之后将这些接口中原本的Service中数据统计都放入数据库中,不仅返回的数据少了,内存的使用更优了,而且相比之前减少了系统与数据值之间的IO次数,减少了响应时间。
Oracle就是为了生产环境的稳定性才控制fetchSize,默认是10。显然一次读取十万数据到数据库已经不是一个好的设计理念了,同时由于fetchSize的限制,会导致10W条数据的读取需要花7-10S时间。
同时在Dao层进行数据的统计计算,Service层进行业务逻辑的处理,进一步进行系统解耦。
注意在这个重构的过程中,必须要保证数据计算不会出现问题,要保证前后的数据一致性,计算逻辑必须要正确。
当解决第一个痛点的时候,由于方法高度内聚,所以很好进行测试,重构花费时间很短。首先不要动之前的计算逻辑的代码,这样方便后面对这些新老方法进行数据对拍,防止出现计算错误。
之后将一个老方法以一个全新的命名放入Service接口,这时候ServiceImpl实现类必然会报错,需要补上具体实现,也就是将老方法直接原封不动的复制到新方法即可,只是老方法的入参(经过预处理的数据),在新方法中要复制进去,重新进行查询进行完全相同的数据预处理。
之后必须对新老方法进行JUnit测试,这是一个逐步推进的过程,每次新编写的方法通过测试,就将老方法进行删除,直到重构完成。
解决第二个痛点的时候,同样由于方法高度内聚,所以很好进行测试,只是要进行SQL语句的编写。同样不要动之前的计算逻辑的代码,方便后续测试。
这时候需要SQL写完之后,在Dao层编写与原方法相同名字的接口,这时候Dao层会报错,需要在相应的xml中补上具体实现,也就是把写好的SQL直接复制到xml中即可,只要把查询参数简单修改即可。
编写完毕之后立即进行JUnit的测试,通过Dao层返回的数据与之前方法的统计值进行比较,只有通过测试了,才能进行方法替换,这里因为方法高度内聚,只要将原本的方法前加上XXXDao.即可。同样这是一个逐步推进的过程,每次新编写的SQL需要通过测试,才能将老方法进行删除,直到重构完成。
后续数据库数据量进一步增大之后,假设每小时接入一次数据,那么要求实时性不高的情况下,我会考虑2-4小时进行一次后台报表的生成,自动插入到一张专门的小时数据库报表中,以此进一步优化性能。
代码编写的时候,尤其是逻辑复杂的计算的时候,需要进行代码的规范性,如变量命名、必要的注释、方法的高度内聚等。这些无一例外会提升代码的可阅读性以及可维护性。
同时重构应该考虑前后的一致性,尤其要注意测试用例的编写。
系统设计之初,可能会留下隐患的地方需要时刻关注,为了快速交付版本,可以先简单进行实现,但是那些需要注意的坏的味道的代码最好留下必要的注释如:
// TODO 数据量一大,需要XXXX重构
最后说一点,写完代码反复翻阅,不断自己私下进行改进,进行重构,这不是一种工作量的提升,其实更是一种对系统的理解,也方便后面的维护。