一、 問題

一件商品只有100個庫存,現在有1000或者更多的用戶來購買,每個用戶計劃同時購買1個到幾個不等商品。

如何保證庫存在高併發的場景下是安全的?

(1)不多發

(2)不少發

二、 下單的步驟

(1)下單

(2)下單同時預佔庫存

(3)支付

(4)支付成功真正減扣庫存

(5)取消訂單

(6)回退預佔庫存

三、 什麼時候進行預佔庫存?

(1)方案一:加入購物車的時候去預佔庫存

(2)方案二:下單的時候去預佔庫存

(3)方案三:支付的時候去預佔庫存

四、 分析

(1)方案一:加入購物車並不代表用戶一定會購買,如果這個時候開始預佔庫存,會導致想購買的無法加入購物車。而不想購買的人一直佔用庫存。顯然這種做法是不可取的。

(2)方案二:商品加入購物車後,選擇下單,這個時候去預佔庫存。用戶選擇去支付說明了,用戶購買慾望是比 方案一 要強烈的。訂單也有一個時效,例如半個小時。超過半個小時後,系統自動取消訂單,回退預佔庫存。

(3)方案三:下單成功去支付的時候去預佔庫存。只有100個用戶能支付成功,900個用戶支付失敗。用戶體驗不好,就像你走了一條光明大道,一路通暢,突然被告知此處不通行。而且支付流程也是一個比較複雜的流程,如果和減庫存放在一起,將會變的更復雜。

所以綜上所述:選擇方案二比較合理。

五、 重複下單問題

(1)用戶點擊過快,重複提交兩次

(2)網絡延時,用戶刷新或者點擊下單重複提交

(3)網絡框架重複請求,某些網絡框架,在延時比較高的情況下會自動重複請求

(4)用戶惡意行爲

六、 解決辦法

在UI攔截,點擊後按鈕置灰,不能繼續點擊,防止用戶,連續點擊造成的重複下單。

1、在下單前獲取一個下單的唯一token,下單的時候需要這個token。後臺系統校驗這個 token是否有效,才繼續進行下單操作。

/**     

* 先生成 token 保存到 Redis

* token 作爲 key , 並設置過期時間 時間長度 根據任務需求

* value 爲數字 自增判斷 是否使用過 *

* @param user

* @return

*/

public String createToken(User user) {
String key = "placeOrder:token:" + user.getId();
String token = UUID.randomUUID().toString();
//保存到Redis
redisService.set(key + token, 0, 1000L);
return token;
}

/**

* 校驗下單的token是否有效

* @param user

* @param token

* @return

*/

public Boolean checkToken(User user, String token) {
String key = "placeOrder:token:" + user.getId();
if (null != redisService.get(key + token)) {
long times = redisService.increment(key + token, 1);
if (times == 1) {
//利用increment 原子性 判斷是否 該token 是否使用
return true;
} else {
// 已經使用過了

}

//刪除
redisService.remove(key + token);
}

return false;
}

2、如何安全的減扣庫存?

同一個用戶或者多個用戶同時搶購一個商品的時候,我們如何做到併發安全減扣庫存?

(1)數據庫操作商品庫存

public interface ProductDao extends JpaRepository<Product, Integer> {

/**
* @param pid 商品ID
* @param num 購買數量
* @return
*/

@Transactional
@Modifying
@Query("update Product set availableNum = availableNum - ?2 , reserveNum = reserveNum + ?2 where id = ?1")
intreduceStock1(Integerpid, Integernum);

/**
* @param pid 商品ID
* @param num 購買數量
* @return
*/
@Transactional
@Modifying
@Query("update Product set availableNum = availableNum - ?2 , reserveNum = reserveNum + ?2 where id = ?1 and availableNum - ?2 >= 0")
intreduceStock2(Integerpid, Integernum);

}

(2)下單

 /**
* 下單操作1
*
* @param req
*/
private int place(PlaceOrderReq req) {
User user = userDao.findOne(req.getUserId());
Product product = productDao.findOne(req.getProductId());
//下單數量
Integer num = req.getNum();
//可用庫存
Integer availableNum = product.getAvailableNum();
//可用預定
if (availableNum >= num) {
//減庫存
int count = productDao.reduceStock1(product.getId(), num);
if (count == 1) {
//生成訂單
createOrders(user, product, num);
} else {
logger.info("庫存不足 3");
}
return 1;

} else {
logger.info("庫存不足 4");
return -1;
}

}

/**
* 下單操作2
*
* @param req
*/
private int place2(PlaceOrderReq req) {
User user = userDao.findOne(req.getUserId());
Product product = productDao.findOne(req.getProductId());
//下單數量
Integer num = req.getNum();
//可用庫存
Integer availableNum = product.getAvailableNum();
//可用預定
if (availableNum >= num) {
//減庫存
int count = productDao.reduceStock2(product.getId(), num);
if (count == 1) {
//生成訂單
createOrders(user, product, num);
} else {
logger.info("庫存不足 3");
}
return 1;

} else {
logger.info("庫存不足 4");
return -1;
}
}

