幾行爛代碼,用錯Transactional,賠了16萬~

架構的小事 2024-05-14 02:17:14

前幾天在某平台看到一個技術問題,很有意思啊。

涉及到的兩個技術點,大家平時開發使用的也比較多,但是屬于一個小細節,深挖下去,還是有點意思的。

來,先帶你看一下問題是什麽,同時給你解讀一下這個問題:

https://segmentfault.com/q/1010000040361592

首先,這位同學給出了一個代碼片段:

他說他有一個 func 方法,這個方法裏面幹了兩件事:

1.先查詢數據庫裏面的商品庫存。2.如果還有庫存,那麽對庫存進行減一操作,模擬商品賣出。

對于第二件事,提問的同學其實寫了兩個操作在裏面,所以我再細分一下:

2.1 對庫存進行減一操作。2.2 在訂單表插入訂單數據。

很顯然,這兩個操作都會對數據庫進行操作,且應該是應該原子性的操作。

所以,在方法上加了一個 @Transactional 注解。

接著,爲了解決並發訪問的問題,他用 lock 把整個代碼包裹了起來,保證在單體結構下,同一時刻只有一個請求能去執行減少庫存,生成訂單的操作。

非常的完美。

首先,先把大前提申明一下:MySQL 數據庫的隔離機制使用的是可重複讀級別。

這個時候,問題就來了。

如果是高並發的情況下,假設真的就有多個線程同時調用 func 方法。

要保證一定不能出現超賣的情況,那麽就需要事務的開啓與提交能完整的包裹在 lock 與 unlock之間。

顯然事務的開啓一定是在 lock 之後的。

故關鍵在于事務的提交是否一定在 unlock 之前?

如果事務的提交在 unlock 之前,沒有問題。

因爲事務已經提交了,代表庫存一定減下來了,而這個時候鎖還沒釋放,所以,其他線程也進不來。

畫個簡單的示意圖如下:

等 unlock 之後,再進來一個線程,執行查詢數據庫的操作,那麽查詢到的值一定是減去庫存之後的值。

但是,如果事務的提交是在 unlock 之後,那麽有意思的事情就出現了,你很有可能發生超賣的情況。

上面的圖就變成了這樣的了,注意最後兩個步驟調換了:

舉個例子。

假設現在庫存就只有一個了。

這個時候 A,B 兩個線程來請求下單。

A 請求先拿到鎖,然後查詢出庫存爲一,可以下單,走了下單流程,把庫存減爲 0 了。

但是由于 A 先執行了 unlock 操作,釋放了鎖。

B 線程看到後馬上就沖過來拿到了鎖,並執行了查詢庫存的操作。

注意了,這個時候 A 線程還沒來得及提交事務,所以 B 讀取到的庫存還是 1,如果程序沒有做好控制,也走了下單流程。

哦豁,超賣了。

所以,再次重申問題:

在上面的示例代碼的情況下,如果事務的提交在 unlock 之前,是沒有問題的。但是如果在 unlock 之後是會有問題的。

那麽事務的提交到底是在 unlock 之前還是之後呢?

這個事情,先把問題聽懂了,接著我們先按下不表。你可以簡單的思考一下。

我想先聊聊這句被我輕描淡寫,一筆帶過,你大概率沒有注意到的話:

顯然事務的開啓一定是在 lock 之後的。

這句話,不是我說的,是提問的同學說的:

你有沒有一絲絲疑問?

怎麽就顯然了?哪裏就顯然了?爲什麽不是一進入方法就開啓事務了?

請給我證據。

來吧,瞅一眼證據。

事務開啓時機

證據,我們需要去源碼裏面找。

另外,我不得不多說一句 Spring 在事務這塊的源碼寫的非常的清晰易懂,看起來基本上沒有什麽障礙。

所以如果你不知道怎麽去啃源碼,那麽事務這塊源碼,也許是你撕開源碼的一個口子。

好了,不多說了,去找答案。

答案就藏在這個方法裏面的:

org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin

先看我下面框起來的那一行日志:

Switching JDBC Connection [HikariProxyConnection@946359486 wrapping com.mysql.jdbc.JDBC4Connection@7a24806] to manual commit

你知道的,我是個技術博主,偶爾教點單詞。

Switching,轉換。

Connection,鏈接。

manual commit,手動提交。

Switching ... to ...,把什麽轉換爲什麽。

沒想到吧,這次學技術的同時不僅學了幾個單詞,還會了一個語法。

所以,上面那句話翻譯過來就非常簡單了:

把數據庫連接切換爲手動提交。

然後,我們看一下打印這行日志的代碼邏輯,也就是被框起來的代碼部分。

