作者:謝益培
關鍵詞:並行、丟失更新
預收款賬戶表上有個累計抵扣金額的欄位,該欄位的含義是統計商家預收款賬戶上累計用於抵扣結算成功的金額數。更新時機是,賬單結算完成時,更新累計抵扣金額=累計抵扣金額+賬單金額。
發現當賬單結算完成時,偶爾會發生累計抵扣金額欄位值更新不準確的現象。
比如,某商家賬戶上累計抵扣金額原本為0元,當發生兩筆分別為10和8的賬單結算完成後,理論上累計抵扣金額應該變為18元,但實際為10元。也就是說,第二次更新把前一次更新內容給覆蓋掉了。
該問題為典型的第二類丟失更新問題。
事務在並行情況下,常見如下問題:
第一類丟失更新:A事務復原時,把已經提交的B事務的更新資料覆蓋了。SQL標準中未對此做定義,所有資料庫都已解決了第一類丟失更新的問題。
第二類丟失更新:A事務覆蓋B事務已經提交的資料,造成B事務所做操作丟失。第二類丟失更新,和不可重複讀本質上是同一類並行問題,通常將它看成不可重複讀的特例。當兩個或多個事務查詢相同的記錄,然後各自基於查詢的結果更新記錄時會造成第二類丟失更新問題。每個事務不知道其它事務的存在,最後一個事務對記錄所做的更改將覆蓋其它事務之前對該記錄所做的更改。
發生問題的程式碼:
@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資料庫,設定事務隔離級別為可重複讀無法避免發生「第二類丟失更新」問題。從這個案例中也得到一個教訓就是,規範標準和所選的產品(元件)實際實現情況,兩者需要同時考慮,對於邊緣性或存在爭議的規範內容要儘可能避免直接使用,最好通過其他機制來保證。
以下整理了針對該問題的常見解決方案並按解決思路進行了分類。
將事務隔離級別改為序列,能解決但並行效能低,還可能導致大量超時和鎖競爭。
調整SQL語句,將更新賦值邏輯改為「c=c+x」形式,其中c為要更新的欄位,x為增量值。這種方式能確保累加值不會被覆蓋。但這種方式需要額外編寫特殊的SQL,而且嚴格意義上講,存在業務邏輯洩露到持久層的不規範問題。
方法執行時增加分散式鎖,來控制同一賬戶同一時刻只有一個執行緒可對其進行操作。效果等同將事務級別改為序列,也是排隊執行,並行效能也低,只是鎖機制不是由資料庫實現了而已。
分散式鎖的實現方式有多種,比如該專案中有封裝好的基於Redis的分散式鎖,其註解的使用方式如下:
@CbbSingle(key = "QF:finishDeductTransaction:customerCode", value = {"#{customerCode}"}) public void finishDeductTransaction(String customerCode, String entityCode, String currency, ABTransaction abTransaction) {
通過SQL語句啟用資料庫排他鎖,例如:select * from table where name=’xxx’ for update。通過此sql查詢到的資料就會被資料庫上排他鎖,因此其他事務就無法對該資料及進行修改了。
該方法比上面兩種在並行效能方面會好一些,但仍然可能存在鎖等待和超時情況發生。
樂觀鎖的思路是假設並行衝突發生概率較低,開啟事務時先不加鎖,在更新資料時通過版本比對以及判斷影響行數來判斷是否更新成功。
其中,版本概念,可以是要更新記錄的版本號,或者更新時間等。也可以用舊值條件或校驗和等方式;
該方法並行效能最好,但一旦發生並行衝突會導致方法執行失敗,此時就需要搭配額外的重試或自旋邏輯來閉環。
思路如下:
//先查詢出來要更新的資料 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("更新時資料版本號已發生改變。原因:發生並行事務"); } }