[TOC]
公司的某些業(yè)務(wù)用到了數(shù)據(jù)庫的悲觀鎖 for update,但有些同事沒有把 for update 放在 Spring 事務(wù)中執(zhí)行,在并發(fā)場景下發(fā)生了嚴重的線程阻塞問題,為了把這個問題吃透,秉承著老司機的職業(yè)素養(yǎng),我決定要給同事們一個交代。
案發(fā)現(xiàn)場
最近公司的某些 Dubbo 服務(wù)之間的 RPC 調(diào)用過程中,偶然性地發(fā)生了若干起嚴重的超時問題,導(dǎo)致了某些模塊不能正常提供服務(wù)。我們的數(shù)據(jù)庫用的是 Oracle,經(jīng)過 DBA 排查,發(fā)現(xiàn)了一些 sql 的執(zhí)行時間特別長,對比發(fā)現(xiàn)這些執(zhí)行時間長的 sql 都帶有 for update 悲觀鎖,于是相關(guān)開發(fā)人員查看 sql 對應(yīng)的業(yè)務(wù)代碼,發(fā)現(xiàn) for update 沒有放在 Spring 事務(wù)中執(zhí)行,但是按照常理來說,如果 for update 沒有加 Spring 事務(wù),每次執(zhí)行完 Mybatis 都會幫我們 commit 釋放掉資源,并發(fā)時出現(xiàn)的問題應(yīng)該是沒有鎖住對應(yīng)資源產(chǎn)生臟數(shù)據(jù)而不是發(fā)生阻塞。但是經(jīng)過代碼的調(diào)試,不加 Spring 事務(wù)并發(fā)執(zhí)行確實會阻塞。
案例分析
基于案發(fā)現(xiàn)場的問題所在,我特地寫了幾個針對問題的案例分析測試代碼,”talk is cheap, show you the code”:
加 Spring 事務(wù)執(zhí)行但不提交事務(wù)
public void forupdateByTransaction() throws Exception {
// 主線程獲取獨占鎖
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();
}
此時 for update 被包裝在 Spring 事務(wù)中,將事務(wù)交由 Spring 管理,根據(jù)數(shù)據(jù)事務(wù)機制,sql 執(zhí)行過程中,只有執(zhí)行了 commit 或者 rollback 操作, 才會提交事務(wù),所以此時每次執(zhí)行 commit,for update 沒有被釋放,會鎖住對應(yīng)資源,直到提交事務(wù)釋放 for udpate。所以此時的主線程執(zhí)行更新操作會阻塞。
不加 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ù)庫連接池的初始化大小調(diào)大一點,使該次并發(fā)執(zhí)行至少會獲取 2 個以上 ID 不同的 connection 對象來執(zhí)行 for update,以下是某一次的執(zhí)行日志:
[圖片上傳失敗...(image-b1b717-1597968503918)]
得到測試結(jié)果,發(fā)現(xiàn)如果有 2 個或以上 ID 不同的 connection 對象執(zhí)行 sql,會發(fā)生阻塞,而 Mysql 不會發(fā)生阻塞,至于 Mysql 為什么不會發(fā)生阻塞,后面我再給大家解釋。
由于我們使用的 druid 連接池,它的 autoCommit 默認為 true,所以我此時將 druid 連接池的 autoCommit 參數(shù)設(shè)置為 false,再次跑測試代碼,發(fā)現(xiàn)此時 oracle 不會發(fā)生阻塞,我們先記住這個測試結(jié)果,下面我會帶大家走一波源碼,來解釋這個現(xiàn)象。
聰明的你可能會想到,Mybatis 的底層源碼不是給我們封裝了一些重復(fù)性操作嗎,比如我們執(zhí)行一條 sql 語句,mybatis 自動為我們 commit 或者 rollback了,這也是 JDBC 框架的基本要求,那么既然 Mybatis 幫我們 commit 了,for update 應(yīng)該會被釋放才對,為什么還會發(fā)生阻塞問題呢?如果你能想到這個問題,說明你是個認真思考的人,這個問題我們也是先記住,后面會有解釋。
加 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();
}
}
這個案例分析主要是為了測試是否跟 Spring 事務(wù)有關(guān)聯(lián),我將 druid 鏈接池的 autoCommit 參數(shù)分別設(shè)置為 true 和 false,發(fā)現(xiàn) for update 在 Spring 事務(wù)的包裝下并發(fā)執(zhí)行,并不會發(fā)生阻塞,從測試結(jié)果來看,似乎是跟 Spring 事務(wù)有很大的關(guān)系。
我們現(xiàn)在總結(jié)一下案例分析測試結(jié)果:
- 事務(wù)不提交,for update 悲觀鎖不會被釋放;
- 不加 Spring 事務(wù)并發(fā)執(zhí)行 for update 語句,如果有兩個以上的不同 ID 的 connection 執(zhí)行 for update,會發(fā)生阻塞現(xiàn)象,Mysql 則不會阻塞;
- 不加 Spring 事務(wù)并發(fā)執(zhí)行 for update 語句,并且 druid 連接池的 autocommit=false,不會發(fā)生阻塞;
- 加 Spring 事務(wù)并發(fā)執(zhí)行 for update 語句,不會發(fā)生阻塞。
源碼走一波
基于上述的案例分析,我們源碼走一波,從底層源碼的角度來解析為什么會有這樣的結(jié)果。
Mybatis 事務(wù)管理器
有沒有發(fā)現(xiàn),我到現(xiàn)在也是一直在強調(diào) Spring 事務(wù),其實在數(shù)據(jù)庫的角度來說,sql 只要在 START TRANSACTION 與 COMMIT 或者 ROLLBACK 之間執(zhí)行,就算是一個事務(wù),而我強調(diào)的 Spring 事務(wù),指的是在Spring 管理下的事務(wù),而 Mybatis 也有自己的事務(wù)管理器,通常我們使用 Mybatis 都是配合 Spring 來使用,而 Spring 整合 Mybatis,在 Mybatis-spring 包中,有一個名叫 SpringManagedTransaction 的類,這個就是 Mybatis 在 Spring 體系下的的 JDBC 事務(wù)管理器,Mybatis 用它來管理 JDBC connection 的生命周期,別看它名字是以 Spring 開頭,但它和 Spring 的事務(wù)管理器沒有半毛錢關(guān)系。
Mybatis 執(zhí)行 sql 時會創(chuàng)建一個 SqlSession 會話,關(guān)于 SqlSession,坐我旁邊的鐘同學之前有向我提問過 SqlSession 的創(chuàng)建機制,我特意寫了一篇文章,感興趣的可以看看,這里就不再重復(fù)述說了:
「鐘同學,this is for you!」
在創(chuàng)建 SqlSession 時,相應(yīng)地會創(chuàng)建一個事務(wù)管理器:
org.mybatis.spring.transaction.SpringManagedTransactionFactory#newTransaction:
public Transaction newTransaction(DataSource dataSource, TransactionIsolationLevel level, boolean autoCommit) {
return new SpringManagedTransaction(dataSource);
}
創(chuàng)建一個 transaction 時,我們發(fā)現(xiàn)傳入的 autoCommit 根本沒有賦值給 SpringManagedTransaction,這里暗藏玄機,我們繼續(xù)往下看:
執(zhí)行 sql 時,Mybatis 會從事務(wù)管理器中從數(shù)據(jù)庫連接池中獲取一個 connection 對象:
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");
}
}
這里會從數(shù)據(jù)庫連接池中獲取 connection 對象,然后將 connection 對象中的 autoCommit 值賦值給 SpringManagedTransaction!可以這么理解,在 Spring 體系下的 Mybatis 事務(wù)管理器,autoCommit 的值被數(shù)據(jù)庫連接池的覆蓋掉了!而后面的 debug 日志也說明了,這個 JDBC connection 對象不歸你 Spring 管理,我 Mybatis 自己就可以管理了,你 Spring 就別瞎參合了。
sql 執(zhí)行完之后,Mybatis 會自動幫我們 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ù)管理,那么會強制執(zhí)行 commit 操作,我們點進去,發(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ù)庫連接池的 autoCommit 默認為 true,而事務(wù)管理器獲取 connection 對象時,又將 connection 的 autocommit 賦值給事務(wù)管理器,如果此時 autoCommit 為 true,Mybatis 認為 connection 已經(jīng)自動提交事務(wù)了,既然這事不歸我管,那么我 Mybatis 自然就不會再去 commit 了。
根據(jù)測試結(jié)果,將 druid 的 autoCommit 設(shè)置為 false 后,不會發(fā)生阻塞現(xiàn)象,即 Mybaits 會執(zhí)行下面的 commit 操作。那么問題來了,connection 的 autocommit = true 時,到底有沒有 commit ?從測試結(jié)果來看,很明顯沒有 commit。這里就要從數(shù)據(jù)庫層來解釋了,由于公司 Oracle 數(shù)據(jù)庫的 autocommit 使用的是默認的 false 值,即需要顯式提交 commit 事務(wù)才會被提交。這也就是為什么當 druid 的 autoCommit=false 時,并發(fā)執(zhí)行不會產(chǎn)生阻塞現(xiàn)象,因為 Mybatis 已經(jīng)幫我們自動 commit 了。
而為什么當 druid 的 autoCommit=true 時,Mysql 依然不會阻塞呢?我先開啟 Mysql 的日志打?。?/p>
set global general_log = 1;