我單獨拿出來:

邏輯非常清晰,就是把連接的 AutoCommit 參數從 ture 修改爲 false。

那麽現在問題就來了,這個時候,事務啓動了嗎?

我覺得沒啓動,只是就緒了而已。

啓動和就緒還是有一點點差異的,就緒是啓動之前的步驟。

那麽事務的啓動有哪些方式呢?

第一種:使用啓動事務的語句,這種是顯式的啓動事務。比如 begin 或 start transaction 語句。與之配套的提交語句是 commit,回滾語句是 rollback。第二種:autocommit 的值默認是 1,含義是事務的自動提交是開啓的。如果我們執行 set autocommit=0,這個命令會將這個線程的自動提交關掉。意味著如果你只執行一個 select 語句,這個事務就啓動了,而且並不會自動提交。這個事務持續存在直到你主動執行 commit 或 rollback 語句,或者斷開連接。

很顯然,在 Spring 裏面采用的是第二種方式。

而上面的代碼 con.setAutoCommit(false) 只是把這個鏈接的自動提交關掉。

事務真正啓動的時機是什麽時候呢?

前面說的 begin/start transaction 命令並不是一個事務的起點,在執行到它們之後的第一個操作 InnoDB 表的語句,事務才算是真正啓動。

如果你想要馬上啓動一個事務,可以使用 start transaction with consistent snapshot 這個命令。需要注意的是這個命令在讀已提交的隔離級別(RC)下是沒意義的,和直接使用 start transaction 一個效果。

回到在前面的問題:什麽時候才會執行第一個 SQL 語句?

就是在 lock 代碼之後。

所以,顯然事務的開啓一定是在 lock 之後的。

這一個簡單的“顯然”,先給大家鋪墊一下。

接下來,給大家上個動圖看一眼,更加直觀。

首先說一下這個 SQL:

select * from information_schema.innodb_trx;

不多解釋,你只要知道這是查詢當前數據庫有哪些事務正在執行的語句就行。

你就注意看下面的動圖,是不是第 27 行查詢語句執行完成之後,查詢事務的語句才能查出數據,說明事務這才真正的開啓:

最後,我們把目光轉移到這個方法的注釋上:

寫這麽長一段注釋,意思就是給你說,這個參數我們默認是 ture,原因就是在某些 JDBC 的驅動中,切換爲自動提交是一個很重的操作。

那麽在哪設置的爲 true 呢?

沒看到代碼,我一般是不死心的。

所以,一起去看一眼。

setAutoCommit 這個方法有好幾個實現類,我也不知道具體會走哪一個:

所以,我們可以在下面這個接口打上一個斷點:

java.sql.Connection#setAutoCommit

然後重啓程序,IDE 會自動幫你判斷走那個實現類的:

可以看到,默認確實是 true。

等等,你不會真的以爲我是想讓你看這個 true 吧?

我是想讓你知道這個調試技巧啊。

不知道有多少個小夥伴曾經問過我:這個接口實現類好多啊,我怎麽知道在哪打斷點啊?

我說:很簡單啊,就在每個實現類的第一行代碼打上斷點就好了。

然後他說:別鬧,我經常給你的文章一鍵三聯。

我當時就被感動了,既然是這樣的好讀者,我當然把可以直接在接口上打斷點的這個小技巧教給他啦。

好了,不扯遠了。

再說一個小細節,這一小節就收尾。

你再去看這小節的開頭,我直接說答案藏在這個方法裏面:

org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin

直接把答案告訴你了,隱去了探索的過程。

但是這個東西,就像是數學公式推導一樣,省略了一步,就會讓人看起來一臉懵逼。

就像下面這個小耗子一樣:

所以,我是怎麽知道在這個地方打斷點的呢?

答案就是調用棧。

先給大家看一下我的代碼:

啥也先不管,上來就先在 26 行,方法入口處打上斷點,跑起來:

诶,你看這個調用棧,我框起來的這個地方:

看這個名字,你就不好奇嗎?

它簡直就是在跳著腳,在喊你:點我,快,愣著幹啥,你TM快點我啊。我這裏有秘密!

然後,我就這樣輕輕的一點,就到了這裏:

org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction

這裏有個切面,可以理解爲 try 裏面就是在執行我們的業務代碼邏輯:

而在 try 代碼塊,執行我們的業務代碼之前,有這樣的一行代碼:

找到這裏了,你就在這一行代碼之前,再輕輕的打個斷點,然後調試進去,就能找到這一小節開始的時候,說的這個方法:

org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin

不信?你看嘛,我不騙你。

它們之間只隔了三個調用:

這樣就找到答案了。

