記一次併發和事務探索過程

一、前情提要

1、事務的相關概念和集成過程就不在這裏重複,可看本人另一篇https://blog.csdn.net/qq_20475615/article/details/93713519

2、這次主要是探索併發中數據的問題,場景是電商系統下單減庫存,mysql,暫沒涉及分佈式和集羣

3、所有測試我們先預設原商品庫存爲100,且我們通過用戶不同來指定休眠更好的看效果,admin爲休眠的用戶它下單1個商品,另一個請求下單3個商品

二、實踐過程

  • 髒讀,代碼如下
@Transactional(rollbackFor = Exception.class,isolation = Isolation.READ_UNCOMMITTED)
@Override
public boolean checkAndLockStock(Map<String,Integer> goodsMap, String orderId) {
	if(null == goodsMap){
		throw BusinessException.of(BusinessMessageEnum.E_ERROR);
	}
	List<AddMallStockLogDTO> logs = Lists.newArrayList();
	for(Map.Entry<String,Integer> entry:goodsMap.entrySet()){
		MallGoods goods = this.getById(entry.getKey());
		if(ObjectUtils.isEmpty(entry.getValue()) || ObjectUtils.isEmpty(goods.getStock()) || goods.getStock() < entry.getValue()){
			throw new BusinessException(BusinessMessageEnum.E_AMOUNT_NOT_ENOUGH.getMsg());
		}
		boolean result = this.baseMapper.updateGoods(goods.getId(),entry.getValue());
		if("admin".equals(securityUtil.getCurrUser().getUsername())){
			try {
				Thread.sleep(10000);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
//                throw BusinessException.of(BusinessMessageEnum.E_ERROR);
		}
		if(!result){
			throw BusinessException.of(BusinessMessageEnum.E_ERROR);
		}
	}
	return true;
}

首先是admin請求先發,然後更新後不提交事務,進入休眠,接着另一個用戶請求過來發現讀到的數據已經是admin沒提交的事務的數據,如果在休眠後讓admin那個線程回滾,髒讀就出現了

把隔離級別改一下就可以了

@Transactional(rollbackFor = Exception.class,isolation = Isolation.READ_COMMITTED)

 

  • 丟失更新:有一類和二類,一類是事務回滾把別人更新的也回滾了,二類是把人家改的覆蓋了

隔離級別爲 READ_COMMITTED 髒讀沒了,但是在admin請求過來後更新了數據但不提交事務,另一個請求過來一陣操作並更新了數據提交事務,然後admin休眠到了才提交事務,結果另一個請求東西沒了,本來結果應該是去掉 3+1,爲96,但數據庫結果是99,也就是前面先提交事務的減3不見了

改級別

@Transactional(rollbackFor = Exception.class,isolation = Isolation.REPEATABLE_READ)
  1. 事務A先查詢後更新,不提交事務,此時有寫鎖,事務B來查詢後再更新,阻塞,等A先提交事務之後B才能更新
  2. 事務A先查詢後休眠,此時不會鎖,事務B來查詢後再更新,提交事務,A更新再提交事務

REPEATABLE_READ 是在寫的時候給該條數據加鎖,前面更新的事務沒提交後面的事務是提交不了因爲數據被鎖住了。 這個級別保證的就是修改數據的提交順序

實際上比如減少庫存在這個REPEATABLE_READ 隔離級別下還出現數據錯亂是因爲在業務先查詢之後計算,再保存計算的值有問題,它保證的是你寫完的數據不丟失,而不是保證你讀完到寫完,比如我一開始用的

Integer before = goods.getStock();
Integer after = before - entry.getValue();
goods.setStock(after);
boolean result = this.updateById(goods);

而更新的順序其實是沒問題的,假如自己寫sql用 update t_mall_goods set stock = stock - #{value} where id = #{id} ,保證順序性之後拿最新的值而不是預先計算好,則沒問題。

那假如我們沒有這個順序性呢,就導致A更新後還沒提交,B更新後沒有限制直接提交,那A剛纔update拿的即使是最新值也沒用,那個最新值在B更新之前的,結果就是B的更新沒有了

  • SERIALIZABLE

上面說到 REPEATABLE_READ不保證讀完到寫完,SERIALIZABLE 便可以,最嚴格的老師,只要它讀過,別人就只能讀不能寫,就相當於我看你一眼你就是我老婆,剩下的別人只能看不能動了,得等到我放你自由

 

  • 死鎖

用 REPEATABLE_READ 解決,是加寫鎖,這在商品裏會帶來的問題,下訂單整個事務可能涉及多個商品,而如果兩個請求各鎖住一個商品,各自等對方的事務提交,就死鎖了。

@Transactional(rollbackFor = Exception.class,isolation = Isolation.REPEATABLE_READ)
@Override
public boolean checkAndLockStock() {
	Map<String,Integer> goodsMap = Maps.newLinkedHashMap();
	if("admin".equals(securityUtil.getCurrUser().getUsername())){
		goodsMap.put("194528707925250048",1);
		goodsMap.put("152869740279238656",1);
	}
	if("general".equals(securityUtil.getCurrUser().getUsername())){
		goodsMap.put("152869740279238656",2);
		goodsMap.put("194528707925250048",2);
	}
	for(Map.Entry<String,Integer> entry:goodsMap.entrySet()){
		MallGoods goods = this.getById(entry.getKey());
		if(ObjectUtils.isEmpty(entry.getValue()) || ObjectUtils.isEmpty(goods.getStock()) || goods.getStock() < entry.getValue()){
			throw new BusinessException(BusinessMessageEnum.E_AMOUNT_NOT_ENOUGH.getMsg());
		}
		boolean result = this.baseMapper.updateGoods(goods.getId(),entry.getValue());
		try {
			Thread.sleep(10000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		if(!result){
			throw BusinessException.of(BusinessMessageEnum.E_ERROR);
		}
	}
	return true;
}

上面故意讓兩個訂單下兩個相同商品,但是相互執行順序調換,結果

實際上這種問題之所以出現因爲我是單個商品更新的,如果商品是批量更新,儘管底層還是一條條執行,但這個時間差很小,沒有在業務層一條條更新這麼大的時間差,就基本不會有這種在業務層出現死鎖的問題。

其實我這裏是爲了看效果用了LinkedHashMap按照我們存入的順序,實際上用HashMap,存儲不按我們的順序再hash之後,存儲重疊的商品讀取出來前後順序都是一致的。但如果用的其它結構比如直接商品實體的list之類的,就需要注意這個死鎖問題。

三、解決

如果希望自己讀完到寫完的過程中在業務層進行計算又能最後數據保持一致,可以使用下面的幾種方式

1、代碼鎖:這種就是簡單粗暴,代碼上加 synchronized 鎖

  • 如果鎖整個方法就可能處理併發性能很差,相當於隊列,一個個請求處理;
  • 那優化一下只鎖住比如操作數據庫那塊代碼,稍微好了些,但是這種在不同商品減庫存的時候也因爲鎖而阻塞的,其實這完全沒必要,不同商品減庫存互不干擾所以不會產生庫存問題;
  • 再優化一下,我們對於每個商品加鎖,這樣不同商品減庫存就不會因爲競爭鎖而不能操作,只有涉及同個商品的纔會競爭;

2、悲觀鎖:這種就是用前先鎖住,其他人等着

  • 共享鎖/讀鎖/S鎖:大家都只能讀,可加S,但不能加X,select stock from t_mall_goods where id='' lock in share mode,(這個不能解決上面的問題,這裏只是拓展
  • 排它鎖/寫鎖/X鎖:不管三七二十一,for update 先鎖上,別人只能看不能動,別人看也不能用S或者X來鎖,select stock from t_mall_goods where id='' for update,後面更新完了提交完事務了才能讓別人去動

3、樂觀鎖:這種就是大家都能改,最後看誰快,慢的就回滾或者再獲取新值進行操作

  • 舊值:可以利用某一列作爲參考(選擇的這個列需要注意ABA問題,也就是A查到100,B從100改爲90,C又改回100,A以爲沒變就去改動但這個100不是原來的,詳情請自查資料),比如選擇update_time,在第一次查詢獲取到的值比如設爲 before_update_time,然後在更新時把該列放到where中來判斷是不是被改過了,update t_mall_goods set stock = #{stock},update_time = now() where id = #{id} and update_time = #{before_update_time}
  • 版本:維護一個版本列,原理如同前面的,update t_mall_goods set stock = #{stock},version = version+1 where id = #{id} and version = #{version}

4、記錄鎖:這種就是類似悲觀鎖,專門用一個表來作爲鎖,所有線程去創建一個記錄,創建成功的也就是拿到鎖,當然這個需要考慮拿鎖的線程如果崩了沒有及時刪除記錄導致其它線程都等待到超時了,所以要加個定時任務去處理這個異常記錄。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章