Java后端:10w行级别数据的Excel导入优化记录
一些細節
數據導入:導入使用的模板由系統提供,格式是 xlsx (支持 65535+行數據) ,用戶按照表頭在對應列寫入相應的數據
數據校驗:數據校驗有兩種:
字段長度、字段正則表達式校驗等,內存內校驗不存在外部數據交互。對性能影響較小
數據重復性校驗,如票據號是否和系統已存在的票據號重復(需要查詢數據庫,十分影響性能)
數據插入:測試環境數據庫使用 MySQL 5.7,未分庫分表,連接池使用 Druid
迭代記錄
第一版:POI + 逐行查詢校對 + 逐行插入
這個版本是最古老的版本,采用原生 POI,手動將 Excel 中的行映射成 ArrayList 對象,然后存儲到 List,代碼執行的步驟如下:
手動讀取 Excel 成 List
循環遍歷,在循環中進行以下步驟
檢驗字段長度
一些查詢數據庫的校驗,比如校驗當前行欠費對應的房屋是否在系統中存在,需要查詢房屋表
寫入當前行數據
返回執行結果,如果出錯 / 校驗不合格。則返回提示信息并回滾數據
顯而易見的,這樣實現一定是趕工趕出來的,后續可能用的少也沒有察覺到性能問題,但是它最多適用于個位數/十位數級別的數據。存在以下明顯的問題:
查詢數據庫的校驗對每一行數據都要查詢一次數據庫,應用訪問數據庫來回的網絡IO次數被放大了 n 倍,時間也就放大了 n 倍
寫入數據也是逐行寫入的,問題和上面的一樣
數據讀取使用原生 POI,代碼十分冗余,可維護性差。
第二版:EasyPOI + 緩存數據庫查詢操作 + 批量插入
針對第一版分析的三個問題,分別采用以下三個方法優化
緩存數據,以空間換時間
逐行查詢數據庫校驗的時間成本主要在來回的網絡IO中,優化方法也很簡單。將參加校驗的數據全部緩存到 HashMap 中。直接到 HashMap 去命中。
例如:校驗行中的房屋是否存在,原本是要用 區域 + 樓宇 + 單元 + 房號 去查詢房屋表匹配房屋ID,查到則校驗通過,生成的欠單中存儲房屋ID,校驗不通過則返回錯誤信息給用戶。而房屋信息在導入欠費的時候是不會更新的。并且一個小區的房屋信息也不會很多(5000以內)因此我采用一條SQL,將該小區下所有的房屋以 區域/樓宇/單元/房號 作為 key,以 房屋ID 作為 value,存儲到 HashMap 中,后續校驗只需要在 HashMap 中命中
自定義 SessionMapper
Mybatis 原生是不支持將查詢到的結果直接寫人一個 HashMap 中的,需要自定義 SessionMapper
SessionMapper 中指定使用 MapResultHandler 處理 SQL 查詢的結果集
@Repository public?class?SessionMapper?extends?SqlSessionDaoSupport?{@Resourcepublic?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();@Overridepublic?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?{//?收費標準綁定?區域樓宇單元房號?-?房屋IDMap<String,?Long>?getHouseMapByAreaId(@Param("areaId")?Long?areaId); }????示例 Mapper.xml
<select?id="getHouseMapByAreaId"?resultMap="mapResultLong">SELECTCONCAT(?h.bulid_area_name,?h.build_name,?h.unit_name,?h.house_num?)?k,h.house_id?vFROMbase_house?hWHEREh.area_id?=?#{areaId}GROUP?BYh.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 類對應的方法即可。
使用 values 批量插入
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,非常方便,代碼維護起來也容易。
第三版:EasyExcel + 緩存數據庫查詢操作 + 批量插入
第二版采用 EasyPOI 之后,對于幾千、幾萬的 Excel 數據已經可以輕松導入了,不過耗時有點久(5W 數據 10分鐘左右寫入到數據庫)不過由于后來導入的操作基本都是開發在一邊看日志一邊導入,也就沒有進一步優化。但是好景不長,有新小區需要遷入,票據 Excel 有 41w 行,這個時候使用 EasyPOI 在開發環境跑直接就 OOM 了,增大 JVM 內存參數之后,雖然不 OOM 了,但是 CPU 占用 100% 20 分鐘仍然未能成功讀取全部數據。故在讀取大 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導入速度的方法:
使用更快的 Excel 讀取框架(推薦使用阿里 EasyExcel)
對于需要與數據庫交互的校驗、按照業務邏輯適當的使用緩存。用空間換時間
使用 values(),(),() 拼接長 SQL 一次插入多行數據
使用多線程插入數據,利用掉網絡IO等待時間(推薦使用并行流,簡單易用)
避免在循環中打印無用的日志
總結
以上是生活随笔為你收集整理的Java后端:10w行级别数据的Excel导入优化记录的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 蜂鸣器的专业分类是这样的
- 下一篇: Java后端:一个注解搞定 Spring