調用棧,另一個調試源碼小技巧,屢試不爽,送給你。

之前還是之後

好了,前面是開胃菜,可能有的同學吃開胃菜就已經弄飽了。

沒事,現在上正餐,再按一按還是能吃進去的。

還是拿前面的這份代碼來說事,流程就是這樣的:

1.先拿鎖。2.查詢庫存。3.判斷是否還有庫存。4.有庫存則執行減庫存,創建訂單的邏輯。5.沒有庫存則返回。6.釋放鎖。

所以代碼是這樣的:

完全符合我們之前的那份代碼片段,有事務,也有鎖:

回到我們最開始抛出來的問題:

在上面的示例代碼的情況下那麽事務的提交到底是在 unlock 之前還是之後呢?

我們可以帶入一個具體的場景。

比如我數據庫裏面有 10 個頂配版的 iPad,原價 1.6w 元一台,現在單價 1w 一個,這個價格夠秒殺吧?

反正一共就 10 台,所以,我的數據庫裏面是這樣的,

然後我搞 100 個人來搶東西,不過分吧?

我這裏用 CountDownLatch 來模擬一下並發:

執行一下,先看結果,立馬就見分曉:

動圖右邊的部分:

上面是浏覽器請求,觸發 Controller 的代碼。

然後中間是産品表,有 10 個庫存。

最下面是訂單表,沒有一條數據。

觸發了代碼之後,庫存爲 0 了,沒有問題。

但是,訂單居然有 20 筆!

也就是說超賣了 10 個ipad pro 頂配版!

超賣的,可不在活動預算範圍內啊!

那可就是一個 1.6w 啊,10 個就是 16w 啊。

就這麽其貌不揚,人畜無害,甚至看起來猥猥瑣瑣的代碼,居然讓我虧了整整 16w 。

其實,結果出現了,答案也就隨之而來了。

在上面的示例代碼的情況下,事務的提交在 unlock 之後。

其實你仔細分析後,猜也能猜出來,肯定是在 unlock 之後的。

而且上面的描述“unlock之後”其實是有一定的迷惑性的,因爲釋放鎖是一個比較特別的操作。

換一個描述,就比較好理解了:

在上面的示例代碼的情況下,事務的提交在方法運行結束之後。

你細品,這個描述是不是迷惑性就沒有那麽強了,甚至你還會恍然大悟:這不是常識嗎?

爲什麽是方法結束之後,分析具體原因之前,我想先簡單分析一下這樣的代碼寫出來的原因。

我猜可能是這樣的。

最開始的代碼結構是這樣:

然後,寫著寫著發現不對,並發的場景下,庫存是一個共享的資源,這玩意得加鎖啊。

于是搞了這出:

後面再次審查代碼的時候,發現:喲,這個第三步得是一個事務操作才行呀。

于是代碼就成了這樣:

演進路線非常合理,最終的代碼看起來也簡直毫無破綻。

但是問題到底出在哪裏了呢?

找答案

答案還是在這個類裏面:

org.springframework.transaction.interceptor.TransactionAspectSupport#invokeWithinTransaction

前面我們聊事務開啓的時候,說的是第 382 行代碼。

然後 try 代碼塊裏面執行的是我們的業務代碼。

現在,我們要研究事務的提交了,所以主要看我框起來的地方。

首先 catch 代碼塊裏面,392 行,看方法名稱已經非常的見名知意了:

completeTransactionAfterThrowing 在抛出異常之後完成事務的提交。

你看我的代碼,只是用到了 @Transactional 注解,並沒有指定異常。

那麽問題就來了:

Spring 管理的事務,默認回滾的異常是什麽呢?

如果你不知道答案,就可以帶著問題去看源碼。

如果你知道答案,但是沒有親眼看到對應的代碼,那麽也可以去尋找源碼。

如果你知道答案,也看過這部分源碼,溫故而知新。

先說答案:默認回滾的異常是 RuntimeException 或者 Error。

我只需要在業務代碼裏面抛出一個 RuntimeException 的子類,比如這樣的:

然後在 392 行打上斷點,開始調試就完事了:

只需要往下調試幾步,你就能走到這個方法來:

org.springframework.transaction.interceptor.RuleBasedTransactionAttribute#rollbackOn

發現這個 winner 對象爲空,接著走了這個邏輯:

return super.rollbackOn(ex);

答案就藏著這行代碼的背後:

如果異常類型是 RuntimeException 或者 Error 的子類,那麽就返回 true,即需要回滾,調用 rollback 方法:

如果返回爲 false,則表示不需要回滾,調用 commit 方法:

那麽怎麽讓它返回 false 呢?

