建議自查!MySQL驅(qū)動Bug引發(fā)的事務(wù)不回滾問題,也許你正面臨該風(fēng)險!
不點藍字關(guān)注,我們哪來故事?

作者:KL博主
來源:https://my.oschina.net/klblog/blog/5542934
關(guān)于事務(wù)不回滾的問題,我們之前有講過多期:
我來出個題:這些事務(wù)會不會回滾?大概率你會錯!
為什么catch了異常,但事務(wù)還是回滾了?
群友:事務(wù)中的異常不也拋出了,為什么沒catch到而回滾?
為什么加了@Transactional注解,事務(wù)沒有回滾?
今天分享一個開源文檔在線預(yù)覽項目解決方案kkFileView作者發(fā)現(xiàn)的一個最新情況。
如標(biāo)題,最終查明問題是因為 mysql-connector-java:8.0.28 的一個 bug 導(dǎo)致的。但是在真相未浮出之前,整個問題可謂撲朔迷離,博主好久沒有排查過如此得勁的 bug ,隨著一層層的 debug 深入,真相也隨之浮出水面。這個問題屬于底層 jdbc 驅(qū)動的問題,具有普遍性,可能不知不覺中,你的應(yīng)用也在線上遭受這個 bug 的摧殘,所以,請耐心聽我講完這個故事,然后回去檢查下你的應(yīng)用狀態(tài),是否也踩坑了。喜歡直接的可以直接拉到文末結(jié)語看結(jié)果。
背景
講故事一般先介紹人物、背景。這里也不列外,先把相關(guān)方介紹下。通常,故事情節(jié)越豐富越精彩,但是這里博主會考慮篇幅 (不講廢話) 會把一些與結(jié)果走向無關(guān)的細(xì)節(jié)忽略掉,力求敘述完整就好。
commons-db : 我們內(nèi)部維護的,一個采用注解驅(qū)動的 Spring 生態(tài)下的多數(shù)據(jù)源管理組件。組件給每個 DataSource 預(yù)設(shè)了些性能優(yōu)化的默認(rèn)值,沒有全部列出,不過包含了影響問題走向的屬性(useLocalSessionState),如下:
Properties defaultProperties = new Properties();
defaultProperties.put("prepStmtCacheSize", 300);
defaultProperties.put("prepStmtCacheSqlLimit", 2048);
defaultProperties.put("useLocalSessionState", true);
defaultProperties.put("cacheResultSetMetadata", true);
defaultProperties.put("elideSetAutoCommits", true);
java-project : 用來測試組件功能的項目,會作為和出現(xiàn)問題的項目做行為測試對比。spring-boot:2.5.4、mysql-connector-java:8.0.26 store:游戲庫項目,正是這個項目發(fā)現(xiàn)了問題。spring-boot:2.6.6 、mysql-connector-java:8.0.28 阿里云 RDS (MySQL): 阿里云 MySQL 默認(rèn)的隔離級別為 READ_COMMITTED,而 MySQL 默認(rèn)的隔離級別為 REPEATABLE_READ
說明:java-project 和 store 的 commons-db 版本其實不一樣,因為不影響結(jié)果。這里假設(shè)他們版本一致。
問題
一天,開發(fā)反饋,在 store 項目里使用 commons-db 組件時,出現(xiàn)了事務(wù)回滾不生效的問題。如下圖代碼所示:
@Transactional
@DataSource(type = Type.MASTER,value = "developer")
public void addUser(ApolloUser user){
userRepository.save(user);
int i = 1/0; //拋異常
}
具體表現(xiàn)為:執(zhí)行 addUser 方法,當(dāng) 1/0 拋出 RuntimeException 類型異常時,user 對象還是添加成功了。一句話總結(jié)就是,【事務(wù)回滾不生效了】。
假設(shè)
假設(shè) 1:曾假設(shè)過是不是 @Transactional 的 aop 沒生效,導(dǎo)致并未開啟顯式事務(wù)。 假設(shè) 1 不成立,因為在開啟了 debug 日志模式后,清晰的輸出了事務(wù)每個階段的行為日志,如:

