【探討】批次操作以及多執行緒下保證事務的一致性

酷酷-發表於2024-11-29

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 小結

大家要是有更好的思路胡總和有哪裡理解不對的地方,還請指正哈。

相關文章