很簡單嘛,這樣一搞就好了:

框架給你留了口子,你就把它用起來。

當我把代碼改成上面那樣,然後重新啓動項目,再次訪問代碼。

我們去尋找出現指定異常不回滾的具體的實現邏輯在哪。

其實也在我們剛剛看到的方法裏面:

你看,這個時候 winner 不爲 null 了。它是一個 NoRollbackRuleAttribute 對象了。

所以就走入這行代碼,返回 false 了:

return !(winner instanceof NoRollbackRuleAttribute);

于是,就成功走到了 else 分支裏面,出了異常也 commit 了,你說神奇不神奇:

寫到這裏的時候,我突然想到了一個騷操作,甚至有可能變成一道沙雕面試題:

這個操作騷不騷,到底會回滾呢還是不回滾呢?

如果你在項目裏看到這樣的代碼肯定是要罵一句傻b的。

但是面試官就喜歡搞這些陰間的題目。

我想到這個問題的時候,我也不知道答案是什麽,但是我知道答案還是在源碼裏面:

首先,從結果上可以直觀的看到,經過 for 循環之後, winner 是 RollbackRuleAttribute 對象,所以下面的代碼返回 true,需要回滾:

return !(winner instanceof NoRollbackRuleAttribute);

問題就變成了 winner 爲什麽經過 for 循環之後是 RollbackRuleAttribute?

答案需要你自己去調試一下,很容易就明白了,我描述起來比較費勁。

簡單一句話:導致 winner 是 RollbackRuleAttribute 的原因,就是因爲被循環的這個 list 是先把 RollbackRuleAttribute 對象 add 了進去。

那麽爲什麽 RollbackRuleAttribute 對象先加入到集合呢?

org.springframework.transaction.annotation.SpringTransactionAnnotationParser#parseTransactionAnnotation(org.springframework.core.annotation.AnnotationAttributes)

別問,問就是因爲代碼是這樣寫的。

爲什麽代碼要這樣寫呢?

我想可能設計這塊代碼的開發人員覺得 rollbackFor 的優先級比 noRollbackFor 高吧。

再來一個問題:

Spring 源碼怎麽匹配當前這個異常是需要回滾的?

別想那麽複雜,大道至簡,直接遞歸,然後一層層的找父類,對比名稱就完事了。

你注意截圖裏面的注釋:

一個是 Found it!

表示找到了,匹配上了,用了感歎號表示很開心。

一個是 If we've gone as far as we can go and haven't found it...

啥意思呢,這個 as far as 在英語裏面是一個連詞,表示“直到..爲止..”的意思。引導的是狀語從句,強調的是程度或範圍。

所以,上面這句話的意思就是:

如果我們已經走到我們能走的最遠的地方,還沒匹配上,代碼就只能這樣寫了:

異常類,最遠的地方就是 Throwable.class。沒匹配上,就返回 -1。

好了,通過兩個沒啥卵用的知識點,順帶學了點實戰英語,關于業務代碼出了異常回滾還是提交這一塊的代碼就差不多了。

但是我還是建議大家親自去 Debug 一下,可太有意思了。

然後我們接著聊正常場景下的提交。

這個代碼塊裏面,try 我們也聊了,catch 我們也聊了。

就差個 finally 了。

我看網上有的文章說 finally 裏面就是 commit 的地方。

錯了啊,老弟。

這裏只是把數據庫連接給重置一下。

方法上已經給你說的很清楚了:

Spring 的事務是基于 ThreadLocal 來做的。在當前的這個事務裏面,可能有一些隔離級別、回滾類型、超時時間等等的個性化配置。

不管是這個事務正常返回還是出現異常,只要它完事了,就得給把這些個性化的配置全部恢複到默認配置。

所以,放到了 finally 代碼塊裏面去執行了。

真正的 commit 的地方是這行代碼:

那麽問題又來了:

走到這裏來了,事務一定會提交嗎?

話可別說的那麽絕對,兄弟,看代碼:

org.springframework.transaction.support.AbstractPlatformTransactionManager#commit

在 commit 之前還有兩個判斷,如果事務被標記爲 rollback-only 了,還是得回滾。

而且,你看日志。

我這事務還沒提交呢,鎖就被釋放了?

接著往下看 commit 相關的邏輯,我們就會遇到老朋友:

HikariCP,SpringBoot 2.0 之後的默認連接池,強得一比,在之前的文章裏面介紹過。

關于事務的提交,就不大篇幅的介紹了。

給大家指個路:

com.mysql.cj.protocol.a.NativeProtocol#sendQueryString

在這個方法的入口處打上斷點:

然後你會發現很多的 SQL 都會經過這個地方。

所以,爲了你順利調試,你需要在斷點上設置一下:

這樣只有 SQL 語句是 commit 的時候才會停下來。

又一個調試小細節,送給你,不客氣。

現在,我們知道原因了,那我現在把代碼稍微變一下:

把 ReentrantLock 換成了 synchronized。

那你說這個代碼還會不會有問題?

說沒有問題的同學請好好反思一下。

這個地方的原理和前面講的東西是一模一樣的呀,肯定也是有問題的。

這個加鎖方式就是錯誤的。

所以你記住了,以後面試官問你 @Transactional 的時候,你把標准答案先背一遍之後,如果你對鎖這塊的知識點非常的熟悉,就可以在不經意間說一下結合鎖用的時候的異常場景。

別說你寫的,就說你 review 代碼的時候發現的,深藏功與名。

另外記得擴展一下,現在都是集群服務了,加鎖得上分布式鎖。

但是原理還這個原理。

既然都聊到分布式鎖了,這和面試官又得大戰幾個回合。

是你主動提起的,把面試官引到了你的主戰場,拿幾分,不過分吧。

一個面試小技巧,送給你,不客氣。

解決方案

現在我們知道問題的原因了。

解決方案其實都呼之欲出了嘛。

正確的使用鎖,把整個事務放在鎖的工作範圍之內:

這樣,就可以保證事務的提交一定是在 unlock 之前了。

對不對?

說對的同學,今天就先到這裏,請回去等通知啊。

別被帶到溝裏去了呀,朋友。

你仔細想想這個事務會生效嗎?

提示到這裏還沒想明白的同學,趕緊去搜一下事務失效的幾種場景。

我這裏說一個能正常使用的場景:

只是這種自己注入自己的方式,我覺得很惡心。

如果項目裏面出現了這樣的代碼,一定是代碼分層沒有做好,項目結構極其混亂。

不推薦。

還可以使用編程式事務的方式去寫,自己去控制事務的開啓、提交、回滾。

比直接使用 @Transactional 靠譜。

除此之外,還有一個騷一點的解決方案。

其他地方都不動,就只改一下 @Transactional 這個地方:

把隔離級別串行化,再次跑測試用例,絕對不會出現超賣的情況。

甚至都不需要加鎖的邏輯。

你覺得好嗎?

好啥啊?

串行化性能跟不上啊!

這玩意太悲觀了,對于同一行的數據,讀和寫的時候都會進行加鎖操作。當讀寫鎖出現沖突的時候,後面來的事務就排隊等著。

這個騷操作,知道就行了,別用。

你就當是一個沒啥卵用的知識點就行了。

但是,如果你們是一個不追求性能的場景,這個沒有卵用的知識點就變成騷操作了。

rollback-only

前面提到了這個 rollback-only,爲了更好的行文,所以我一句話就帶過了,其實它也是很有故事的,單獨拿一節出來簡單說一下,給大家模擬一下這個場景。

以後你見到這個異常就會感覺很親切。

Spring 的事務傳播級別默認是 REQUIRED,含義是如果當前沒有事務,就新建一個事務,如果上下文中已經有一個事務,則共享這個事務。

直接上代碼:

這裏有 sellProduct、sellProductBiz 兩個事務,sellProductBiz 是內層事務,它會抛出了異常。

當執行整個邏輯的時候,會抛出這個異常:

Transaction rolled back because it has been marked as rollback-only

根據這個異常的堆棧,可以找到這個地方,在前面出現過:

所以,我們只需要分析這個 if 條件爲什麽滿足了,就大概摸清楚脈絡了。

if (!shouldCommitOnGlobalRollbackOnly() && defStatus.isGlobalRollbackOnly())

前面的 shouldCommitOnGlobalRollbackOnly 默認爲 false:

問題就精簡爲了:defStatus.isGlobalRollbackOnly() 爲什麽是true?

爲什麽?

因爲 sellProductBiz 抛出異常後,會調用 completeTransactionAfterThrowing 方法執行回滾邏輯。

肯定是這個方法裏面搞事情了啊。

org.springframework.transaction.support.AbstractPlatformTransactionManager#processRollback

在這裏,把鏈接的 rollbackOnly 置爲了 true。

所以,後面的事務想要 commit 的時候,一檢查這個參數,哦豁,回滾吧。

大概就是這樣的:

如果這不是你期望的異常,怎麽解決呢?

理解了事務的傳播機制就簡單的一比:

就這樣,跑起來沒毛病,互不幹擾。

來源:公衆號

作者:why技術

0 阅读:0

架構的小事

簡介:感謝大家的關注