由for update引發(fā)的血案

[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é)果:

  1. 事務(wù)不提交,for update 悲觀鎖不會被釋放;
  2. 不加 Spring 事務(wù)并發(fā)執(zhí)行 for update 語句,如果有兩個以上的不同 ID 的 connection 執(zhí)行 for update,會發(fā)生阻塞現(xiàn)象,Mysql 則不會阻塞;
  3. 不加 Spring 事務(wù)并發(fā)執(zhí)行 for update 語句,并且 druid 連接池的 autocommit=false,不會發(fā)生阻塞;
  4. 加 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;

image.png

查看日志,發(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)生一些很隱晦的問題。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

友情鏈接更多精彩內(nèi)容