方法1:不考慮庫存安全的寫法

/**
* 方法 1
* 減可用
* 加預佔
* 庫存數據不安全
* @param req
*/
@Override
@Transactional
public void placeOrder(PlaceOrderReq req) {
place1(req);
}

分析:在高並的場景下,假設庫存只有 2 件 ,兩個請求同時進來,搶購改商品,購買數量都是 2. A請求 此時去獲取庫存,發現庫存剛好足夠,執行扣庫存下單操作。在 A 請求爲完成的時候(事務未提交),B請求 此時也去獲取庫存,發現庫存還有2. 此時也去執行扣庫存,下單操作。庫存剩 2 ,但是賣出了 4 。最終數據庫庫存數量將變爲 -2 ,所以庫存是不安全的。

方法2:這個操作可以保證庫存數據是安全的

/**
* 方法 2
* 減可用
* 加預佔
* 庫存數據不安全
* @param req
*/
@Override
@Transactional
public void placeOrder(PlaceOrderReq req) {
place2(req);
}

分析:在方法1 的基礎上 ,更新庫存的語句,增加了可用庫存數量 大於 0, availableNum - num >= 0 ;實質是使用了數據庫的樂觀鎖來控制庫存安全,在併發量不是很大的情況下可以這麼做。但是如果是秒殺,搶購,瞬時流量很高的話,壓力會都到數據庫,可能拖垮數據庫。

方法3:該方法也可以保證庫存數量安全

/**   

* 方法 3

* 採用 Redis 鎖 通一個時間 只能一個 請求修改 同一個商品的數量

* <p>

* 缺點併發不高,同時只能一個用戶搶佔操作,用戶體驗不好! *

* @param req

*/

@Override
public void placeOrder2(PlaceOrderReq req) {
String lockKey = "placeOrder:" + req.getProductId();
Boolean isLock = redisService.lock(lockKey);
if (!isLock) {
logger.info("系統繁忙稍後再試!");
return 2;
}

//place2(req); place1(req);

//這兩個方法都可以
redisService.unLock(lockKey);
}

分析:利用Redis 分佈式鎖, 強制控制 同一個商品,同時只能一個請求處理下單。其他請求返回 ‘系統繁忙稍後再試!’;強制把處理請求串行化,缺點併發不高 ,處理比較慢,不適合搶購等方案 。用戶體驗也不好,明明看到庫存是充足的,就是強不到。相比方案2減輕了數據庫的壓力。

方法4 :可以保證庫存安全,滿足高併發處理,但是相對複雜一點

/**     

* 方法 4

* 商品的數量 等其他信息 先保存 到 Redis

* 檢查庫存 與 減少庫存 不是原子性, 以 increment > 0 爲準 *

* @param req

*/

@Override
public void placeOrder3(PlaceOrderReq req) {
String key = "product:" + req.getProductId();
// 先檢查 庫存是否充足
Integer num = (Integer) redisService.get(key);
if (num < req.getNum()) {
logger.info("庫存不足 1");
} else{
//不可在這裏下單減庫存,否則導致數據不安全, 情況類似 方法1;
}

//減少庫存
long value = redisService.increment(key, -req.getNum().longValue());

//庫存充足
if (value >= 0) {
logger.info("成功搶購 ! ");
//TODO 真正減 扣 庫存 等操作 下單等操作 ,這些操作可用通過 MQ 或 其他方式
place2(req);
} else {
//庫存不足,需要增加剛剛減去的庫存
redisService.increment(key, req.getNum().longValue());
logger.info("庫存不足 2 ");
}
}

分析:利用Redis increment 的原子操作,保證庫存安全。事先需要把庫存的數量等其他信息保存到Redis,並保證更新庫存的時候,更新Redis。

進來的時候 先 get 庫存數量是否充足,再執行 increment。以 increment > 0 爲準。檢查庫存 與 減少庫存 不是原子性的。檢查庫存的時候技術庫存充足也不可下單;否則造成庫存不安全,原來類似 方法1. increment 是個原子操作,已這個爲準。

redisService.increment(key, -req.getNum().longValue()) >= 0 說明庫存充足,可以下單。

redisService.increment(key, -req.getNum().longValue()) < 0 的時候 不能下單,次數庫存不足。並且需要 回加剛剛減去的庫存數量,否則會導致剛纔減扣的數量 一直買不出去。數據庫與緩存的庫存不一致。

次方法可以滿足 高並搶購等一些方案,真正減扣庫存和下單可以異步執行。

訂單時效問題,訂單取消等 爲保證商家利益,同時把商品賣給有需要的人,訂單下單成功後,往往會有個有效時間。超過這個時間,訂單取消,庫存回滾。

訂單取消後,可利用MQ 回退庫存等。

相關推薦

後臺回覆 學習資料   領取學習視頻

如有收穫,點個在看,誠摯感謝

相關文章