並發下加鎖不當,踩坑了!

本來是不打算寫這個文章但是在一個群裡面發現又有群友遇到和我一樣的問題不知道咋辦

知識點

1、並發(勉強)
2、mysql MVCC原理
3、spring 事務機制

起因

這個話題是由最近一次對接第三方商城發現的,該商城執行流程很奇特,流程如下:

1、用戶購買,三方平台調用本系統積分扣除介面,返回結果給三方。
2、三方回調本系統商品兌換介面,是否兌換成功,否單獨調用三方失敗處理介面(有步驟3回調),並返回現有介面結果給三方(有步驟3回調)。
3、三方回調用本系統商品兌換成功/失敗介面(確認三方已經收到消息並處理)

ps:步驟2兌換流程 加鎖——>查詢訂單是否存在——>扣積分——>插入訂單——>減庫存——>贈送金幣——>釋放鎖(由於流程現在無論是否兌換成功都必須保存訂單,所以不能在步驟2方法使用事務回滾)

這個流程總體看起來很怪,我也是第一次遇到這樣的,不過即使覺得不合理也得按照人家的來。

問題

如果仔細看看上面執行流程就會發現步驟2會帶來兩次連續的回調,這個連續回調也引發了本文的問題。
在測試兌換失敗場景時我這邊要把扣的積分返還給用戶,操作偽程式碼如下:

ServiceImpl:
@Transactional
public void dealOrderExchangeNotice(....){
		RedisLock lock = null;
		try{
			lock=new RedisLock(bizId);
			if (lock.lock()) {
				 //查詢訂單
				 IntegralShoppingOrder shoppingOrder = selectOne(bizId);
				   //shoppingOrder.getStatus()==1 代表訂單扣積分成功 可以返還積分
					if (shoppingOrder != null && shoppingOrder.getStatus() == 1) {
						//返還積分 
						//更新訂單狀態為 4(訂單失敗)
					}
		}catch (Exception e) {
		
		}finally {
			if (lock != null) {
				lock.unlock();
			}
		}	
}

如果沒有出現問題看著上面的程式碼感覺沒有啥問題的…..

測試時發現每次都是給用戶返還了兩次積分(相當於花100送200了,這哪了得..),剛開始看上面的程式碼看了好久沒有發現問題,加上log後查詢伺服器日誌發現失敗訂單幾乎在同一時間會收到兩條回調資訊,
(勉強算作一個高並發吧),兩個請求都拿到了鎖且shoppingOrder的getStatus()都是一樣的,感覺到問題了出現重複讀了………

解決過程

兩個請求都拿到了鎖證明第一個回調請求已經執行完畢了,按道理應該將訂單狀態更新成4了第二個請求查詢到的也應該是4,但是還是出現同樣的值說明第二個請求查詢時第一個沒有提交事務。
這樣明確出兩個排查方向 重複讀(mysql MVCC原理)、事務提交(spring 事務機制)。

mysql MVCC原理

mysql默認事務隔離級別是 RR(Repeatable Read,可重複讀),事務A在讀到一條數據之後,此時事務B對該數據進行了修改並提交,那麼事務A再讀該數據,讀到的還是原來的內容。

MVCC的實現,是通過保存數據在某個時間點的快照來實現的。也就是說,不管需要執行多長時間,每個事務看到的數據是一致的。根據事務開始的時間不同,每個事物對同一張表,同一時刻看到的數據可能是不一樣的。
由此可以確定第二個請求執行查詢時第一個請求事務沒有提交,兩者的事務版本號是一樣的所以查詢的值是一樣的,因此問題不在資料庫了!

小知識:
第一個SELECT執行的時候,當前事務取到了系統版本號n(並不是begin的時候就生成版本號,而是執行事務內第一個語句時生成),系統版本號自增為n+1。此後,其他事務的更新操作能取到的系統版本號最小為n+1,所以當前事務再次SELECT將看不見它們的更新。

spring 事務機制

Spring 事務管理分為編程式和聲明式兩種。編程式事務指的是通過編碼方式實現事務;聲明式事務基於 AOP,將具體的邏輯與事務處理解耦。
聲明式事務管理使業務程式碼邏輯不受污染,因此實際使用中聲明式事務用的比較多。

小知識:
1、默認配置下 Spring 只會回滾運行時、未檢查異常(繼承自 RuntimeException 的異常)或者 Error。
2、@Transactional 註解只能應用到 public 方法才有效。

很明顯我這邊也是採用聲明式事務,Aop自動提交事務是在dealOrderExchangeNotice程式碼塊中的方法執行完畢後才執行事務提交工作

ps:在群裡面討論時有一個群友說事務提交是在finally執行之前,這個觀點是錯誤的

因為這個還在一個群裡面被人噴了討論的話題老舊

從上面兩個知識點結合之前看的《Mysql45講》(需要,公眾號回復『Mysql45講』),我畫了一個執行圖很清晰的說明了問題所在(不懂千萬不要空想動手畫一畫可能馬上明白了)

最後把上面的加鎖程式碼轉到controller層後重試沒有出現多返積分的問題了

Controller:
public void dealOrderExchangeNotice(....){
	RedisLock lock = null;
	try{
		lock=new RedisLock(bizId);
		if (lock.lock()) {
			S.dealOrderExchangeNotice(....);
		}finally {
				if (lock != null) {
					lock.unlock();
				}
		}	
}

ServiceImpl:
@Transactional
public void dealOrderExchangeNotice(....){
		 lock = null;
		try{
			 //查詢訂單
			IntegralShoppingOrder shoppingOrder = selectOne(bizId);
			//shoppingOrder.getStatus()==1 代表訂單扣積分成功 可以返還積分
			if (shoppingOrder != null && shoppingOrder.getStatus() == 1) {
			//返還積分 
			//更新訂單狀態為 4(訂單失敗)
					
		}catch (Exception e) {
		
		}
}

類似像這種寫法也是錯誤的

@Service
@Transactional
public class OrderServiceImpl implements OrderService {
    
    @Override
    public synchronized int update(Integer id) {
          ...
          ...
          ...
        }
}

總結

鎖不要載入事務中

由於本人文筆水平有限,文中的描述可能有些不清晰,但是通過問題的排查讓我體驗到理論結合實際程式碼的快樂,理論可能不是很高深、很難懂,但是有時木有結合實際也會出現意想不到的問題。

在這裡插入圖片描述