1 前言
假如給你一個場景,有一批1萬或者10萬的資料,讓你插入到資料庫中怎麼做呢?我們這節來看看。
首先一點我們單純的 一個個 INSERT 語句,我們就不試了,這一個個的肯定慢,我們這裡統一用 INSERT INTO 表(欄位1,欄位2) VALUES(值1,值2),(值11,值22),(值111,值222);這種方式分批跑高效點。
2 實踐
2.1 迴圈批次在一個事務
首先我們看一個簡單的,就是在一個事務裡,一個執行緒迴圈執行:
@Transactional(rollbackFor = Exception.class) public void batchSave() { // 分批 // 至於分多少批:PgSQL 的佔位符個數是有限制的 不能超過 Short.MAX(32767) // 所以一批最多 = 32767 / 你的一行欄位個數 // 比如我這裡 = 32767 / 66個欄位 = 496 也就是一批最多496個資料 我這裡分450個 List<List<OrderPo>> partition = Lists.partition(list, 450); StopWatch stopWatch = new StopWatch(); stopWatch.start(); // 順序插入 for (List<OrderPo> sub : partition) { orderMapper.batchSave(sub); } stopWatch.stop(); log.info("耗時:" + stopWatch.getTotalTimeSeconds()); }
執行的效果:
// 1萬條資料的耗時: 10000 6.9006843 10000 6.7756503 10000 5.7293283 10000 5.5923225 10000 5.818869 10000 5.8303096 // 10萬條資料的耗時: 100000 58.5615497 100000 58.0595869 100000 58.5441196 100000 58.3003101 100000 57.1142761 100000 54.5769813 100000 53.8146378
這種方式最大的優點就是簡單、純粹,中間有出錯,事務回滾,最大的缺點也是比較明顯就是慢。
2.2 利用執行緒池並行插入
為了加快查詢,我們引入執行緒池插入,也就是分批後交給各個執行緒並行插入:
// 執行緒池 private static final ThreadPoolExecutor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1024), new ThreadFactory() { // 執行緒名字 private final String PREFIX = "BATCH_INSERT_"; // 計數器 private AtomicLong atomicLong = new AtomicLong(); @Override public Thread newThread(Runnable r) { Thread thread = new Thread(null, r, PREFIX + atomicLong.incrementAndGet()); return thread; } }); @SneakyThrows @Transactional(rollbackFor = Exception.class) public void batchSave() { // 分批 // 至於分多少批:PgSQL 的佔位符個數是有限制的 不能超過 Short.MAX(32767) // 所以一批最多 = 32767 / 你的一行欄位個數 // 比如我這裡 = 32767 / 66個欄位 = 496 也就是一批最多496個資料 List<List<OrderPo>> partition = Lists.partition(list, 450); CountDownLatch countDownLatch = new CountDownLatch(partition.size()); StopWatch stopWatch = new StopWatch(); stopWatch.start(); // 順序插入 for (List<OrderPo> sub : partition) { THREAD_POOL_EXECUTOR.execute(() -> { try { log.info("執行緒:{}開始處理", Thread.currentThread().getName()); orderMapper.batchSave(sub); } finally { countDownLatch.countDown(); } }); } // 等待插入完畢 countDownLatch.await(); stopWatch.stop(); log.info("耗時:" + stopWatch.getTotalTimeSeconds()); }
看下執行效果:
// 1萬條資料的耗時: 10000 4.6711569 10000 4.4839416 10000 4.4310133 10000 4.3802914 10000 3.8440867 10000 4.0849564 // 10萬條資料的耗時: 100000 37.6524237 100000 35.1318877 100000 36.6338523 100000 36.4448236 100000 35.3499332 100000 36.0569744 100000 34.2736072
這種方式,優點是相對快了,但是缺點:事務下降到每個執行緒裡了,可能會存在某個執行緒成功了,某個失敗了,導致會存在資料丟,並且當併發比較高的時候,執行緒池佇列滿了呢?以及當前是阻塞的,await 會一致等,假如要加上等待時間,那等待時間設定多少呢?都是要考量的。
2.3 執行緒池並行插入但共用一個事務
可以將上邊的多執行緒共用到一個事務裡,也就是不再用宣告式事務,我們可以用程式設計式事務,並且要讓他們共用一個事務的話,其實說白了就是要共用一個資料庫連線,可以參考我前的【Spring】【Mybatis】【事務】Spring + MyBaits + 事務 三者是如何協調的呢?(從一個資料庫連線串一串 Spring、Mybatis、事務的聯絡)、【Spring】【Mybatis】【Dynamic-Datasource】【事務】Spring + MyBaits + 事務 + 動態資料來源 四者是如何協調的呢?(從一個資料庫連線串一串四者的聯絡),我這裡實現方式如下:
// 執行緒池 private static final ThreadPoolExecutor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(8, 16, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1024), new ThreadFactory() { // 執行緒名字 private final String PREFIX = "BATCH_INSERT_"; // 計數器 private AtomicLong atomicLong = new AtomicLong(); @Override public Thread newThread(Runnable r) { Thread thread = new Thread(null, r, PREFIX + atomicLong.incrementAndGet()); return thread; } }); @SneakyThrows public void batchSave() { // 分批 // 至於分多少批:PgSQL 的佔位符個數是有限制的 不能超過 Short.MAX(32767) // 所以一批最多 = 32767 / 你的一行欄位個數 // 比如我這裡 = 32767 / 66個欄位 = 496 也就是一批最多496個資料 List<List<OrderPo>> partition = Lists.partition(list, 450); // 手動事務提前建立出來 DefaultTransactionDefinition transactionDefinition = new DefaultTransactionDefinition(); transactionDefinition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); // 提前獲取連線 TransactionStatus transactionStatus = dataSourceTransactionManager.getTransaction(transactionDefinition); // 獲取資料來源以及連線 供多執行緒使用 DataSource dataSource = dataSourceTransactionManager.getDataSource(); Object resource = TransactionSynchronizationManager.getResource(dataSource); // 異常標誌 AtomicBoolean exceptionFlag = new AtomicBoolean(false); boolean poolExceptionFlag = false; // 計數器等待執行完畢 CountDownLatch countDownLatch = new CountDownLatch(partition.size()); StopWatch stopWatch = new StopWatch(); stopWatch.start(); // 順序插入 for (List<OrderPo> sub : partition) { try { THREAD_POOL_EXECUTOR.execute(() -> { try { // 如果沒有發生異常 if (exceptionFlag.get()) { log.info("有其他執行緒執行失敗,後續無需執行,因為最終會回滾"); return; } // 釋放上次繫結的資料來源連線 try { TransactionSynchronizationManager.unbindResource(dataSource); } catch (Exception ignored){ } // 裝上本次使用的連線 TransactionSynchronizationManager.bindResource(dataSource, resource); log.info("執行緒:{}開始處理", Thread.currentThread().getName()); // 執行插入 orderMapper.batchSave(sub); // 模擬異常 if (ThreadLocalRandom.current().nextInt(3) == 1) { int i = 1/0; } } catch (Exception e) { // 發生異常設定異常標誌 log.error(String.format("執行緒:%s我發生了異常,e:%s", Thread.currentThread().getName(), e.getMessage()), e); exceptionFlag.set(true); } finally { // 不管是成功還是失敗 都要計數器 -1 countDownLatch.countDown(); } }); } catch (Exception e) { // 提交任務失敗 那就是失敗了 exceptionFlag.set(true); log.info("當前執行緒池繁忙,請稍後重試"); dataSourceTransactionManager.rollback(transactionStatus); poolExceptionFlag = true; break; } } // 等待執行完畢 這裡有個隱患 等待多長時間呢? 執行緒池任務過多的話最嚴重的情況 就是一直要在這裡阻塞 // 因為事務的提交還是回滾都交給了 主任務執行緒 // 如果提交到執行緒池都成功了的話 就等待都執行完 if (!poolExceptionFlag) { countDownLatch.await(); } // 異常標誌來做提交還是回滾 if (exceptionFlag.get()) { // 發生異常 回滾 dataSourceTransactionManager.rollback(transactionStatus); } else { // 未發生異常 可以提交 dataSourceTransactionManager.commit(transactionStatus); } stopWatch.stop(); log.info("耗時:" + stopWatch.getTotalTimeSeconds()); }
這種方式相對於上邊一種,事務是共用到一個事務了,但是用到執行緒池以及佇列滿了如何呢?以及阻塞當前執行緒的問題。
2.4 批次任務表
那麼基於這種批次操作,我們是不是可以建立兩張表,思路如下:
至於如何非同步執行每個明細,我們可以用 XXL-JOB定時去撈執行失敗或者未執行的任務,如果任務數量比較多的話,撈出來透過傳送 MQ 均攤的方式處理掉。
3 小結
大家要是有更好的思路胡總和有哪裡理解不對的地方,還請指正哈。