高併發下丟失更新的解決方案
作者:謝益培
1 背景
關鍵詞:併發、丟失更新
預收款賬戶表上有個累計抵扣金額的欄位,該欄位的含義是統計商家預收款賬戶上累計用於抵扣結算成功的金額數。更新時機是,賬單結算完成時,更新累計抵扣金額=累計抵扣金額 + 賬單金額。
2 問題及現象
發現當賬單結算完成時,偶爾會發生累計抵扣金額欄位值更新不準確的現象。
比如,某商家賬戶上累計抵扣金額原本為 0 元,當發生兩筆分別為 10 和 8 的賬單結算完成後,理論上累計抵扣金額應該變為 18 元,但實際為 10 元。也就是說,第二次更新把前一次更新內容給覆蓋掉了。
3 問題分析
該問題為典型的第二類丟失更新問題。
3.1 概念解釋
事務在併發情況下,常見如下問題:
- 髒讀:一個事務讀取了已被另個一個事務修改但尚未提交的資料。當一個事務正在訪問資料,並且對資料進行了修改,而這種修改還沒有提交到資料庫中;這時另外一個事務也訪問這個資料,然後使用了這個未提交的資料。
- 不可重複讀:在一個事務內,多次讀同一資料,讀到的結果不同。第一個事務還沒有結束時,另外一個事務也訪問該同一資料。那麼,在第一個事務中的兩次讀資料之間,由於第二個事務的修改,那麼第一個事務兩次讀到的資料可能是不一樣的。這樣就發生了在一個事務內兩次讀到的資料是不一樣的,因此稱為是不可重複讀。
- 幻讀:同一事務中,當同一個查詢執行多次的時候,由於其他事務進行了插入操作並提交事務,導致每次返回不同的結果集。幻讀是事務非獨立執行時發生的一種現象。例如第一個事務對一個表中的資料進行了修改,這種修改涉及到表的全部資料行。同時,第二個事務也修改了這個表中的資料,這種修改是向表中插入了一行新資料。那麼,就會發生操作第一個事務的使用者發現表中還有沒有修改的資料行,就好像發生了幻覺一樣。
- 更新丟失:兩個事務同時更新一行資料,一個事務對資料的更新把另一個事務對資料的更新覆蓋了。這是因為系統沒有執行任何的鎖操作,因此併發事務並沒有被隔離開來。
圖 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 總結
- 對於常見併發事務問題,需要將事務隔離級別和鎖機制結合起來一起使用;
- 需要對併發問題從業務場景上進行分析和識別,對於併發衝突少的場景,首選樂觀鎖思路;對於併發衝突高的場景採用悲觀鎖思路;
相關文章
- mysql 高併發 select update 併發更新問題解決方案MySql
- 高併發下的介面冪等性解決方案!
- OpenSIPS 2.4.2 高併發下,日誌丟失怎麼辦
- Feign 呼叫丟失Header的解決方案Header
- 高併發解決方案詳解(9大常見解決方案)
- 高併發和大流量解決方案
- 高併發解決方案orleans實踐
- 高併發業務場景下的秒殺解決方案 (初探)
- JavaScript精度丟失原因以及解決方案JavaScript
- PHP高併發和大流量的解決方案PHP
- 海量資料和高併發的解決方案
- 高併發下資料冪等問題的9種解決方案
- C++高併發場景下讀多寫少的解決方案C++
- 高併發大容量NoSQL解決方案探索SQL
- 架構與思維:高併發下冪等性解決方案架構
- PHP高併發商品秒殺問題的解決方案PHP
- RocketMQ訊息丟失解決方案:事務訊息MQ
- 談談高併發系統的一些解決方案
- 什麼是高併發,怎麼解決高併發
- 雪花演算法ID在前端丟失精度解決方案演算法前端
- 資料庫併發寫入問題-丟失更新與寫入偏差資料庫
- Win10系統下所有字型丟失的解決方法Win10
- go get下載包失敗的解決方案Go
- RocketMQ訊息丟失解決方案:同步刷盤+手動提交MQ
- vcruntime140.dll丟失的解決方法
- 資料庫高可靠,輕鬆解決事務丟失問題資料庫
- Vuex資料頁面重新整理丟失問題解決方案Vue
- 09.redis 哨兵主備切換時資料丟失的解決方案Redis
- Feign客戶端呼叫服務時丟失Header引數的解決方案客戶端Header
- PHP+Redis解決高併發下商品超賣問題PHPRedis
- PHP利用Mysql鎖解決高併發PHPMySql
- Elasticsearch——併發衝突以及解決方案Elasticsearch
- 大佬你是怎麼解決高併發的
- PHP 併發場景的幾種解決方案PHP
- RocketMq訊息丟失問題解決MQ
- PHP高併發 商品秒殺 問題的 2大種(MySQL or Redis) 解決方案PHPMySqlRedis
- 記錄--前端金額運算精度丟失問題及解決方案前端
- Golang浮點數精度丟失問題擴充套件包解決方案Golang套件