[TOC]
公司的某些業(yè)務(wù)用到了數(shù)據(jù)庫(kù)的悲觀鎖 for update施敢,但有些同事沒有把 for update 放在 Spring 事務(wù)中執(zhí)行枚钓,在并發(fā)場(chǎng)景下發(fā)生了嚴(yán)重的線程阻塞問題梅屉,為了把這個(gè)問題吃透臼疫,秉承著老司機(jī)的職業(yè)素養(yǎng)万搔,我決定要給同事們一個(gè)交代。
案發(fā)現(xiàn)場(chǎng)
最近公司的某些 Dubbo 服務(wù)之間的 RPC 調(diào)用過程中官帘,偶然性地發(fā)生了若干起嚴(yán)重的超時(shí)問題瞬雹,導(dǎo)致了某些模塊不能正常提供服務(wù)。我們的數(shù)據(jù)庫(kù)用的是 Oracle刽虹,經(jīng)過 DBA 排查酗捌,發(fā)現(xiàn)了一些 sql 的執(zhí)行時(shí)間特別長(zhǎng),對(duì)比發(fā)現(xiàn)這些執(zhí)行時(shí)間長(zhǎng)的 sql 都帶有 for update 悲觀鎖涌哲,于是相關(guān)開發(fā)人員查看 sql 對(duì)應(yīng)的業(yè)務(wù)代碼胖缤,發(fā)現(xiàn) for update 沒有放在 Spring 事務(wù)中執(zhí)行,但是按照常理來說阀圾,如果 for update 沒有加 Spring 事務(wù)哪廓,每次執(zhí)行完 Mybatis 都會(huì)幫我們 commit 釋放掉資源,并發(fā)時(shí)出現(xiàn)的問題應(yīng)該是沒有鎖住對(duì)應(yīng)資源產(chǎn)生臟數(shù)據(jù)而不是發(fā)生阻塞初烘。但是經(jīng)過代碼的調(diào)試涡真,不加 Spring 事務(wù)并發(fā)執(zhí)行確實(shí)會(huì)阻塞。
案例分析
基于案發(fā)現(xiàn)場(chǎng)的問題所在肾筐,我特地寫了幾個(gè)針對(duì)問題的案例分析測(cè)試代碼哆料,”talk is cheap, show you the code”:
加 Spring 事務(wù)執(zhí)行但不提交事務(wù)
public void forupdateByTransaction() throws Exception {
// 主線程獲取獨(dú)占鎖
reentrantLock.lock();
new Thread(() -> transactionTemplate.execute(transactionStatus -> {
// select * from forupdate where name = #{name} for update
this.forupdateMapper.findByName("testforupdate");
System.out.println("==========for update==========");
countDownLatch.countDown();
// 阻塞不讓提交事務(wù)
reentrantLock.lock();
return null;
})).start();
countDownLatch.await();
System.out.println("==========for update has countdown==========");
this.forupdateMapper.updateByName("testforupdate");
System.out.println("==========update success==========");
reentrantLock.unlock();
}
此時(shí) for update 被包裝在 Spring 事務(wù)中,將事務(wù)交由 Spring 管理吗铐,根據(jù)數(shù)據(jù)事務(wù)機(jī)制东亦,sql 執(zhí)行過程中,只有執(zhí)行了 commit 或者 rollback 操作唬渗, 才會(huì)提交事務(wù)典阵,所以此時(shí)每次執(zhí)行 commit,for update 沒有被釋放镊逝,會(huì)鎖住對(duì)應(yīng)資源壮啊,直到提交事務(wù)釋放 for udpate。所以此時(shí)的主線程執(zhí)行更新操作會(huì)阻塞蹋半。
不加 Spring 事務(wù)并發(fā)執(zhí)行
public void forupdateByConcurrent() {
AtomicInteger atomicInteger = new AtomicInteger();
for (int i = 0; i < 100; i++) {
new Thread(() -> {
// select * from forupdate where name = #{name} for update
this.forupdateMapper.findByName("testforupdate");
System.out.println("========ok:" + atomicInteger.getAndIncrement());
}).start();
}
}
首先我們先將數(shù)據(jù)庫(kù)連接池的初始化大小調(diào)大一點(diǎn)他巨,使該次并發(fā)執(zhí)行至少會(huì)獲取 2 個(gè)以上 ID 不同的 connection 對(duì)象來執(zhí)行 for update充坑,以下是某一次的執(zhí)行日志:
[圖片上傳失敗...(image-b1b717-1597968503918)]
得到測(cè)試結(jié)果减江,發(fā)現(xiàn)如果有 2 個(gè)或以上 ID 不同的 connection 對(duì)象執(zhí)行 sql,會(huì)發(fā)生阻塞捻爷,而 Mysql 不會(huì)發(fā)生阻塞辈灼,至于 Mysql 為什么不會(huì)發(fā)生阻塞,后面我再給大家解釋也榄。
由于我們使用的 druid 連接池巡莹,它的 autoCommit 默認(rèn)為 true司志,所以我此時(shí)將 druid 連接池的 autoCommit 參數(shù)設(shè)置為 false,再次跑測(cè)試代碼降宅,發(fā)現(xiàn)此時(shí) oracle 不會(huì)發(fā)生阻塞骂远,我們先記住這個(gè)測(cè)試結(jié)果,下面我會(huì)帶大家走一波源碼腰根,來解釋這個(gè)現(xiàn)象激才。
聰明的你可能會(huì)想到,Mybatis 的底層源碼不是給我們封裝了一些重復(fù)性操作嗎额嘿,比如我們執(zhí)行一條 sql 語句瘸恼,mybatis 自動(dòng)為我們 commit 或者 rollback了,這也是 JDBC 框架的基本要求册养,那么既然 Mybatis 幫我們 commit 了东帅,for update 應(yīng)該會(huì)被釋放才對(duì),為什么還會(huì)發(fā)生阻塞問題呢球拦?如果你能想到這個(gè)問題靠闭,說明你是個(gè)認(rèn)真思考的人,這個(gè)問題我們也是先記住坎炼,后面會(huì)有解釋阎毅。
加 Spring 事務(wù)并發(fā)執(zhí)行
private void forupdateByConcurrentAndTransaction() {
AtomicInteger atomicInteger = new AtomicInteger();
for (int i = 0; i < 100; i++) {
new Thread(() -> transactionTemplate.execute(transactionStatus -> {
// select * from forupdate where name = #{name} for update
this.forupdateMapper.findByName("testforupdate");
System.out.println("========ok:" + atomicInteger.getAndIncrement());
return null;
})).start();
}
}
這個(gè)案例分析主要是為了測(cè)試是否跟 Spring 事務(wù)有關(guān)聯(lián),我將 druid 鏈接池的 autoCommit 參數(shù)分別設(shè)置為 true 和 false点弯,發(fā)現(xiàn) for update 在 Spring 事務(wù)的包裝下并發(fā)執(zhí)行扇调,并不會(huì)發(fā)生阻塞,從測(cè)試結(jié)果來看抢肛,似乎是跟 Spring 事務(wù)有很大的關(guān)系狼钮。
我們現(xiàn)在總結(jié)一下案例分析測(cè)試結(jié)果:
- 事務(wù)不提交,for update 悲觀鎖不會(huì)被釋放捡絮;
- 不加 Spring 事務(wù)并發(fā)執(zhí)行 for update 語句熬芜,如果有兩個(gè)以上的不同 ID 的 connection 執(zhí)行 for update,會(huì)發(fā)生阻塞現(xiàn)象福稳,Mysql 則不會(huì)阻塞涎拉;
- 不加 Spring 事務(wù)并發(fā)執(zhí)行 for update 語句,并且 druid 連接池的 autocommit=false的圆,不會(huì)發(fā)生阻塞鼓拧;
- 加 Spring 事務(wù)并發(fā)執(zhí)行 for update 語句,不會(huì)發(fā)生阻塞越妈。
源碼走一波
基于上述的案例分析季俩,我們?cè)创a走一波,從底層源碼的角度來解析為什么會(huì)有這樣的結(jié)果梅掠。
Mybatis 事務(wù)管理器
有沒有發(fā)現(xiàn)酌住,我到現(xiàn)在也是一直在強(qiáng)調(diào) Spring 事務(wù)店归,其實(shí)在數(shù)據(jù)庫(kù)的角度來說,sql 只要在 START TRANSACTION 與 COMMIT 或者 ROLLBACK 之間執(zhí)行酪我,就算是一個(gè)事務(wù)消痛,而我強(qiáng)調(diào)的 Spring 事務(wù),指的是在Spring 管理下的事務(wù)都哭,而 Mybatis 也有自己的事務(wù)管理器肄满,通常我們使用 Mybatis 都是配合 Spring 來使用,而 Spring 整合 Mybatis质涛,在 Mybatis-spring 包中稠歉,有一個(gè)名叫 SpringManagedTransaction 的類,這個(gè)就是 Mybatis 在 Spring 體系下的的 JDBC 事務(wù)管理器汇陆,Mybatis 用它來管理 JDBC connection 的生命周期怒炸,別看它名字是以 Spring 開頭,但它和 Spring 的事務(wù)管理器沒有半毛錢關(guān)系毡代。
Mybatis 執(zhí)行 sql 時(shí)會(huì)創(chuàng)建一個(gè) SqlSession 會(huì)話阅羹,關(guān)于 SqlSession,坐我旁邊的鐘同學(xué)之前有向我提問過 SqlSession 的創(chuàng)建機(jī)制教寂,我特意寫了一篇文章捏鱼,感興趣的可以看看,這里就不再重復(fù)述說了:
「鐘同學(xué)酪耕,this is for you导梆!」
在創(chuàng)建 SqlSession 時(shí),相應(yīng)地會(huì)創(chuàng)建一個(gè)事務(wù)管理器:
org.mybatis.spring.transaction.SpringManagedTransactionFactory#newTransaction:
public Transaction newTransaction(DataSource dataSource, TransactionIsolationLevel level, boolean autoCommit) {
return new SpringManagedTransaction(dataSource);
}
創(chuàng)建一個(gè) transaction 時(shí)迂烁,我們發(fā)現(xiàn)傳入的 autoCommit 根本沒有賦值給 SpringManagedTransaction看尼,這里暗藏玄機(jī),我們繼續(xù)往下看:
執(zhí)行 sql 時(shí)盟步,Mybatis 會(huì)從事務(wù)管理器中從數(shù)據(jù)庫(kù)連接池中獲取一個(gè) connection 對(duì)象:
org.mybatis.spring.transaction.SpringManagedTransaction#openConnection:
private void openConnection() throws SQLException {
this.connection = DataSourceUtils.getConnection(this.dataSource);
this.autoCommit = this.connection.getAutoCommit();
this.isConnectionTransactional = DataSourceUtils.isConnectionTransactional(this.connection, this.dataSource);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(
"JDBC Connection ["
+ this.connection
+ "] will"
+ (this.isConnectionTransactional ? " " : " not ")
+ "be managed by Spring");
}
}
這里會(huì)從數(shù)據(jù)庫(kù)連接池中獲取 connection 對(duì)象藏斩,然后將 connection 對(duì)象中的 autoCommit 值賦值給 SpringManagedTransaction!可以這么理解却盘,在 Spring 體系下的 Mybatis 事務(wù)管理器狰域,autoCommit 的值被數(shù)據(jù)庫(kù)連接池的覆蓋掉了!而后面的 debug 日志也說明了黄橘,這個(gè) JDBC connection 對(duì)象不歸你 Spring 管理兆览,我 Mybatis 自己就可以管理了,你 Spring 就別瞎參合了旬陡。
sql 執(zhí)行完之后拓颓,Mybatis 會(huì)自動(dòng)幫我們 commit语婴,我們來看 SqlSessionTemplate 的 sqlSession 代理:
org.mybatis.spring.SqlSessionTemplate.SqlSessionInterceptor:
if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
// force commit even on non-dirty sessions because some databases require
// a commit/rollback before calling close()
sqlSession.commit(true);
}
判斷如果不歸 Spring 事務(wù)管理描孟,那么會(huì)強(qiáng)制執(zhí)行 commit 操作驶睦,我們點(diǎn)進(jìn)去,發(fā)現(xiàn)最終調(diào)用的是 Mybatis 的事務(wù)管理器的 commit 方法:
org.mybatis.spring.transaction.SpringManagedTransaction#commit:
public void commit() throws SQLException {
if (this.connection != null && !this.isConnectionTransactional && !this.autoCommit) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Committing JDBC Connection [" + this.connection + "]");
}
this.connection.commit();
}
}
問題就出現(xiàn)在這里匿醒,前面我也說了场航,我們使用的 druid 數(shù)據(jù)庫(kù)連接池的 autoCommit 默認(rèn)為 true,而事務(wù)管理器獲取 connection 對(duì)象時(shí)廉羔,又將 connection 的 autocommit 賦值給事務(wù)管理器溉痢,如果此時(shí) autoCommit 為 true,Mybatis 認(rèn)為 connection 已經(jīng)自動(dòng)提交事務(wù)了憋他,既然這事不歸我管孩饼,那么我 Mybatis 自然就不會(huì)再去 commit 了。
根據(jù)測(cè)試結(jié)果竹挡,將 druid 的 autoCommit 設(shè)置為 false 后镀娶,不會(huì)發(fā)生阻塞現(xiàn)象,即 Mybaits 會(huì)執(zhí)行下面的 commit 操作揪罕。那么問題來了梯码,connection 的 autocommit = true 時(shí),到底有沒有 commit 好啰?從測(cè)試結(jié)果來看轩娶,很明顯沒有 commit。這里就要從數(shù)據(jù)庫(kù)層來解釋了框往,由于公司 Oracle 數(shù)據(jù)庫(kù)的 autocommit 使用的是默認(rèn)的 false 值鳄抒,即需要顯式提交 commit 事務(wù)才會(huì)被提交。這也就是為什么當(dāng) druid 的 autoCommit=false 時(shí)椰弊,并發(fā)執(zhí)行不會(huì)產(chǎn)生阻塞現(xiàn)象嘁酿,因?yàn)?Mybatis 已經(jīng)幫我們自動(dòng) commit 了。
而為什么當(dāng) druid 的 autoCommit=true 時(shí)男应,Mysql 依然不會(huì)阻塞呢闹司?我先開啟 Mysql 的日志打印:
set global general_log = 1;
查看日志沐飘,發(fā)現(xiàn) Mysql 會(huì)為每條執(zhí)行的 sql 設(shè)置 autocommit=1游桩,即自動(dòng)提交事務(wù),無須顯式提交 commit耐朴,每條 sql 就是一個(gè)事務(wù)借卧。
Spring 事務(wù)管理器
上面的案例分析中,加了 Spring 事務(wù)的并發(fā)執(zhí)行筛峭,并不會(huì)產(chǎn)生阻塞現(xiàn)象铐刘,顯然肯定是 Spring 事務(wù)做了一些不可描述的動(dòng)作,Spring 的事務(wù)管理器有很多個(gè)影晓,這里我們用的是數(shù)據(jù)庫(kù)連接池那個(gè)管理器镰吵,叫 DataSourceTransactionManager檩禾,我這里為了靈活控制事務(wù)范圍的細(xì)粒度,用的是聲明式事務(wù)疤祭,我們繼續(xù)走一波源碼盼产,從事務(wù)入口一路跟蹤進(jìn)來,發(fā)現(xiàn)第一步需要調(diào)用 doBegin 方法:
org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin:
// Switch to manual commit if necessary. This is very expensive in some JDBC drivers,
// so we don't want to do it unnecessarily (for example if we've explicitly
// configured the connection pool to set it already).
if (con.getAutoCommit()) {
txObject.setMustRestoreAutoCommit(true);
if (logger.isDebugEnabled()) {
logger.debug("Switching JDBC Connection [" + con + "] to manual commit");
}
con.setAutoCommit(false);
}
我們?cè)?doBegin 方法發(fā)現(xiàn)了它偷偷地篡改了連接對(duì)象 autoCommit 的值勺馆,將它設(shè)為 false戏售,這里想必大家都會(huì)明白其中的原理吧,Spring 管理事務(wù)其實(shí)就是在 sql 執(zhí)行前將當(dāng)前的 connection 對(duì)象設(shè)置為不自動(dòng)提交模式草穆,接下來執(zhí)行的 sql 都不會(huì)自動(dòng)提交灌灾,等待事務(wù)結(jié)束時(shí),Spring 事務(wù)管理器會(huì)幫我們 commit 提交事務(wù)悲柱。這也就是為什么加了 Spring 事務(wù)的并發(fā)執(zhí)行并不會(huì)產(chǎn)生阻塞的原因紧卒,原理與上述 Mybatis 所描述的一樣。
org.springframework.jdbc.datasource.DataSourceTransactionManager#doCleanupAfterCompletion:
// Reset connection.
Connection con = txObject.getConnectionHolder().getConnection();
try {
if (txObject.isMustRestoreAutoCommit()) {
con.setAutoCommit(true);
}
DataSourceUtils.resetConnectionAfterTransaction(con, txObject.getPreviousIsolationLevel());
}
catch (Throwable ex) {
logger.debug("Could not reset JDBC Connection after transaction", ex);
}
在事務(wù)完成之后诗祸,我們還需要將 connection 對(duì)象還原跑芳,因?yàn)?connection 存在于連接池當(dāng)中,close 時(shí)并不會(huì)真正關(guān)閉直颅,而是被回收回連接池當(dāng)中了博个,如果不對(duì) connection 對(duì)象進(jìn)行還原,那么當(dāng)下一次會(huì)話拿到該 connection 對(duì)象功偿,autoCommit 還是上一次會(huì)話的值盆佣,就會(huì)產(chǎn)生一些很隱晦的問題。