淺談庫存扣減相關問題
問題場景
物品W現在庫存剩餘1個, 使用者P1,P2同時購買.則只有1人能購買成功.(前提是不允許超賣)
秒殺也是類似的情況, 只有1件商品,N個使用者同時搶購,只有1人能搶到..
這裡不談秒殺設計,不談使用佇列等使請求序列化,就談下怎麼用鎖來保證資料正確.
常見的實現方案有以下幾種:
1.程式碼同步, 例如使用 synchronized ,lock 等同步方法
2.不查詢,直接更新 update table set surplus = (surplus - buyQuantity) where id = xx and (surplus - buyQuantity) > 0
3.使用CAS, update table set surplus = aa where id = xx and version = y
4.使用資料庫鎖, select xx for update
5.使用分散式鎖(zookeeper,redis等)
下面就針對這幾種方案來分析下;
1.程式碼同步, 例如使用 synchronized ,lock 等同步方法
面試的時候,我經常會問這個問題,很大一部分人都會回答用這個方案來實現.
虛擬碼如下:
public synchronized void buy(String productName, Integer buyQuantity) {
// 其他校驗...
// 校驗剩餘數量
Product product = 從資料庫查詢出記錄;
if (product.getSurplus < buyQuantity) {
return "庫存不足";
}
// set新的剩餘數量
product.setSurplus(product.getSurplus() - quantity);
// 更新資料庫
update(product);
// 記錄日誌...
// 其他業務...
}
在方法宣告加上synchronized關鍵字,實現同步,這樣2個使用者同時購買,到buy方法時候同步執行,第2個使用者執行的時候,會庫存不足.
嗯.. 看著挺合理的,以前我也是這麼幹的偷笑. 所以現在碰到別人這樣回答,我就會在心裡默默的想.小夥子你是沒踩過這坑啊.
先說下這個方案的前提配置:
1).使用spring 宣告式事務管理
2).事務傳播機制使用預設的(PROPAGATION_REQUIRED)
3).專案分層為controller-service-dao 3層, 事務管理在service層
這個方案不可行,主要是因為以下幾點:
1).synchronized 作用範圍是單個jvm例項, 如果做了叢集,分散式等,就沒用了
2).synchronized是作用在物件例項上的,如果不是單例,則多個例項間不會同步(這個一般用spring管理bean,預設就是單例)
3).單個jvm時,synchronized也不能保證多個數據庫事務的隔離性. 這與程式碼中的事務傳播級別,資料庫的事務隔離級別,加鎖時機等相關.
3-1).先說隔離級別,常用的是 Read Committed 和 Repeatable Read ,另外2種不常用就不說了
3-1-1)RR(Repeatable Read)級別.mysql預設的是RR,事務開啟後,不會讀取到其他事務提交的資料
根據前面的前提,我們知道在buy方法時會開啟事務.
假設現在有執行緒T1,T2同時執行buy方法.假設T1先執行,T2等待.
spring的事務開啟和提交等是通過aop(代理)實現的,所以執行buy方法前,就會開啟事務.
這時候T1,T2是兩個事務,當T1執行完後,T2執行,讀取不到T1提交的資料,所以會出問題.
3-1-2).RC(Read Committed)級別.事務開啟後,可以讀取到其他事務提交的資料
看起來這個級別可以解決上面的問題.T2執行時,可以讀取到T1提交的結果.
但是問題是,T2執行的時候, T1的事務提交了嗎?
事務和鎖的流程如下
1.開啟事務(aop)
2.加鎖(進入synchronized方法)
3.釋放鎖(退出synchronized方法)
4.提交事務(aop)
可以看出是先釋放鎖,再提交事務.所以T2執行查詢,可能還是未讀到T1提交的資料,還會出問題
3-2).根據3-1中的問題,發現主要矛盾是事務開啟和提交的時機與加鎖解鎖時機不一致.有小夥伴們可能就想到了解決方案.
3-2-1).在事務開啟前加鎖,事務提交後解鎖.
確實是可以,這相當於事務序列化.拋開效能不談,來談談怎麼實現.
如果使用預設的事務傳播機制,那麼要保證事務開啟前加鎖,事務提交後解鎖,就需要把加鎖,解鎖放在controller層.
這樣就有個潛在問題,所有操作庫存的方法,都要加鎖,而且要是同一把鎖,寫起來挺累的.
而且這樣還是不能跨jvm.
3-2-2).將查詢庫存,扣減庫存這2步操作,單獨提取個方法,單獨使用事務,並且事務隔離級別設定為RC.
這個其實和上面的3-2-1異曲同工,最終都是講加解鎖放在了事務開啟提交外層.
比較而言優點是入口少了. controller不用處理.
缺點除了上面的不能跨jvm,還有就是 單獨的這個方法,需要放到另外的service類中.
因為使用spring,同一個bean的內部方法呼叫,是不會被再次代理的,所以配置的單獨事務等需要放到另外的service bean 中
2.不查詢,直接更新
看完第一種方案,有小夥伴就說了. 你說的那麼複雜,那麼多問題,不就是因為查詢的資料不是最新的嗎?
我們不查詢,直接更新不就行啦.
虛擬碼如下:
public synchronized void buy(String productName, Integer buyQuantity) {
// 其他校驗...
int 影響行數 = update table set surplus = (surplus - buyQuantity) where id = 1 ;
if (result < 0) {
return "庫存不足";
}
// 記錄日誌...
// 其他業務...
}
測試後發現庫存變成-1了, 繼續完善下
public synchronized void buy(String productName, Integer buyQuantity) {
// 其他校驗...
int 影響行數 = update table set surplus = (surplus - buyQuantity) where id = 1 and (surplus - buyQuantity) > 0 ;
if (result < 0) {
return "庫存不足";
}
// 記錄日誌...
// 其他業務...
}
測試後,功能OK;
這樣確實可以實現,不過有一些其他問題:
1). 不具備通用性,例如add操作
2). 庫存操作一般要記錄操作前後的數量等,這樣沒法記錄
3). 其他…
但是根據這個方案,可以引出方案3.
3.使用CAS, update table set surplus = aa where id = xx and yy = y
CAS是指compare/check and swap/set 意思都差不多,不必太糾結是哪個單詞
我們將上面的sql修改一下:
int 影響行數 = update table set surplus = newQuantity where id = 1 and surplus = oldQuantity ;
這樣,執行緒T1執行完後,執行緒T2去更新,影響行數=0,則說明資料被更新, 重新查詢判斷執行.虛擬碼如下:
public void buy(String productName, Integer buyQuantity) {
// 其他校驗...
Product product = getByDB(productName);
int 影響行數 = update table set surplus = (surplus - buyQuantity) where id = 1 and surplus = 查詢的剩餘數量 ;
while (result == 0) {
product = getByDB(productName);
if (查詢的剩餘數量 > buyQuantity) {
影響行數 = update table set surplus = (surplus - buyQuantity) where id = 1 and surplus = 查詢的剩餘數量 ;
} else {
return "庫存不足";
}
}
// 記錄日誌...
// 其他業務...
}
看到重新查詢幾個字,小夥伴們應該就又想到事務隔離級別問題了.
沒錯,所以上面程式碼中的getByDB方法,必須單獨事務(注意同一個bean內單獨事務不生效哦),而且資料庫的事務隔離級別必須是RC,
否則上面的程式碼就會是死迴圈了.
上面的方案,可能會出現一個CAS中經典問題. ABA的問題.
ABA是指:
執行緒T1 查詢,庫存剩餘 100
執行緒T2 查詢,庫存剩餘 100
執行緒T1 執行subupdate t set surplus = 90 where
id = x and surplus = 100;
執行緒T3 查詢, 庫存剩餘 90
執行緒T3 執行add update t set surplus = 100 where id = x and surplus = 90;
執行緒T2 執行subupdate t set surplus = 90 where
id = x and surplus = 100;
這裡執行緒T2執行的時候,庫存的100已經不是查詢到的100了,但是對於這個業務是不影響的.
一般的設計中CAS會使用version來控制.
update t set surplus = 90 ,version = version+1 where id = x and version = oldVersion ;
這樣,每次更新version在原基礎上+1,就可以了.
使用CAS要注意幾點,
1)失敗重試次數,是否需要限制
2)失敗重試對使用者是透明的
4.使用資料庫鎖, select xx for update
方案3種的cas,是樂觀鎖的實現, 而select for udpate 則是悲觀鎖. 在查詢資料的時候,就將資料鎖住.
虛擬碼如下:
public void buy(String productName, Integer buyQuantity) {
// 其他校驗...
String productName = select * from table where name = productName for update;
if (查詢的剩餘數量 > buyQuantity) {
影響行數 = update table set surplus = (surplus - buyQuantity) where name = productName ;
} else {
return "庫存不足";
}
// 記錄日誌...
// 其他業務...
}
執行緒T1 進行sub , 查詢庫存剩餘 100
執行緒T2 進行sub , 這時候,執行緒T1事務還未提交,執行緒T2阻塞,直到執行緒T1事務提交或回滾才能查詢出結果.
所以執行緒T2查詢出的一定是最新的資料.相當於事務序列化了,就解決了資料一致性問題.
對於select for update,需要注意的有2點.
1) 統一入口:所有庫存操作都需要統一使用 select for update ,這樣才會阻塞,
如果另外一個方法還是普通的select, 是不會被阻塞的
2) 加鎖順序:如果有多個鎖,那麼加鎖順序要一致,否則會出現死鎖.
5.使用分散式鎖(zookeeper,redis等)
使用分散式鎖,原理和方案1種的synchronized是一樣的.只不過synchronized的flag只有jvm程序內可見,而分散式鎖的flag則是全域性可見.方案4種的select for update 的flag 也是全域性可見.
分散式鎖的實現方案有很多:基於redis,基於zookeeper,基於資料庫等等.前面一篇部落格寫了基於redis的簡易實現
需要注意,使用分散式鎖和synchronized鎖有同樣的問題,就是鎖和事務的順序,這個在方案1裡面已經講過.不再重複.
簡單總結:
方案1: synchronized等jvm內部鎖不適合用來保證資料庫資料一致性,不能跨jvm
方案2: 不具備通用性,不能記錄操作前後日誌
方案3: 推薦使用.但是如果資料競爭激烈,則自動重試次數會急劇上升,需要注意.
方案4: 推薦使用.最簡單的方案,但是如果事務過大,會有效能問題.操作不當,會有死鎖問題
方案5: 和方案1類似,只是能跨jvm