事務傳播行爲引發的問題
有了前文的基礎篇對事物傳播行爲和隔離級別有了清楚的認識,我們在看下實戰中會遇到哪些問題。
業務背景:
一個商品可以領取券碼,功能是這樣的,一個商品預先設置庫存總量,並且導入庫存券碼。商品維度的信息和數量是緩存到redis中的,用戶領取的時候將redis中的商品總數減去1,然後進入到詳情頁這個時候有個分配庫存的動作,將庫存改爲狀態已用,領取流水改爲已分配,填充券碼,就如下圖所示:
看起來非常簡單,rest請求爲product/detail/{productId}
service邏輯:
@Transaction(propagation = Propagation.REQUIRED)
public Object assignStoreIfNecessary(String uid, Long productId){
Product product = productService.getProductById(productId);
....
ProductFlow productFlow = flowService.getFlow(uid,productId);
if(productFlow.getStatus != 0){
....
return result;
}
//加上分佈式鎖分配庫存
lockTemplate.doBiz(new LockedCallback<Store>() {
@Override
public Store callback() {
Store unusedStore = storeService.findUnusedStore(productId);
if(unusedStore == null){
throw new BusinessException("3","未能獲取到一個庫存 uid="+uid+",productId="+productId);
}
flowService.update2Used(unusedStore.getId());
log.info("uid={},assign storeId={},productId={}",uid,unusedStore.getId(),productId);
return unusedStore;
}
},"assign-store","assign-store"+rightId, "assignstoreKey",4);
}
flow.setStoreId(unusedStore.getId());
flow.setStoreTicketCode(unusedStore.getTicketCode());
flow.setStatus(1);
flowService.update(flow);
獲取商品的券碼SQL執行如下:
SELECT * FROM `store` WHERE `productId` = #{productId} and `status` =0 ORDER BY `id` LIMIT 1
爲了防止多個線程併發導致,同一個商品不同用戶獲取到相同的券碼我們加入了分佈式鎖,關於分佈式鎖可以參考我之前的文章一行代碼分佈式鎖
在看下更新操作:
@Transactional(propagation = Propagation.REQUIRES)
@Override
public int update2Used(Long storeId) {
Assert.notNull(storeId,"update2Used storeId="+storeId);
return storeMapper.updateStatusById(storeId, 1);
}
整個的流程就是根據productId和uid獲取一個庫存券碼(這一步是有分佈式鎖),將這個券碼置爲已使用狀態,領取流水的填充庫存和修改狀態,有什麼問題???????
然而線上還真就出現了同一個商品多個用戶分配到了同一個券碼!!!!!
當時懷疑的幾個點:
1)分佈式鎖沒有起到作用,多個線程同時執行一條SQL獲取同一行庫存(同一個券碼)
2)如果鎖沒問題,那麼線程肯定是讀取未提交的數據,查看隔離級別
3)事務傳播(組內一個同事提出的)
拿到分配重複的券碼,去流水錶查看確實有兩個用戶分配了同一個券碼
看了先線上日誌兩個線程獲取鎖的是串行的 第一個釋放鎖,晚於第二個獲取鎖的時間,中間相差幾毫秒,所以分佈式鎖沒有問題1)排除掉了。
查看了線上Mysql隔離級別爲readCommit,所以2)排除了。
只剩下事務傳播了,我們將上面的代碼簡化下:
@Transaction(propagation = Propagation.REQUIRED)
public Object assignStoreIfNecessary(String uid, Long productId){
before();// (A)
//lock start
@Transaction(propagation = Propagation.REQUIRED)
flowService.update2Used(unusedStore.getId());
//lock end
after(); //(B)
}
現如今明顯的問題是,A線程讀取到B線程沒有提交的數據,但是B執行獲取一個商品的券碼並將其設置爲已使用了(lockStart和lockEnd部分的代碼),但是A沒有感知到。所以處理方式就是讓B執行的(lockStart和lockEnd部分的代碼)能讓A感知到。
再回到Propagation.REQUIRED 和Propagation.REQUIRES_NEW的,不熟悉的基礎篇看下,上文的代碼中外層Service使用的Propagation.REQUIRED內層的Service也是Propagation.REQUIRED,沒有開啓使用則創建事務,所以內層事務用的是外層的事務,真個方法全部完成後纔是事務才提交,其他線程才能感知到。
有一種方式讓這個方法加上鎖,真個事務提交,其他線程也就能感知到了,但是這種方式不可取,影響性能。
再有如果能有一種方式讓方法內部B執行的(lockStart和lockEnd)最爲一個獨立的事務,不受外層影響,在配合上分佈式鎖,就可以了。所以只需要做一點改動:
@Transaction(propagation = Propagation.REQUIRED)
flowService.update2Used(unusedStore.getId());
改成
@Transaction(propagation = Propagation.REQUIRES_NEW)
flowService.update2Used(unusedStore.getId());
就可以了,改完線上確實好了。
那麼以前爲什麼沒有碰到類似的事情?service調用service很常見啊,以前也是這樣寫的。
因爲以前寫的都是跟個人有關(每個人都會處理不同的行,併發操作的同一張表的不同行,而且事務是個人的事務,不會有併發–當然多個pc同一個用戶一起訪問除外–不法分子),不像這類多個線程共享的庫存(可同時查詢和修改,非個人級的),對於共享的我們通常是lockStart查詢後修改lockEnd,但是方法如果被外部調用就要注意這個部分要作爲獨立的事務,否則還會出現一行代碼導致看似是讀未提交,實質是因爲查詢修改是所有用戶都能直接訪問共享行)要做獨立的事務的問題。