假設(shè) 2:考慮到使用了 commons-db , 如果框架層連接管理問題,導(dǎo)致了事務(wù)的開啟、事務(wù)回滾時獲取到的連接不一致,也有可能導(dǎo)致這個問題。 假設(shè) 2 不成立:馬上就否了,因為從上面日志上可以看到連接是同一個連接。而且不同連接執(zhí)行非預(yù)期的開啟、回滾事務(wù)操作應(yīng)該會拋異常才是。
那么到這里,問題陷入了僵局。不禁沉思,一個看上去人畜無害的代碼,一個看上去邏輯清晰的事務(wù)日志,為什么會事務(wù)回滾失效呢?????
轉(zhuǎn)機
轉(zhuǎn)機 1
隨后,我在 java-project 項目里,使用相同的 MySQL 測試了下,發(fā)現(xiàn)事務(wù)回滾成功了。說明這個問題僅僅影響特定的環(huán)境,而且可以通過對比兩個項目的差異找到問題,離真相更近了。
轉(zhuǎn)機 2
開發(fā)那邊又傳來一個關(guān)鍵的信息,在 store 項目中,當(dāng)設(shè)置隔離級別為 REPEATABLE_READ 時,事務(wù)回滾生效了。代碼如:
@Transactional(isolation = Isolation.REPEATABLE_READ)
@DataSource(type = Type.MASTER,value = "developer")
public void addUser(ApolloUser user){
userRepository.save(user);
int i = 1/0;
}
到這里,然道要懷疑是隔離級別的問題么?顯然是不成立的,因為對事務(wù)的認(rèn)知字典里,就沒出現(xiàn)過隔離級別影響事務(wù)回滾的字條。然后從 java-project 的測試也可以看出,在相同的 RC 隔離級別下,java-project 可以成功。
第一個解決方法
然后終歸是向前進了一步了,可以臨時用設(shè)置隔離級別的辦法來解決【事務(wù)回滾不生效問題】。不過,不同的隔離級別,對事務(wù)鎖、并發(fā)性能是不一樣,這個在調(diào)整前必須要有預(yù)期。
轉(zhuǎn)機 3
事出反常必有妖,本著不信是隔離級別導(dǎo)致的問題,我在 store 項目里將 isolation 設(shè)置成 Isolation.READ_UNCOMMITTED ,發(fā)現(xiàn)事務(wù)回滾也生效了。這也說明了和隔離級別沒有直接的關(guān)系。然后本著探究【為啥默認(rèn)的 READ_COMMITTED 導(dǎo)致事務(wù)不生效?】的思路排查了下,發(fā)現(xiàn)了些問題,如下代碼是事務(wù)邏輯中的一部分(源碼見:DataSourceUtils.prepareConnectionForTransaction ()):

發(fā)現(xiàn),相比 RR、RU , 差別就是當(dāng)隔離級別是 READ_COMMITTED 時,不會在對 session 有更新操作了。到這一步也只是多了一個明確的現(xiàn)象,可以解釋知道真相后的行為,并沒有觸達真相邊緣。
分析
上文整了一堆,還沒發(fā)現(xiàn)真實問題。所以先不做其他測試了,先分析下有預(yù)期后,在針對性去驗證。
先來看下普遍的正常的 Spring Transactional 完整的事務(wù)回滾的過程,普遍的指的是沒有做過特殊參數(shù)配置的,一般這些參數(shù)也不會配置。
1、在添加了 @Transactional 的方法執(zhí)行前,會執(zhí)行事務(wù)管理器(DataSourceTransactionManager)的 doBegin 方法創(chuàng)建一個事務(wù),在 doBegin 方法里,會設(shè)置 autoCommit = false。會判當(dāng)前隔離級別是否和用戶定義的一致,否則就更新隔離級別。