查看日志,發(fā)現(xiàn) Mysql 會為每條執(zhí)行的 sql 設(shè)置 autocommit=1,即自動提交事務(wù),無須顯式提交 commit,每條 sql 就是一個事務(wù)。
Spring 事務(wù)管理器
上面的案例分析中,加了 Spring 事務(wù)的并發(fā)執(zhí)行,并不會產(chǎn)生阻塞現(xiàn)象,顯然肯定是 Spring 事務(wù)做了一些不可描述的動作,Spring 的事務(wù)管理器有很多個,這里我們用的是數(shù)據(jù)庫連接池那個管理器,叫 DataSourceTransactionManager,我這里為了靈活控制事務(wù)范圍的細粒度,用的是聲明式事務(wù),我們繼續(xù)走一波源碼,從事務(wù)入口一路跟蹤進來,發(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);
}
我們在 doBegin 方法發(fā)現(xiàn)了它偷偷地篡改了連接對象 autoCommit 的值,將它設(shè)為 false,這里想必大家都會明白其中的原理吧,Spring 管理事務(wù)其實就是在 sql 執(zhí)行前將當前的 connection 對象設(shè)置為不自動提交模式,接下來執(zhí)行的 sql 都不會自動提交,等待事務(wù)結(jié)束時,Spring 事務(wù)管理器會幫我們 commit 提交事務(wù)。這也就是為什么加了 Spring 事務(wù)的并發(fā)執(zhí)行并不會產(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 對象還原,因為 connection 存在于連接池當中,close 時并不會真正關(guān)閉,而是被回收回連接池當中了,如果不對 connection 對象進行還原,那么當下一次會話拿到該 connection 對象,autoCommit 還是上一次會話的值,就會產(chǎn)生一些很隱晦的問題。