做者:后青春期的Keats
https://www.cnblogs.com/keats...
项目中有一个 Excel 导入的需求:缴费记录导入。java
由实施 / 用户 将别的系统的数据填入咱们系统中的 Excel 模板,应用将文件内容读取、校对、转换以后产生欠费数据、票据、票据详情并存储到数据库中。面试
在我接手以前可能因为以前导入的数据量并很少没有对效率有太高的追求。可是到了 4.0 版本,我预估导入时Excel 行数会是 10w+ 级别,而往数据库插入的数据量是大于 3n 的,也就是说 10w 行的 Excel,则至少向数据库插入 30w 行数据。正则表达式
所以优化原来的导入代码是势在必行的。我逐步分析和优化了导入的代码,使之在百秒内完成(最终性能瓶颈在数据库的处理速度上,测试服务器 4g 内存不只放了数据库,还放了不少微服务应用。处理能力不太行)。sql
具体的过程以下,每一步都有列出影响性能的问题和解决的办法。数据库
导入 Excel 的需求在系统中仍是很常见的,个人优化办法可能不是最优的,欢迎读者在评论区留言交流提供更优的思路编程
这个版本是最古老的版本,采用原生 POI,手动将 Excel 中的行映射成 ArrayList 对象,而后存储到 List<ArrayList> ,代码执行的步骤以下:后端
显而易见的,这样实现必定是赶工赶出来的,后续可能用的少也没有察觉到性能问题,可是它最多适用于个位数/十位数级别的数据。存在如下明显的问题:缓存
针对初版分析的三个问题,分别采用如下三个方法优化服务器
逐行查询数据库校验的时间成本主要在来回的网络IO中,优化方法也很简单。将参加校验的数据所有缓存到 HashMap 中。直接到 HashMap 去命中。另外关注公众号Java技术栈回复福利获取一份Java面试题资料。网络
例如:校验行中的房屋是否存在,本来是要用 区域 + 楼宇 + 单元 + 房号 去查询房屋表匹配房屋ID,查到则校验经过,生成的欠单中存储房屋ID,校验不经过则返回错误信息给用户。而房屋信息在导入欠费的时候是不会更新的。
而且一个小区的房屋信息也不会不少(5000之内)所以我采用一条SQL,将该小区下全部的房屋以 区域/楼宇/单元/房号 做为 key,以 房屋ID 做为 value,存储到 HashMap 中,后续校验只须要在 HashMap 中命中。
Mybatis 原生是不支持将查询到的结果直接写人一个 HashMap 中的,须要自定义 SessionMapper。
SessionMapper 中指定使用 MapResultHandler 处理 SQL 查询的结果集
@Repository public class SessionMapper extends SqlSessionDaoSupport { @Resource public void setSqlSessionFactory(SqlSessionFactory sqlSessionFactory) { super.setSqlSessionFactory(sqlSessionFactory); } // 区域楼宇单元房号 - 房屋ID @SuppressWarnings("unchecked") public Map<String, Long> getHouseMapByAreaId(Long areaId) { MapResultHandler handler = new MapResultHandler(); this.getSqlSession().select(BaseUnitMapper.class.getName()+".getHouseMapByAreaId", areaId, handler); Map<String, Long> map = handler.getMappedResults(); return map; } }
MapResultHandler 处理程序,将结果集放入 HashMap
public class MapResultHandler implements ResultHandler { private final Map mappedResults = new HashMap(); @Override public void handleResult(ResultContext context) { @SuppressWarnings("rawtypes") Map map = (Map)context.getResultObject(); mappedResults.put(map.get("key"), map.get("value")); } public Map getMappedResults() { return mappedResults; } }
示例 Mapper
@Mapper @Repository public interface BaseUnitMapper { // 收费标准绑定 区域楼宇单元房号 - 房屋ID Map<String, Long> getHouseMapByAreaId(@Param("areaId") Long areaId); }
示例 Mapper.xml
<select id="getHouseMapByAreaId" resultMap="mapResultLong"> SELECT CONCAT( h.bulid_area_name, h.build_name, h.unit_name, h.house_num ) k, h.house_id v FROM base_house h WHERE h.area_id = #{areaId} GROUP BY h.house_id </select> <resultMap id="mapResultLong" type="java.util.HashMap"> <result property="key" column="k" javaType="string" jdbcType="VARCHAR"/> <result property="value" column="v" javaType="long" jdbcType="INTEGER"/> </resultMap>
以后在代码中调用 SessionMapper 类对应的方法便可。
MySQL insert 语句支持使用 values (),(),() 的方式一次插入多行数据,经过 mybatis foreach 结合 java 集合能够实现批量插入,代码写法以下:
<insert id="insertList"> insert into table(colom1, colom2) values <foreach collection="list" item="item" index="index" separator=","> ( #{item.colom1}, #{item.colom2}) </foreach> </insert>
EasyPOI采用基于注解的导入导出,修改注解就能够修改Excel,很是方便,代码维护起来也容易。
第二版采用 EasyPOI 以后,对于几千、几万的 Excel 数据已经能够轻松导入了,不过耗时有点久(5W 数据 10分钟左右写入到数据库)不过因为后来导入的操做基本都是开发在一边看日志一边导入,也就没有进一步优化。
可是好景不长,有新小区须要迁入,票据 Excel 有 41w 行,这个时候使用 EasyPOI 在开发环境跑直接就 OOM 了,增大 JVM 内存参数以后,虽然不 OOM 了,可是 CPU 占用 100% 20 分钟仍然未能成功读取所有数据。另外关注公众号Java技术栈回复JVM46获取一份JVM调优教程。
故在读取大 Excel 时须要再优化速度。莫非要我这个渣渣去深刻 POI 优化了吗?别慌,先上 GITHUB 找找别的开源项目。这时阿里 EasyExcel 映入眼帘:
emmm,这不是为我量身定制的吗!赶忙拿来试试。
EasyExcel 采用和 EasyPOI 相似的注解方式读写 Excel,所以从 EasyPOI 切换过来很方便,分分钟就搞定了。也确实如阿里大神描述的:41w行、25列、45.5m 数据读取平均耗时 50s,所以对于大 Excel 建议使用 EasyExcel 读取。
在第二版插入的时候,我使用了 values 批量插入代替逐行插入。每 30000 行拼接一个长 SQL、顺序插入。整个导入方法这块耗时最多,很是拉跨。后来我将每次拼接的行数减小到 10000、5000、3000、1000、500 发现执行最快的是 1000。
结合网上一些对 innodb_buffer_pool_size 描述我猜是由于过长的 SQL 在写操做的时候因为超过内存阈值,发生了磁盘交换。限制了速度,另外测试服务器的数据库性能也不怎么样,过多的插入他也处理不过来。因此最终采用每次 1000 条插入。
每次 1000 条插入后,为了榨干数据库的 CPU,那么网络IO的等待时间就须要利用起来,这个须要多线程来解决,而最简单的多线程可使用 并行流 来实现,接着我将代码用并行流来测试了一下:
10w行的 excel、42w 欠单、42w记录详情、2w记录、16 线程并行插入数据库、每次 1000 行。插入时间 72s,导入总时间 95 s。
并行插入的代码我封装了一个函数式编程的工具类,也提供给你们
/** * 功能:利用并行流快速插入数据 * * @author Keats * @date 2020/7/1 9:25 */ public class InsertConsumer { /** * 每一个长 SQL 插入的行数,能够根据数据库性能调整 */ private final static int SIZE = 1000; /** * 若是须要调整并发数目,修改下面方法的第二个参数便可 */ static { System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "4"); } /** * 插入方法 * * @param list 插入数据集合 * @param consumer 消费型方法,直接使用 mapper::method 方法引用的方式 * @param <T> 插入的数据类型 */ public static <T> void insertData(List<T> list, Consumer<List<T>> consumer) { if (list == null || list.size() < 1) { return; } List<List<T>> streamList = new ArrayList<>(); for (int i = 0; i < list.size(); i += SIZE) { int j = Math.min((i + SIZE), list.size()); List<T> subList = list.subList(i, j); streamList.add(subList); } // 并行流使用的并发数是 CPU 核心数,不能局部更改。全局更改影响较大,斟酌 streamList.parallelStream().forEach(consumer); } }
这里多数使用到不少 Java8 的API,不了解的朋友能够翻看我以前关于 Java 的博客。方法使用起来很简单:
InsertConsumer.insertData(feeList, arrearageMapper::insertList);
避免在 for 循环中打印过多的 info 日志
在优化的过程当中,我还发现了一个特别影响性能的东西:info 日志,仍是使用 41w行、25列、45.5m 数据,在 开始-数据读取完毕 之间每 1000 行打印一条 info 日志,缓存校验数据-校验完毕 之间每行打印 3+ 条 info 日志,日志框架使用 Slf4j 。打印并持久化到磁盘。下面是打印日志和不打印日志效率的差异
打印日志
不打印日志
我觉得是我选错 Excel 文件了,又从新选了一次,结果依旧
缓存校验数据-校验完毕,不打印日志耗时仅仅是打印日志耗时的 1/10 !
提高Excel导入速度的方法:
若是你以为阅读后有收获,不妨点个推荐吧!
关注公众号Java技术栈回复"面试"获取我整理的2020最全面试题及答案。
推荐去个人博客阅读更多:
2.Spring MVC、Spring Boot、Spring Cloud 系列教程
3.Maven、Git、Eclipse、Intellij IDEA 系列工具教程
以为不错,别忘了点赞+转发哦!