事件起因
事件源于生产环境的一个宕机问题,经过排查发现是系统后台管理中,导入excel更新数据的功能在使用时占用了大量内存,严重影响了系统业务的正常使用,并突破瓶颈导致系统宕机。
根据堆栈信息和线程信息定位到了具体的代码,问题原因是代码中对一张339万数据的表进行了全表查询,导致数据全部涌入内存。
当然,不得不佩服这位开发的"骚操作",但遗憾的是,经过几次代码迁移后已无法找到罪魁祸首,而且这个功能本身又属于低频使用的功能。好在我们及时进行了优化,并发布了优化后的版本。
然而,事情并没有就此结束。后续运维找到我,希望进一步优化这个功能,因为导入3000条数据就需要8分钟,而如果导入10000条数据,岂不是心态都要崩了。于是,我决定深入研究这个功能,找出导致性能瓶颈的原因,并寻求更优的解决方案。
功能介绍
这个功能位于系统后台管理中,属于低频使用的功能。它主要用于在用户决定更换对接银行时,导入数据并更新银行账户等信息。操作步骤如下:
-
先选择导入的数据条数(下拉选择,上面的需求就是想增加更大值的选项)。
-
上传包含变更前后数据的Excel文件。
-
程序执行导入操作,并将每条更新失败的数据及失败原因展示在页面列表中,并支持导出列表功能。
代码优化
原逻辑为:将一个单位的所有数据(估算一个单位的数据量为2.2w)全量查询出来,对查询的所有数据和excel中所有的数据进行匹配,获取需要更新的数据。代码如下:
// 1.根据单位id查询的数据 List<TEjzh> zhList = zhDAO.getzhByDwid(dwid); // 2.对excel中的数据进行去重 List<Map<Integer, String>> excellistpoint = getExcellistpoint(excellistpoint1); // 3. 对excel中的数据进行分组,分为超过导入条数的数据和未超过的 Map<String, List<Map<Integer, String>>> get300 = get300(excellistpoint); // 4. 未超过的,即需要更新的数据 List<Map<Integer, String>> excel = get300.get(NumberPubConstant.C_NUMBER_0); // 5. 超过的,即不需要更新的,并记录失败原因 List<Map<Integer, String>> list = get300.get(NumberPubConstant.C_NUMBER_1); // excel数据 for (int i = 0; i < excel.size(); i++) { if(ywService.checkYw(ajbh)){ // 6.判断是否有处理中的业务,有则跳过更新记录失败原因 } // 单位所有的账号数据 for (TEjzh tEjzh : zhList) { if (newZh.equals(cZh)) { // 7. 判断更新的新数据是否在库中已经存在,有则跳过更新记录失败原因 } else { // 8.根据Excel中的数据在表中数据是否存在,存在则更新相关数据,不存在则更新失败。 if (condition) { // 9.记录需要更新的数据 } else{ // 10.记录失败原因 } } } }
以上代码存在的问题:
-
问题1:第1步,查询了单位在表中的所有字段,数据量大并且没有必要。
-
问题2:第2步,去重算法可以优化,比较耗时。
-
问题3:第3、4、5步,既然设置的导入条数限制,就不应该继续处理,应该提前进行校验。
-
问题4:第6步,存在循环查库问题,并且这个查询sql还挺复杂。
-
问题5:第8步中,两部分数据的匹配规则太过鸡肋,用单位所有数据和excel中数据进行双重for循环匹配既耗内存又耗时间。
如何优化?
问题1:
没有必要查询所有字段的数据,根据需要查询即可。查询单位所有的zh字段数据, 用于第4步中的判断库中是否存在,做过滤数据使用。修改查询sql,只取一个字段,用List
接收;
zhList.contains(newZh)
问题2:
第2步的方法,去重算法可以优化,下面是原来的部分代码;
for (int i = 0; i < excellistpoint.size() - 1; i++) { for (int j = excellistpoint.size() - 1; j > i; j--) { // 条件1:区分方案,有不同的去重判断 if (condition1) { // 条件2:a字段和b字段判断去重 if (condition2) { // 记录失败数据 // 条件3:c字段判断去重 } else if(condition3) { } } else{ // 条件4:a字段判断去重 if (condition4) // 记录失败数据 // 条件5:c字段判断去重 }else if(condition5) { } } } }
这种去重的方式有很多,唯独不推荐上面这种,常用的就是set集合去重,时间复杂度为O(n).
Iterator<Map<Integer, String>> it = excellistpoint.iterator(); Set<String> repeatedValue = new HashSet<>(); Set<String> repeatedZh = new HashSet<>(); while (it.hasNext()) { Map<Integer, String> item = it.next(); String key = condition1 ? a+b: a; if (repeatedValue.contains(key)) { // 记录失败数据 it.remove(); } else if (repeatedZh.contains(c)){ // 记录失败数据 it.remove(); } else{ repeatedValue.add(key); repeatedZh.add(c); } }
问题3:
既然设置的导入条数限制,目的一般是防止用户等待时间过长,减轻服务的压力。而第3、4、5步中的校验已经是代码流程中相对靠后了,并且还要处理限制数量内的数据,限制数量外的数据也要做记录反馈,显然违背了目的。
将校验前置,在获取到excel数据后,判断数据条数,如果大于设置的条数限制,给与页面提示,并终止程序。
问题4:
这个循环查库,目的是查询要更新的数据是否处于业务流程中。换个思路,如果先批量查出正在进行中的业务数据,再去匹配岂不更好。因为一个单位正在进行中的业务不多并且只需要查询一个字段的List集合。
对单个的查询是是否处于业务中的sql进行了修改,在循环外将所有处于业务流程中的数据的id查询出来。在循环体中进行判断当前数据是否匹配业务中的id。
ywzIDList.contains(id)
问题5:
第8步中,两部分数据的匹配规则可以使用条件更新的sql去判断,省去了上面代码中zhList的循环。
对于不匹配的数据也可以根据批量更新返回的结果去判断哪些更新失败了来做记录。这样既省了空间,又减少了复杂度。
另外批量更新的数据量不能太大,可以对数据进行分片多次提交sql。分片的方式也有很多工具类,比如Guava,Apache,Hutool这些工具都有提供现成的方法,Java8的Stream也有提供。因为不想引入第三方工具和JDK版本限制,下面是自定义的实现,有条件的还是别重复造轮子了。
int round = zhList.size() / 500 + 1; for (int i = 0; i < round; i++) { int startLen = i * 500; int endLen = (Math.min((i + 1) * 500, zhList.size())); final List<TEjzh> partZhList = zhList.subList(startLen, endLen); int[] result = zhDAO.batchUpdateEjzh(partZhList, isYayh); for (int j = 0; j < result.length; j++) { Map<String, String> map = new HashMap<>(); if (result[j] != 1) { // 处理更新失败的数据 } else { // 处理更新成功的数据 } } }
经过以上优化,成功提升了导入功能的性能十倍,导入3000条数据的时间从8分钟缩短至不到1分钟。这不仅有效地解决了系统宕机问题,还显著提升了用户体验,让导入数据这一本应简单的操作变得更加高效和便捷。这次优化实践也为我们今后解决类似性能问题提供了宝贵经验。