微信公衆號「後端進階」,專一後端技術分享:Java、Golang、WEB框架、分佈式中間件、服務治理等等。
老司機傾囊相授,帶你一路進階,來不及解釋了快上車!java
公司的某些業務用到了數據庫的悲觀鎖 for update,但有些同事沒有把 for update 放在 Spring 事務中執行,在併發場景下發生了嚴重的線程阻塞問題,爲了把這個問題吃透,秉承着老司機的職業素養,我決定要給同事們一個交代。git
最近公司的某些 Dubbo 服務之間的 RPC 調用過程當中,偶然性地發生了若干起嚴重的超時問題,致使了某些模塊不能正常提供服務。咱們的數據庫用的是 Oracle,通過 DBA 排查,發現了一些 sql 的執行時間特別長,對比發現這些執行時間長的 sql 都帶有 for update 悲觀鎖,因而相關開發人員查看 sql 對應的業務代碼,發現 for update 沒有放在 Spring 事務中執行,可是按照常理來講,若是 for update 沒有加 Spring 事務,每次執行完 Mybatis 都會幫咱們 commit 釋放掉資源,併發時出現的問題應該是沒有鎖住對應資源產生髒數據而不是發生阻塞。可是通過代碼的調試,不加 Spring 事務併發執行確實會阻塞。github
基於案發現場的問題所在,我特意寫了幾個針對問題的案例分析測試代碼,"talk is cheap, show you the code":spring
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(); // 阻塞不讓提交事務 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 事務中,將事務交由 Spring 管理,根據數據事務機制,sql 執行過程當中,只有執行了 commit 或者 rollback 操做, 纔會提交事務,因此此時每次執行 commit,for update 沒有被釋放,會鎖住對應資源,直到提交事務釋放 for udpate。因此此時的主線程執行更新操做會阻塞。sql
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(); } }
首先咱們先將數據庫鏈接池的初始化大小調大一點,使該次併發執行至少會獲取 2 個以上 ID 不一樣的 connection 對象來執行 for update,如下是某一次的執行日誌:數據庫
獲得測試結果,發現若是有 2 個或以上 ID 不一樣的 connection 對象執行 sql,會發生阻塞,而 Mysql 不會發生阻塞,至於 Mysql 爲何不會發生阻塞,後面我再給你們解釋。後端
因爲咱們使用的 druid 鏈接池,它的 autoCommit 默認爲 true,因此我此時將 druid 鏈接池的 autoCommit 參數設置爲 false,再次跑測試代碼,發現此時 oracle 不會發生阻塞,咱們先記住這個測試結果,下面我會帶你們走一波源碼,來解釋這個現象。bash
聰明的你可能會想到,Mybatis 的底層源碼不是給咱們封裝了一些重複性操做嗎,好比咱們執行一條 sql 語句,mybatis 自動爲咱們 commit 或者 rollback了,這也是 JDBC 框架的基本要求,那麼既然 Mybatis 幫咱們 commit 了,for update 應該會被釋放纔對,爲何還會發生阻塞問題呢?若是你能想到這個問題,說明你是個認真思考的人,這個問題咱們也是先記住,後面會有解釋。微信
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 事務有關聯,我將 druid 連接池的 autoCommit 參數分別設置爲 true 和 false,發現 for update 在 Spring 事務的包裝下併發執行,並不會發生阻塞,從測試結果來看,彷佛是跟 Spring 事務有很大的關係。session
咱們如今總結一下案例分析測試結果:
貼上測試代碼地址:https://github.com/objcoding/test-forupdate
基於上述的案例分析,咱們源碼走一波,從底層源碼的角度來解析爲何會有這樣的結果。
有沒有發現,我到如今也是一直在強調 Spring 事務,其實在數據庫的角度來講,sql 只要在 START TRANSACTION 與 COMMIT 或者 ROLLBACK 之間執行,就算是一個事務,而我強調的 Spring 事務,指的是在Spring 管理下的事務,而 Mybatis 也有本身的事務管理器,一般咱們使用 Mybatis 都是配合 Spring 來使用,而 Spring 整合 Mybatis,在 Mybatis-spring 包中,有一個名叫 SpringManagedTransaction 的類,這個就是 Mybatis 在 Spring 體系下的的 JDBC 事務管理器,Mybatis 用它來管理 JDBC connection 的生命週期,別看它名字是以 Spring 開頭,但它和 Spring 的事務管理器沒有半毛錢關係。
Mybatis 執行 sql 時會建立一個 SqlSession 會話,關於 SqlSession,坐我旁邊的鐘同窗以前有向我提問過 SqlSession 的建立機制,我特地寫了一篇文章,感興趣的能夠看看,這裏就再也不重複述說了:
在建立 SqlSession 時,相應地會建立一個事務管理器:
org.mybatis.spring.transaction.SpringManagedTransactionFactory#newTransaction:
public Transaction newTransaction(DataSource dataSource, TransactionIsolationLevel level, boolean autoCommit) { return new SpringManagedTransaction(dataSource); }
建立一個 transaction 時,咱們發現傳入的 autoCommit 根本沒有賦值給 SpringManagedTransaction,這裏暗藏玄機,咱們繼續往下看:
執行 sql 時,Mybatis 會從事務管理器中從數據庫鏈接池中獲取一個 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"); } }
這裏會從數據庫鏈接池中獲取 connection 對象,而後將 connection 對象中的 autoCommit 值賦值給 SpringManagedTransaction!能夠這麼理解,在 Spring 體系下的 Mybatis 事務管理器,autoCommit 的值被數據庫鏈接池的覆蓋掉了!然後面的 debug 日誌也說明了,這個 JDBC connection 對象不歸你 Spring 管理,我 Mybatis 本身就能夠管理了,你 Spring 就別瞎參合了。
sql 執行完以後,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 事務管理,那麼會強制執行 commit 操做,咱們點進去,發現最終調用的是 Mybatis 的事務管理器的 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(); } }
問題就出如今這裏,前面我也說了,咱們使用的 druid 數據庫鏈接池的 autoCommit 默認爲 true,而事務管理器獲取 connection 對象時,又將 connection 的 autocommit 賦值給事務管理器,若是此時 autoCommit 爲 true,Mybatis 認爲 connection 已經自動提交事務了,既然這事不歸我管,那麼我 Mybatis 天然就不會再去 commit 了。
根據測試結果,將 druid 的 autoCommit 設置爲 false 後,不會發生阻塞現象,即 Mybaits 會執行下面的 commit 操做。那麼問題來了,connection 的 autocommit = true 時,到底有沒有 commit ?從測試結果來看,很明顯沒有 commit。這裏就要從數據庫層來解釋了,因爲公司 Oracle 數據庫的 autocommit 使用的是默認的 false 值,即須要顯式提交 commit 事務纔會被提交。這也就是爲何當 druid 的 autoCommit=false 時,併發執行不會產生阻塞現象,由於 Mybatis 已經幫咱們自動 commit 了。
而爲何當 druid 的 autoCommit=true 時,Mysql 依然不會阻塞呢?我先開啓 Mysql 的日誌打印:
set global general_log = 1;
查看日誌,發現 Mysql 會爲每條執行的 sql 設置 autocommit=1,即自動提交事務,無須顯式提交 commit,每條 sql 就是一個事務。
上面的案例分析中,加了 Spring 事務的併發執行,並不會產生阻塞現象,顯然確定是 Spring 事務作了一些不可描述的動做,Spring 的事務管理器有不少個,這裏咱們用的是數據庫鏈接池那個管理器,叫 DataSourceTransactionManager,我這裏爲了靈活控制事務範圍的細粒度,用的是聲明式事務,咱們繼續走一波源碼,從事務入口一路跟蹤進來,發現第一步須要調用 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 方法發現了它偷偷地篡改了鏈接對象 autoCommit 的值,將它設爲 false,這裏想必你們都會明白其中的原理吧,Spring 管理事務其實就是在 sql 執行前將當前的 connection 對象設置爲不自動提交模式,接下來執行的 sql 都不會自動提交,等待事務結束時,Spring 事務管理器會幫咱們 commit 提交事務。這也就是爲何加了 Spring 事務的併發執行並不會產生阻塞的緣由,原理與上述 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); }
在事務完成以後,咱們還須要將 connection 對象還原,由於 connection 存在於鏈接池當中,close 時並不會真正關閉,而是被回收回鏈接池當中了,若是不對 connection 對象進行還原,那麼當下一次會話拿到該 connection 對象,autoCommit 仍是上一次會話的值,就會產生一些很隱晦的問題。
其實這個問題從應用層來分析還好,直接擼源碼就完了,主要是這個問題還涉及一些數據庫底層的一些原理,因爲我對數據庫還不是那麼地專業,因此在這過程當中,還特別請教了公司的 DBA 斌哥哥,很是感謝他的協助。
另外,我實際上是不太建議使用 for update 這種悲觀鎖的,它太過於依賴數據庫層了,並且當併發量起來了,雖然能夠保證數據一致性,可是這樣犧牲了性能,會大大影響效率,嚴重拖垮數據庫資源,並且像此次同樣,有些開發人員使用了 for update 卻忘記 commit 事務了,致使引發不少鎖故障。