Mybatis 批處理真的很強!從 7 分鐘優(yōu)化到 10 秒!
這篇文章會一步一步帶你從一個新手的角度慢慢揭開批處理的神秘面紗,對于初次寫Mybatis批處理的同學可能會有很大的幫助,建議收藏點贊~
處理批處理的方式有很多種,這里不分析各種方式的優(yōu)劣,只是概述?ExecutorType.BATCH
?這種的用法,另學藝不精,如果有錯的地方,還請大佬們指出更正。
問題原因
在公司寫項目的時候,有一個自動對賬的需求,需要從文件中讀取幾萬條數(shù)據(jù)插入到數(shù)據(jù)庫中,后續(xù)可能跟著業(yè)務的增長,會上升到幾十萬,所以對于插入需要進行批處理操作,下面我們就來看看我是怎么一步一步踩坑的。
簡單了解一下批處理背后的秘密,BatchExecutor
批處理是 JDBC 編程中的另一種優(yōu)化手段。JDBC 在執(zhí)行 SQL 語句時,會將 SQL 語句以及實參通過網(wǎng)絡請求的方式發(fā)送到數(shù)據(jù)庫,一次執(zhí)行一條 SQL 語句,一方面會減小請求包的有效負載,另一個方面會增加耗費在網(wǎng)絡通信上的時間。
通過批處理的方式,我們就可以在 JDBC 客戶端緩存多條 SQL 語句,然后在 flush 或緩存滿的時候,將多條 SQL 語句打包發(fā)送到數(shù)據(jù)庫執(zhí)行,這樣就可以有效地降低上述兩方面的損耗,從而提高系統(tǒng)性能。
不過,有一點需要特別注意:
每次向數(shù)據(jù)庫發(fā)送的 SQL 語句的條數(shù)是有上限的,如果批量執(zhí)行的時候超過這個上限值,數(shù)據(jù)庫就會拋出異常,拒絕執(zhí)行這一批 SQL 語句,所以我們需要控制批量發(fā)送 SQL 語句的條數(shù)和頻率。
版本1-呱呱墜地
廢話不多說,早先時候項目的代碼里就已經(jīng)存在了批處理的代碼,偽代碼的樣子大概是這樣子的:
@Resource
private?某Mapper類?mapper實例對象;
private?int?BATCH?=?1000;
??private?void?doUpdateBatch(Date?accountDate,?List<某實體類>?data)?{
????SqlSession?batchSqlSession?=?null;
????try?{
??????if?(data?==?null?||?data.size()?==?0)?{
????????return;
??????}
??????batchSqlSession?=?sqlSessionFactory.openSession(ExecutorType.BATCH,?false);
??????for?(int?index?=?0;?index?<?data.size();?index++)?{
????????mapper實例對象.更新/插入Method(accountDate,?data.get(index).getOrderNo());
????????if?(index?!=?0?&&?index?%?BATCH?==?0)?{
??????????batchSqlSession.commit();
??????????batchSqlSession.clearCache();
????????}
??????}
??????batchSqlSession.commit();
????}?catch?(Exception?e)?{
??????batchSqlSession.rollback();
??????log.error(e.getMessage(),?e);
????}?finally?{
??????if?(batchSqlSession?!=?null)?{
????????batchSqlSession.close();
??????}
????}
??}
我們先來看看上述這種寫法的幾種問題
你真的懂commit、clearCache、flushStatements嘛?
我們先看看官網(wǎng)給出的解釋:

然后我們結(jié)合上述寫法,它會在判斷批處理條數(shù)達到1000條的時候會去手動commit,然后又手動clearCache
,我們先來看看commit到底都做了一些什么,以下為調(diào)用鏈
??@Override
??public?void?commit()?{
????commit(false);
??}??
??@Override
??public?void?commit(boolean?force)?{
????try?{
??????executor.commit(isCommitOrRollbackRequired(force));
??????dirty?=?false;
????}?catch?(Exception?e)?{
??????throw?ExceptionFactory.wrapException("Error?committing?transaction.??Cause:?"?+?e,?e);
????}?finally?{
??????ErrorContext.instance().reset();
????}
??}
??private?boolean?isCommitOrRollbackRequired(boolean?force)?{
????//?autoCommit默認為false,調(diào)用過插入、更新、刪除之后的dirty值為true
????return?(!autoCommit?&&?dirty)?||?force;
??}
??@Override
??public?void?commit(boolean?required)?throws?SQLException?{
????if?(closed)?{
??????throw?new?ExecutorException("Cannot?commit,?transaction?is?already?closed");
????}
????clearLocalCache();
????flushStatements();
????if?(required)?{
??????transaction.commit();
????}
??}
我們會發(fā)現(xiàn),其實你直接調(diào)用commit的情況下,它就已經(jīng)做了clearLocalCache
這件事情,所以大可不必在commit后加上一句clearCache
,而且clearCache
是做了什么你又知道嘛?就擱這調(diào)用?。?/p>
另外flushStatements
的作用,官網(wǎng)里也有詳細解釋:

