起因
- 一個序列號產生方法發現有併發問題。
- 修改這個方法中發生了一些錯誤,而這涉及到了一些的知識點,所以記錄下。
涉及點
synchronized
方法:如果此方法內包含數據庫操作,且外圍有事務時,並不能完全鎖住。- 數據庫隔離級別
- RC-不可重複讀,也就是在同一個事務中,多次select同一條sql獲取的結果可能不同。
- RR-可重複度,同一個事務中,多次select同一條sql獲取的結果相同,除非中間update進行了數據修改。
- mysql的默認隔離級別是RR,其它的數據庫一般默認級別是RC。
- mybatis緩存,在有事務的情況下,多次select同一個sql,第二次將不再請求數據庫,而是直接從緩存讀取。
描述
情景一
-
類似的代碼如下,下發的代碼在併發時就會出現重複的code:
public Integer getCode(){ CodeRule codeRuleInfo = selectCodeRuleInfo();//1、從數據庫獲取當前的記錄 Integer newCode = codeRuleInfo.getCurrentCode() + 1;//當前code+1 codeRuleInfo.setCode(newCode);//2、賦值最新的 updateCodeRule(codeRuleInfo);//3、更新數據庫 return newCode; }
-
由於是單機環境,最開始就想用
synchronized
解決,但發現getCode
本身是在一個事務下的,這樣就導致僅僅靠synchronized
並不能鎖住,原因如下:1-事務開始 -> 2-synchronized開始 -> 3-synchronized結束 -> 4-事務提交
其中3和4之間會又時間差,導致這期間如果有併發進來,還是會出現重複,所以需要改成:
1-synchronized開始 -> 2-事務開始 -> 3-事務提交 -> 4-synchronized結束
-
正常的改進應該是:
//原有方法,增加synchronized鎖 public synchronized Integer getCode(){ return AAA.getCode(); } //另一個類中AAA,開啓新事務 @Transactional(propagation = Propagation.REQUIRES_NEW) public Integer getCode(){ CodeRule codeRuleInfo = selectCodeRuleInfo(); Integer newCode = codeRuleInfo.getCurrentCode() + 1;//當前code+1 codeRuleInfo.setCode(newCode); updateCodeRule(codeRuleInfo); return newCode; }
情景二
-
當時改的時候腦子抽了,並沒有按照上方的改,而是改成這樣了:
//原有方法,增加synchronized鎖 public synchronized Integer getCode(){ CodeRule codeRuleInfo = selectCodeRuleInfo(); Integer newCode = codeRuleInfo.getCurrentCode() + 1;//當前code+1 codeRuleInfo.setCode(newCode); AAA.updateCodeRule(codeRuleInfo);//只把更新放到了新事務中 return newCode; } //另一個類中AAA,開啓新事務 @Transactional(propagation = Propagation.REQUIRES_NEW) public Integer updateCodeRule(){ //執行sql更新 }
-
上述方法就導致一個問題,
select
方法在主事務中,而update
方法在新事務中,部署到線上果然不但沒解決,反而非併發僅僅是循環中就全部重複了,原因如下:- mysql默認的隔離級別是
RR
,也就是同一個事務內多次select
同一個語句,獲取的結果都是一樣的,除非兩個select
之間有update
操作纔會重新獲取最新數據。但我們的update
方法放在了新的事務中,導致並沒有對select
產生影響,所以循環下的getCode
就會產生重複數據。
- mysql默認的隔離級別是
-
但是詭異的是,我們在部署前有作過簡單的測試,測試是正常遞增的,找了半天才發現是
mybatis緩存
的原因:- mybatis一級緩存會對多次相同的sql查詢進行緩存,第二次就不會再讀取數據庫了,而是直接從緩存中獲取,除非中間進行了數據庫的增刪改,而新事務中增刪改同樣不影響緩存清除,聽起來和數據庫的
RR
隔離級別很像,但這裏有個本質區別,它緩存的是一個對象,如果你對這個對象中的數據進行了修改,那麼下次獲取的是修改後的數據,而我們代碼中每次都有codeRuleInfo.setCode(newCode);
來對此對象進行了修改,所以在mybatis緩存的作用下,取出的數據卻是正常遞增的。
- mybatis一級緩存會對多次相同的sql查詢進行緩存,第二次就不會再讀取數據庫了,而是直接從緩存中獲取,除非中間進行了數據庫的增刪改,而新事務中增刪改同樣不影響緩存清除,聽起來和數據庫的
-
這樣就又不對了,照理來說如果按照mybatis緩存,那正式環境也不應該會重複,再進一步的排查,原因如下:
- 我們測試環境中的很簡單,就一個
select
和一個update
,但我們正式環境中getCode
僅僅是一個主事務中的一個小方法,在循環中getCode
後面還有一些其它表的增刪改操作,而這些增刪改會清空mybatis的緩存。 - 這裏我們有一個誤區,原以爲mybatis緩存也是僅僅只有針對該表的增刪改纔會清除緩存,但實際上是針對任意表的增刪改都會清除緩存,所以我們線上環境是每次都會清緩存從數據庫讀取,然後就是數據庫
RR
隔離級別導致數據重複,而本地測試則是走緩存,正常增加。
- 我們測試環境中的很簡單,就一個
更好的併發處理
- 這種涉及到數據庫又存在事務的,採用
synchronized
方法還是有些缺陷的:子事務獨立於主事務,如果子事務出問題,則不能整體回滾了。感覺更應該考慮用數據庫的悲觀鎖和樂觀鎖來解決,或者是參考微服務的一些鎖實現。 - 悲觀鎖實現比較簡單點,在併發不高時可以考慮,在
select
語句中加上for update
,但需要注意的是在RR
隔離環境下,如果select
沒有命中數據,可能會產生間隙鎖(很複雜,不同主鍵、索引、非索引造成的鎖類型各不相同,可自行搜索相關資料),此時又insert
就可能會造成死鎖。解決方法是設置數據庫隔離級別爲RC
,因爲RC
模式下不會有間隙鎖產生,從而不會死鎖。 - mysql數據庫設置成
RC
隔離級別也沒什麼問題,畢竟Oracle
、PostgreSQL
等一大衆數據庫的默認級別就是RC
,而阿里雲等一些雲廠商的RDS數據庫隔離級別默認也是RC
。