1. 程式人生 > 實用技巧 >2020年HZNU天梯訓練賽 Round 5

2020年HZNU天梯訓練賽 Round 5

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

這個版本是最古老的版本,採用原生 POI,手動將 Excel 中的行對映成 ArrayList物件,然後儲存到 List<ArrayList> ,程式碼執行的步驟如下:

  1. 手動讀取 Excel 成 List<ArrayList>
  2. 迴圈遍歷,在迴圈中進行以下步驟
    1. 檢驗欄位長度
    2. 一些查詢資料庫的校驗,比如校驗當前行欠費對應的房屋是否在系統中存在,需要查詢房屋表
    3. 寫入當前行資料
  3. 返回執行結果,如果出錯 / 校驗不合格。則返回提示資訊並回滾資料

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

  • 查詢資料庫的校驗對每一行資料都要查詢一次資料庫,應用訪問資料庫來回的網路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 {

    @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 分鐘仍然未能成功讀取全部資料。故在讀取大 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等待時間(推薦使用並行流,簡單易用)
  • 避免在迴圈中列印無用的日誌