高併發下丟失更新的解決方案

京东云开发者發表於2022-11-23

作者:謝益培

1 背景

關鍵詞:併發、丟失更新

預收款賬戶表上有個累計抵扣金額的欄位,該欄位的含義是統計商家預收款賬戶上累計用於抵扣結算成功的金額數。更新時機是,賬單結算完成時,更新累計抵扣金額=累計抵扣金額 + 賬單金額。

2 問題及現象

發現當賬單結算完成時,偶爾會發生累計抵扣金額欄位值更新不準確的現象。

比如,某商家賬戶上累計抵扣金額原本為 0 元,當發生兩筆分別為 10 和 8 的賬單結算完成後,理論上累計抵扣金額應該變為 18 元,但實際為 10 元。也就是說,第二次更新把前一次更新內容給覆蓋掉了。

3 問題分析

該問題為典型的第二類丟失更新問題。

3.1 概念解釋

事務在併發情況下,常見如下問題:

  1. 髒讀:一個事務讀取了已被另個一個事務修改但尚未提交的資料。當一個事務正在訪問資料,並且對資料進行了修改,而這種修改還沒有提交到資料庫中;這時另外一個事務也訪問這個資料,然後使用了這個未提交的資料。
  2. 不可重複讀:在一個事務內,多次讀同一資料,讀到的結果不同。第一個事務還沒有結束時,另外一個事務也訪問該同一資料。那麼,在第一個事務中的兩次讀資料之間,由於第二個事務的修改,那麼第一個事務兩次讀到的資料可能是不一樣的。這樣就發生了在一個事務內兩次讀到的資料是不一樣的,因此稱為是不可重複讀。
  3. 幻讀:同一事務中,當同一個查詢執行多次的時候,由於其他事務進行了插入操作並提交事務,導致每次返回不同的結果集。幻讀是事務非獨立執行時發生的一種現象。例如第一個事務對一個表中的資料進行了修改,這種修改涉及到表的全部資料行。同時,第二個事務也修改了這個表中的資料,這種修改是向表中插入了一行新資料。那麼,就會發生操作第一個事務的使用者發現表中還有沒有修改的資料行,就好像發生了幻覺一樣。
  4. 更新丟失:兩個事務同時更新一行資料,一個事務對資料的更新把另一個事務對資料的更新覆蓋了。這是因為系統沒有執行任何的鎖操作,因此併發事務並沒有被隔離開來。

圖 1 SQL 標準定義了 4 種資料庫事務隔離級別

第一類丟失更新:A 事務撤銷時,把已經提交的 B 事務的更新資料覆蓋了。SQL 標準中未對此做定義,所有資料庫都已解決了第一類丟失更新的問題。

圖 2 第一類丟失更新

第二類丟失更新:A 事務覆蓋 B 事務已經提交的資料,造成 B 事務所做操作丟失。第二類丟失更新,和不可重複讀本質上是同一類併發問題,通常將它看成不可重複讀的特例。當兩個或多個事務查詢相同的記錄,然後各自基於查詢的結果更新記錄時會造成第二類丟失更新問題。每個事務不知道其它事務的存在,最後一個事務對記錄所做的更改將覆蓋其它事務之前對該記錄所做的更改。

圖 3 第二類丟失更新

3.2 疑惑點

發生問題的程式碼:

@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.REPEATABLE_READ)
public void finishDeductTransaction(String customerCode, String entityCode, String currency, ABTransaction abTransaction) {
    Account account = getAccount(customerCode, entityCode, currency);

    BigDecimal newValue = account.getCumulativeDeductionAmount().add(transaction.getTransactionAmount());
    account.setCumulativeDeductionAmount(newValue);
    //持久化
    Account update = new Account();
    update.setId(account.getId());
    update.setCumulativeDeductionAmount(account.getCumulativeDeductionAmount());
    accountBalanceInfoMapper.update(update);
}