此方法的作用就是將前面所有執(zhí)行過的INSERT、UPDATE、DELETE
語句真正刷新到數(shù)據(jù)庫中。底層調(diào)用了JDBC的statement.executeBatch
方法。
這個方法的返回值通俗來說如果執(zhí)行的是同一個方法并且執(zhí)行的是同一條SQL,注意這里的SQL還沒有設置參數(shù),也就是說SQL里的占位符'?'還沒有被處理成真正的參數(shù),那么每次執(zhí)行的結(jié)果共用一個BatchResult
,真正的結(jié)果可以通過BatchResult
中的getUpdateCounts
方法獲取。
另外如果執(zhí)行了SELECT操作,那么會將先前的UPDATE、INSERT、DELETE
語句刷新到數(shù)據(jù)庫中。這一點去看BatchExecutor
中的doQuery方法即可。
反例
看到這里,我們在來看點反例,你就會覺得這都是啥跟啥啊?。?!誤人子弟啊,直接在百度搜一段關(guān)鍵字:mybatis ExecutorType.BATCH
?批處理,反例如下:

不具備通用性
由于項目中用到批處理的地方肯定不止一個,那每用一次就需要CV一下,0.0 那會不會顯得太菜了?能不能一勞永逸?
版本2-初具雛形
在解決完上述兩個問題后,我們的代碼版本來到了第2版,你以為這就對了?這就完事了?別急,我們繼續(xù)往下看!
import?lombok.extern.slf4j.Slf4j;
import?org.apache.ibatis.session.ExecutorType;
import?org.apache.ibatis.session.SqlSession;
import?org.apache.ibatis.session.SqlSessionFactory;
import?org.springframework.stereotype.Component;
import?javax.annotation.Resource;
import?java.util.List;
import?java.util.function.ToIntFunction;
@Slf4j
@Component
public?class?MybatisBatchUtils?{
????/**
?????*?每次處理1000條
?????*/
????private?static?final?int?BATCH?=?1000;
????@Resource
????private?SqlSessionFactory?sqlSessionFactory;
????/**
?????*?批量處理修改或者插入
?????*
?????*?@param?data?????需要被處理的數(shù)據(jù)
?????*?@param?function?自定義處理邏輯
?????*?@return?int?影響的總行數(shù)
?????*/
????public??<T>?int?batchUpdateOrInsert(List<T>?data,?ToIntFunction<T>?function)?{
????????int?count?=?0;
????????SqlSession?batchSqlSession?=?sqlSessionFactory.openSession(ExecutorType.BATCH);
????????try?{
????????????for?(int?index?=?0;?index?<?data.size();?index++)?{
????????????????count?+=?function.applyAsInt(data.get(index));
????????????????if?(index?!=?0?&&?index?%?BATCH?==?0)?{
????????????????????batchSqlSession.flushStatements();
????????????????}
????????????}
????????????batchSqlSession.commit();
????????}?catch?(Exception?e)?{
????????????batchSqlSession.rollback();
????????????log.error(e.getMessage(),?e);
????????}?finally?{
????????????batchSqlSession.close();
????????}
????????return?count;
????}
}
偽代碼使用案例
@Resource
private?某Mapper類?mapper實例對象;
batchUtils.batchUpdateOrInsert(數(shù)據(jù)集合,?item?->?mapper實例對象.insert方法(item));
這個時候我興高采烈的收工了,直到過了一兩天,導師問我,考慮過這個業(yè)務的性能嘛,后續(xù)量大了可能每天有十多萬筆數(shù)據(jù),問我現(xiàn)在每天要多久,我才發(fā)現(xiàn) 0.0 兩三萬條數(shù)據(jù)插入居然要7分鐘(不完全是這個問題導致這么慢,還有Oracle插入語句的原因,下面會描述),,哈哈,笑不活了,簡直就是Bug制造機,我就開始思考為什么會這么慢,肯定是批處理沒生效,我就思考為什么會沒生效?
版本3-標準寫法
我們知道上面我們提到了BatchExecutor
執(zhí)行器,我們知道每個SqlSession都會擁有一個Executor對象,這個對象才是執(zhí)行 SQL 語句的幕后黑手,我們也知道Spring跟Mybatis整合的時候使用的SqlSession
是SqlSessionTemplate
,默認用的是ExecutorType.SIMPLE
,這個時候你通過自動注入獲得的Mapper對象其實是沒有開啟批處理的
??public?Executor?newExecutor(Transaction?transaction,?ExecutorType?executorType)?{
????executorType?=?executorType?==?null???defaultExecutorType?:?executorType;
????executorType?=?executorType?==?null???ExecutorType.SIMPLE?:?executorType;
????Executor?executor;
????if?(ExecutorType.BATCH?==?executorType)?{
??????executor?=?new?BatchExecutor(this,?transaction);
????}?else?if?(ExecutorType.REUSE?==?executorType)?{
??????executor?=?new?ReuseExecutor(this,?transaction);
????}?else?{
??????executor?=?new?SimpleExecutor(this,?transaction);
????}
????if?(cacheEnabled)?{
??????executor?=?new?CachingExecutor(executor);
????}
????executor?=?(Executor)?interceptorChain.pluginAll(executor);
????return?executor;
??}
那么我們實際上是需要通過sqlSessionFactory.openSession(ExecutorType.BATCH)
得到的sqlSession
對象(此時里面的Executor
是BatchExecutor
)去獲得一個新的Mapper對象才能生效!??!
所以我們更改一下這個通用的方法,把MapperClass
也一塊傳遞進來
public?class?MybatisBatchUtils?{
????
????/**
????*?每次處理1000條
????*/
????private?static?final?int?BATCH_SIZE?=?1000;
????
????@Resource
????private?SqlSessionFactory?sqlSessionFactory;
????
????/**
????*?批量處理修改或者插入
????*
????*?@param?data?????需要被處理的數(shù)據(jù)
????*?@param?mapperClass??Mybatis的Mapper類
????*?@param?function?自定義處理邏輯
????*?@return?int?影響的總行數(shù)
????*/
????public??<T,U,R>?int?batchUpdateOrInsert(List<T>?data,?Class<U>?mapperClass,?BiFunction<T,U,R>?function)?{
????????int?i?=?1;
????????SqlSession?batchSqlSession?=?sqlSessionFactory.openSession(ExecutorType.BATCH);
????????try?{
????????????U?mapper?=?batchSqlSession.getMapper(mapperClass);
????????????int?size?=?data.size();
????????????for?(T?element?:?data)?{
????????????????function.apply(element,mapper);
????????????????if?((i?%?BATCH_SIZE?==?0)?||?i?==?size)?{
????????????????????batchSqlSession.flushStatements();
????????????????}
????????????????i++;
????????????}
????????????//?非事務環(huán)境下強制commit,事務情況下該commit相當于無效
????????????batchSqlSession.commit(!TransactionSynchronizationManager.isSynchronizationActive());
????????}?catch?(Exception?e)?{
????????????batchSqlSession.rollback();
????????????throw?new?CustomException(e);
????????}?finally?{
????????????batchSqlSession.close();
????????}
????????return?i?-?1;
????}
}
這里會判斷是否是事務環(huán)境,不是的話會強制提交,如果是事務環(huán)境的話,這個commit設置force值是無效的,這個在前面的官網(wǎng)截圖中有提到。
使用案例:
batchUtils.batchUpdateOrInsert(數(shù)據(jù)集合,?xxxxx.class,?(item,?mapper實例對象)?->?mapper實例對象.insert方法(item));
附:Oracle批量插入優(yōu)化
我們都知道Oracle主鍵序列生成策略跟MySQL不一樣,我們需要弄一個序列生成器,這里就不詳細展開描述了,然后Mybatis Generator
生成的模板代碼中,insert的id是這樣獲取的
<selectKey?keyProperty="id"?order="BEFORE"?resultType="java.lang.Long">
??select?XXX.nextval?from?dual
</selectKey>
如此,就相當于你插入1萬條數(shù)據(jù),其實就是insert和查詢序列合計預計2萬次交互,耗時竟然達到10s多。我們改為用原生的Batch插入,這樣子的話,只要500多毫秒,也就是0.5秒的樣子
<insert?id="insert"?parameterType="user">
????????insert?into?table_name(id,?username,?password)
????????values(SEQ_USER.NEXTVAL,#{username},#{password})
</insert>
最后這樣一頓操作,批處理 + 語句優(yōu)化一下,這個業(yè)務直接從7分多鐘變成10多秒,完美解決,撒花慶祝~