序列號生成併發引發的synchronized、數據庫隔離級別、myabits緩存等一些問題記錄

起因

  • 一個序列號產生方法發現有併發問題。
  • 修改這個方法中發生了一些錯誤,而這涉及到了一些的知識點,所以記錄下。

涉及點

  • 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就會產生重複數據。
  • 但是詭異的是,我們在部署前有作過簡單的測試,測試是正常遞增的,找了半天才發現是mybatis緩存的原因:

    • mybatis一級緩存會對多次相同的sql查詢進行緩存,第二次就不會再讀取數據庫了,而是直接從緩存中獲取,除非中間進行了數據庫的增刪改,而新事務中增刪改同樣不影響緩存清除,聽起來和數據庫的RR隔離級別很像,但這裏有個本質區別,它緩存的是一個對象,如果你對這個對象中的數據進行了修改,那麼下次獲取的是修改後的數據,而我們代碼中每次都有codeRuleInfo.setCode(newCode);來對此對象進行了修改,所以在mybatis緩存的作用下,取出的數據卻是正常遞增的。
  • 這樣就又不對了,照理來說如果按照mybatis緩存,那正式環境也不應該會重複,再進一步的排查,原因如下:

    • 我們測試環境中的很簡單,就一個select和一個update,但我們正式環境中getCode僅僅是一個主事務中的一個小方法,在循環中getCode後面還有一些其它表的增刪改操作,而這些增刪改會清空mybatis的緩存。
    • 這裏我們有一個誤區,原以爲mybatis緩存也是僅僅只有針對該表的增刪改纔會清除緩存,但實際上是針對任意表的增刪改都會清除緩存,所以我們線上環境是每次都會清緩存從數據庫讀取,然後就是數據庫RR隔離級別導致數據重複,而本地測試則是走緩存,正常增加。

更好的併發處理

  • 這種涉及到數據庫又存在事務的,採用synchronized方法還是有些缺陷的:子事務獨立於主事務,如果子事務出問題,則不能整體回滾了。感覺更應該考慮用數據庫的悲觀鎖和樂觀鎖來解決,或者是參考微服務的一些鎖實現。
  • 悲觀鎖實現比較簡單點,在併發不高時可以考慮,在select 語句中加上for update,但需要注意的是在RR隔離環境下,如果select沒有命中數據,可能會產生間隙鎖(很複雜,不同主鍵、索引、非索引造成的鎖類型各不相同,可自行搜索相關資料),此時又insert就可能會造成死鎖。解決方法是設置數據庫隔離級別爲RC,因爲RC模式下不會有間隙鎖產生,從而不會死鎖。
  • mysql數據庫設置成RC隔離級別也沒什麼問題,畢竟OraclePostgreSQL等一大衆數據庫的默認級別就是RC,而阿里雲等一些雲廠商的RDS數據庫隔離級別默認也是RC
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章