2、方法執(zhí)行失敗后,會執(zhí)行事務(wù)管理器(DataSourceTransactionManager)的 doRollback 方法回滾事務(wù)。
從 Spring Transactional 的事務(wù)日志沒看出來問題,創(chuàng)建事務(wù)、設(shè)置手動提交事務(wù)、回滾事務(wù)都有日志打印。那么我們就深入到驅(qū)動層、或者抓包看,是否這些指令都發(fā)到 MySQL Server 了。
定位問題
如分析,在 store 項目中,將斷點打在 mysql-connector-java 驅(qū)動的 NativeSession.execSQL () 方法里,和 MySQL Server 交互的所有指令,最終都會調(diào)用這個方法執(zhí)行。果然發(fā)現(xiàn)了問題:
事務(wù)回滾失敗時,事務(wù)流程并未執(zhí)行 SET autocommit=0 指令。
等于說事務(wù)回滾失敗時,事務(wù)一直是自動提交的模式,所以,異?;貪L操作并不會回滾已經(jīng)持久化了的數(shù)據(jù)。
發(fā)現(xiàn)這個問題后,接著定位為什么 Spring 執(zhí)行了 Set autoCommit=false , 而最終確并未執(zhí)行的問題,這里再次通過【轉(zhuǎn)機 1】的 java-project 項目做單步調(diào)試對比,發(fā)現(xiàn)一段關(guān)鍵代碼(ConnectionImpl.setAutoCommit ())兩個項目里的代碼不一致:
java-project,mysql-connector-java:8.0.26(事務(wù)回滾生效)

store,mysql-connector-java:8.0.28(事務(wù)回滾不生效)

這里稍微介紹下這個參數(shù)
useLocalSessionState:維護本地 sessionState , 在需要判斷 【事務(wù)提交模式】、【隔離級別】設(shè)置時,獲取本地狀態(tài),而不是每次像 MySQL Server 發(fā)起詢問。
這個參數(shù)有助于減少和 MySQL 的交互,可以提升寫數(shù)據(jù)性能。所以在參數(shù)性能優(yōu)化時,被默認(rèn)設(shè)置為 true 了。這里,如果 useLocalSessionState=false,則正好會掩蓋這個 bug。
解密
因為在 store,mysql-connector-java:8.0.28 有問題的版本的 isAutocommit () 行為邏輯和 isAutoCommit () 不一致,本該調(diào)用判斷 isAutocommit 返回 true 時,卻返回了 false。最終才導(dǎo)致了 store 在接收到 Spring Transactional 設(shè)置 autoCommit=false 的請求時,因為 needsSetOnServer=false , 直接跳過了真正的發(fā)起 Set autocommit=0 指令的執(zhí)行。導(dǎo)致當(dāng)前事務(wù)模式是自動提交模式,所以當(dāng)事務(wù)里有任何增刪改操作時,會在執(zhí)行完后立馬 commit 持久化。這時如果異常而發(fā)起事務(wù) rollback ,自然不會回滾之前已經(jīng)自動提交的事務(wù)。這個很好的解釋了開頭貼出的事務(wù)日志很完整,但是事務(wù)就是回滾不生效的問題。
第二個解決方法
排查到這里,第二個解決問題的方法就出現(xiàn)了,只需要讓判斷是否需要執(zhí)行 Set autocommit=0 時的 needsSetOnServer=true 成立就行了。所以,只要對 store 應(yīng)用做如下兩個參數(shù)任一參數(shù)配置調(diào)整,則可以解決問題了。這個方法比第一個方法要合適些:
useLocalSessionState=false
auto-commit=false
解釋為啥 isolation 設(shè)置成 Isolation.REPEATABLE_READ 會生效
所以到這里就結(jié)束了嗎?并沒有,預(yù)期是即使 useLocalSessionState=ture ,事務(wù)也應(yīng)該完整。然后別忘了 isAutoCommit () 和 isAutocommit () 的差異。先來看下他們的定義:
public boolean isAutocommit() {
return (this.statusFlags & 2) != 0;
}
public boolean isAutoCommit() {
return this.autoCommit;
}
原來在 mysql-connector-java:8.0.28 驅(qū)動里,使用 statusFlags 狀態(tài)代替了 autoCommit 的標(biāo)識(這里先不考究為什么做這個改動),這個解釋了
轉(zhuǎn)機 2:當(dāng)設(shè)置隔離級別為 REPEATABLE_READ 時,事務(wù)回滾生效了。是因為當(dāng)用戶定義的隔離級別 RR 和默認(rèn)的 RC 不一致時,會觸發(fā) session 設(shè)置新的隔離級別,此時也會將 statusFlags = 0 更新為 statusFlags = 2. 故在調(diào)用 isAutocommit () 返回 true ,滿足了執(zhí)行 SET autocommit=0 指令的條件。
這里雖然知道了原因,也確切知道 isAutoCommit () != isAutocommit () ,但是為啥做如此改動確并不清楚。這里具體問題暫且不表,先來復(fù)現(xiàn)下問題。
復(fù)現(xiàn)問題
既然問題已經(jīng)大差不差的定位到了,那么按常規(guī)排查流程,按預(yù)期的問題場景復(fù)現(xiàn)下,明確下問題邊界。因為還還有可能有其他的影響因素一起導(dǎo)致的問題。在 java-project 項目中,做如下依賴的版本調(diào)整
升級 spring-boot:2.6.6 版本和 store 保持一致:問題復(fù)現(xiàn)了 保持 spring-boot:2.5.4,調(diào)整 mysql-connector-java:8.0.28 :問題也復(fù)現(xiàn)了
到這里,基本排除了 Spring Transactional 的嫌疑了。然后將矛頭鎖定到了 mysql-connector-java:8.0.28 身上。
確認(rèn) bug
考慮到從 mysql-connector-java:8.0.26 的 isAutoCommit 更改到了 mysql-connector-java:8.0.28 的 isAutocommit 肯定是有原因的,帶著弄清楚代碼作者提交這個改動的意圖,去翻了下 github。
https://github.com/mysql/mysql-connector-j
找了下 github 的提交記錄 commit ,發(fā)現(xiàn),最新版本的又改回了 isAutoCommit () 了,然后 Commit Message 明確說明了這是 8.0.28 版本的 bug,如。