上述程式碼中可以看到,該方法已設定事務隔離級別為可重複讀(isolation = Isolation.REPEATABLE_READ,也是 MySQL 的預設隔離級別)。按照之前對隔離級別規範的理解,可重複讀級別是應該能夠避免第二類更新丟失的問題的,但為啥還是發生了呢?!於是上網查閱相關資料,得到的結論是:MySQL 資料庫,設定事務隔離級別為可重複讀無法避免發生 “第二類丟失更新” 問題。從這個案例中也得到一個教訓就是,規範標準和所選的產品(元件)實際實現情況,兩者需要同時考慮,對於邊緣性或存在爭議的規範內容要儘可能避免直接使用,最好透過其他機制來保證。

4 解決方案

以下整理了針對該問題的常見解決方案並按解決思路進行了分類。

4.1 依賴資料庫的思路

方法 1:

將事務隔離級別改為序列,能解決但併發效能低,還可能導致大量超時和鎖競爭。

方法 2:

調整 SQL 語句,將更新賦值邏輯改為 “c=c+x” 形式,其中 c 為要更新的欄位,x 為增量值。這種方式能確保累加值不會被覆蓋。但這種方式需要額外編寫特殊的 SQL,而且嚴格意義上講,存在業務邏輯洩露到持久層的不規範問題。

4.2 悲觀鎖思路

方法 3:

方法執行時增加分散式鎖,來控制同一賬戶同一時刻只有一個執行緒可對其進行操作。效果等同將事務級別改為序列,也是排隊執行,併發效能也低,只是鎖機制不是由資料庫實現了而已。

分散式鎖的實現方式有多種,比如該專案中有封裝好的基於 Redis 的分散式鎖,其註解的使用方式如下:

@CbbSingle(key = "QF:finishDeductTransaction:customerCode", value = {"#{customerCode}"})
public void finishDeductTransaction(String customerCode, String entityCode, String currency, ABTransaction abTransaction) {

方法 4:

透過 SQL 語句啟用資料庫排他鎖,例如:select * from table where name=’xxx’ for update。透過此 sql 查詢到的資料就會被資料庫上排他鎖,因此其他事務就無法對該資料及進行修改了。

該方法比上面兩種在併發效能方面會好一些,但仍然可能存在鎖等待和超時情況發生。

4.3 樂觀鎖思路

方法 5:

樂觀鎖的思路是假設併發衝突發生機率較低,開啟事務時先不加鎖,在更新資料時透過版本比對以及判斷影響行數來判斷是否更新成功。

其中,版本概念,可以是要更新記錄的版本號,或者更新時間等。也可以用舊值條件或校驗和等方式;

該方法併發效能最好,但一旦發生併發衝突會導致方法執行失敗,此時就需要搭配額外的重試或自旋邏輯來閉環。

思路如下:

//先查詢出來要更新的資料
select column1,id,version from table where id=1001;
//進行業務邏輯處理
//更新這條資料
update table set column1=xx where id=1001 and version=查詢出來當時的version值
//判斷影響行數
if (records < 1) 更新失敗...

本案例中的問題便是採用這種方法解決的:

@Transactional(propagation = Propagation.REQUIRED, isolation = Isolation.REPEATABLE_READ)
public void finishDeductTransaction(String customerCode, String entityCode, String currency, ABTransaction abTransaction) {
    Account account = getAccount(customerCode, entityCode, currency);

    BigDecimal newValue = account.getCumulativeDeductionAmount().add(transaction.getTransactionAmount());
    account.setCumulativeDeductionAmount(newValue);

    //持久化
    Account update = new Account();
    update.setId(account.getId());
    update.setVersion(account.getVersion());
    update.setCumulativeDeductionAmount(account.getCumulativeDeductionAmount());
    int records = accountBalanceInfoMapper.updateByIdAndVersion(update);
    if (records < 1) {
        throw new SingleThreadException("更新時資料版本號已發生改變。原因:發生併發事務");
    }
}

5 總結

  1. 對於常見併發事務問題,需要將事務隔離級別和鎖機制結合起來一起使用;
  2. 需要對併發問題從業務場景上進行分析和識別,對於併發衝突少的場景,首選樂觀鎖思路;對於併發衝突高的場景採用悲觀鎖思路;

相關文章