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

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

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

image.png

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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末械荷,一起剝皮案震驚了整個(gè)濱河市共耍,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌吨瞎,老刑警劉巖痹兜,帶你破解...
    沈念sama閱讀 217,734評(píng)論 6 505
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異颤诀,居然都是意外死亡字旭,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,931評(píng)論 3 394
  • 文/潘曉璐 我一進(jìn)店門崖叫,熙熙樓的掌柜王于貴愁眉苦臉地迎上來遗淳,“玉大人,你說我怎么就攤上這事心傀∏担” “怎么了?”我有些...
    開封第一講書人閱讀 164,133評(píng)論 0 354
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)养叛。 經(jīng)常有香客問我种呐,道長(zhǎng),這世上最難降的妖魔是什么一铅? 我笑而不...
    開封第一講書人閱讀 58,532評(píng)論 1 293
  • 正文 為了忘掉前任陕贮,我火速辦了婚禮堕油,結(jié)果婚禮上潘飘,老公的妹妹穿的比我還像新娘。我一直安慰自己掉缺,他們只是感情好卜录,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,585評(píng)論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著眶明,像睡著了一般艰毒。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上搜囱,一...
    開封第一講書人閱讀 51,462評(píng)論 1 302
  • 那天丑瞧,我揣著相機(jī)與錄音,去河邊找鬼蜀肘。 笑死绊汹,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的扮宠。 我是一名探鬼主播西乖,決...
    沈念sama閱讀 40,262評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼坛增!你這毒婦竟也來了获雕?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,153評(píng)論 0 276
  • 序言:老撾萬榮一對(duì)情侶失蹤收捣,失蹤者是張志新(化名)和其女友劉穎届案,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體罢艾,經(jīng)...
    沈念sama閱讀 45,587評(píng)論 1 314
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡萝玷,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,792評(píng)論 3 336
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了昆婿。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片球碉。...
    茶點(diǎn)故事閱讀 39,919評(píng)論 1 348
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖仓蛆,靈堂內(nèi)的尸體忽然破棺而出睁冬,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 35,635評(píng)論 5 345
  • 正文 年R本政府宣布豆拨,位于F島的核電站直奋,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏施禾。R本人自食惡果不足惜脚线,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,237評(píng)論 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望弥搞。 院中可真熱鬧邮绿,春花似錦、人聲如沸攀例。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,855評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽粤铭。三九已至挖胃,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間梆惯,已是汗流浹背酱鸭。 一陣腳步聲響...
    開封第一講書人閱讀 32,983評(píng)論 1 269
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留垛吗,地道東北人凹髓。 一個(gè)月前我還...
    沈念sama閱讀 48,048評(píng)論 3 370
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像职烧,于是被迫代替她去往敵國(guó)和親扁誓。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,864評(píng)論 2 354