至此,終于真相大白了。
修復(fù)
8.0.29 release:https://dev.mysql.com/doc/relnotes/connector-j/8.0/en/news-8-0-29.html A connection did not maintain the correct autocommit state when it was used in a pool with useLocalSessionState=true. (Bug #106435, Bug #33850099)
最終解決方法
如 8.0.29 release 公告說明,已經(jīng)修復(fù)了 8.0.28 在設(shè)置 useLocalSessionState=true 的情況下,autoCommit 狀態(tài)設(shè)置的問題。所以,應(yīng)用升級到 mysql-connector-java:8.0.29 版本即可
結(jié)語
先總結(jié)下問題表像為 Spring Transactional【事務(wù)回滾不生效,回滾前提交的數(shù)據(jù)不會回滾】,根本原因是 【mysql-connector-java:8.0.28 版本提交的一個改動 bug ,導(dǎo)致在啟用 useLocalSessionState=true 的情況下,autoCommit 狀態(tài)設(shè)置有問題】。
然后因為 spring-boot:2.6.3 ~ 2.6.7 ,這五個版本默認(rèn)的 MySQL 驅(qū)動就是 mysql-connector-java:8.0.28 ,而 useLocalSessionState=true 幾乎是 Java JDBC DataSource 里的標(biāo)配,所以這個 bug 估計會影響一大波人。然后因為只是影響回滾操作,所以這個問題會隱藏的很深,不容易察覺,所謂影響深遠。
最后,轉(zhuǎn)發(fā)本文支持一下作者,同時也讓更多小伙伴知道并提前處理該問題,避免半夜被叫起來處理問題的尷尬吧!

