10w+ Excel 數據導入,怎麼優化?

做者:後青春期的Keats
https://www.cnblogs.com/keats...

需求說明

項目中有一個 Excel 導入的需求:繳費記錄導入。java

由實施 / 用戶 將別的系統的數據填入咱們系統中的 Excel 模板,應用將文件內容讀取、校對、轉換以後產生欠費數據、票據、票據詳情並存儲到數據庫中。面試

在我接手以前可能因爲以前導入的數據量並很少沒有對效率有太高的追求。可是到了 4.0 版本,我預估導入時Excel 行數會是 10w+ 級別,而往數據庫插入的數據量是大於 3n 的,也就是說 10w 行的 Excel,則至少向數據庫插入 30w 行數據。正則表達式

所以優化原來的導入代碼是勢在必行的。我逐步分析和優化了導入的代碼,使之在百秒內完成(最終性能瓶頸在數據庫的處理速度上,測試服務器 4g 內存不只放了數據庫,還放了不少微服務應用。處理能力不太行)。sql

具體的過程以下,每一步都有列出影響性能的問題和解決的辦法。數據庫

導入 Excel 的需求在系統中仍是很常見的,個人優化辦法可能不是最優的,歡迎讀者在評論區留言交流提供更優的思路編程

一些細節

  • 數據導入:導入使用的模板由系統提供,格式是 xlsx (支持 65535+行數據) ,用戶按照表頭在對應列寫入相應的數據
  • 數據校驗:數據校驗有兩種:
  • 字段長度、字段正則表達式校驗等,內存內校驗不存在外部數據交互。對性能影響較小
  • 數據重複性校驗,如票據號是否和系統已存在的票據號重複(須要查詢數據庫,十分影響性能)
  • 數據插入:測試環境數據庫使用 MySQL 5.7,未分庫分表,鏈接池使用 Druid

迭代記錄

初版:POI + 逐行查詢校對 + 逐行插入

這個版本是最古老的版本,採用原生 POI,手動將 Excel 中的行映射成 ArrayList 對象,而後存儲到 List<ArrayList> ,代碼執行的步驟以下:後端

  1. 手動讀取 Excel 成 List
  2. 循環遍歷,在循環中進行如下步驟
  3. 檢驗字段長度
  4. 一些查詢數據庫的校驗,好比校驗當前行欠費對應的房屋是否在系統中存在,須要查詢房屋表
  5. 寫入當前行數據
  6. 返回執行結果,若是出錯 / 校驗不合格。則返回提示信息並回滾數據

顯而易見的,這樣實現必定是趕工趕出來的,後續可能用的少也沒有察覺到性能問題,可是它最多適用於個位數/十位數級別的數據。存在如下明顯的問題:緩存

  • 查詢數據庫的校驗對每一行數據都要查詢一次數據庫,應用訪問數據庫來回的網絡IO次數被放大了 n 倍,時間也就放大了 n 倍
  • 寫入數據也是逐行寫入的,問題和上面的同樣
  • 數據讀取使用原生 POI,代碼十分冗餘,可維護性差。

第二版:EasyPOI + 緩存數據庫查詢操做 + 批量插入

針對初版分析的三個問題,分別採用如下三個方法優化服務器

緩存數據,以空間換時間

逐行查詢數據庫校驗的時間成本主要在來回的網絡IO中,優化方法也很簡單。將參加校驗的數據所有緩存到 HashMap 中。直接到 HashMap 去命中。另外關注公衆號Java技術棧回覆福利獲取一份Java面試題資料。網絡

例如:校驗行中的房屋是否存在,本來是要用 區域 + 樓宇 + 單元 + 房號 去查詢房屋表匹配房屋ID,查到則校驗經過,生成的欠單中存儲房屋ID,校驗不經過則返回錯誤信息給用戶。而房屋信息在導入欠費的時候是不會更新的。

而且一個小區的房屋信息也不會不少(5000之內)所以我採用一條SQL,將該小區下全部的房屋以 區域/樓宇/單元/房號 做爲 key,以 房屋ID 做爲 value,存儲到 HashMap 中,後續校驗只須要在 HashMap 中命中。

自定義 SessionMapper

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 類對應的方法便可。

使用 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 分鐘仍然未能成功讀取所有數據。另外關注公衆號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導入速度的方法:

  • 使用更快的 Excel 讀取框架(推薦使用阿里 EasyExcel)
  • 對於須要與數據庫交互的校驗、按照業務邏輯適當的使用緩存。用空間換時間
  • 使用 values(),(),() 拼接長 SQL 一次插入多行數據
  • 使用多線程插入數據,利用掉網絡IO等待時間(推薦使用並行流,簡單易用)
  • 避免在循環中打印無用的日誌

若是你以爲閱讀後有收穫,不妨點個推薦吧!

關注公衆號Java技術棧回覆"面試"獲取我整理的2020最全面試題及答案。

推薦去個人博客閱讀更多:

1.Java JVM、集合、多線程、新特性系列教程

2.Spring MVC、Spring Boot、Spring Cloud 系列教程

3.Maven、Git、Eclipse、Intellij IDEA 系列工具教程

4.Java、後端、架構、阿里巴巴等大廠最新面試題

以爲不錯,別忘了點贊+轉發哦!

相關文章
相關標籤/搜索