-
下單涉及的一些步驟
1.下單
2.下單同時預占庫存
3.支付
4.支付成功真正減扣庫存
5.取消訂單
6.回退預占庫存
-
什么時候進行預占庫存
方案一:加入購物車的時候去預占庫存。
方案二:下單的時候去預占庫存。
方案三:支付的時候去預占庫存。
分析:
方案一:加入購物車並不代表用戶一定會購買,如果這個時候開始預占庫存,會導致想購買的無法加入購物車。而不想購買的人一直占用庫存。顯然這種做法是不可取的。
方案二:商品加入購物車后,選擇下單,這個時候去預占庫存。用戶選擇去支付說明了,用戶購買欲望是比 方案一 要強烈的。訂單也有一個時效,例如半個小時。超過半個小時后,系統自動取消訂單,回退預占庫存。
方案三:下單成功去支付的時候去預占庫存。只有100個用戶能支付成功,900個用戶支付失敗。用戶體驗不好,就像你走了一條光明大道,一路通暢,突然被告知此處不通行。而且支付流程也是一個比較復雜的流程,如果和減庫存放在一起,將會變的更復雜。
所以綜上所述:
選擇方案二比較合理。
-
重復下單問題
-
用戶點擊過快,重復提交兩次。
-
網絡延時,用戶刷新或者點擊下單重復提交。
-
網絡框架重復請求,某些網絡框架,在延時比較高的情況下會自動重復請求。
-
用戶惡意行為。
解決辦法
-
在UI攔截,點擊后按鈕置灰,不能繼續點擊,防止用戶,連續點擊造成的重復下單。
-
在下單前獲取一個下單的唯一token,下單的時候需要這個token。后台系統校驗這個 token是否有效,才繼續進行下單操作。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
/** * 先生成 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 ; } |
同一個用戶或者多個用戶同時搶購一個商品的時候,我們如何做到並發安全減扣庫存?
數據庫操作商品庫存:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
/** * Created by Administrator on 2017/9/8. */ 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" ) int reduceStock1(Integer pid, Integer num); /** * @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" ) int reduceStock2(Integer pid, Integer num); } |
下單:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
/** * 下單操作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 2 3 4 5 6 7 8 9 10 11 12 13 |
/** * 方法 1 * 減可用 * 加預占 * 庫存數據不安全 * * @param req */ @Override @Transactional public void placeOrder(PlaceOrderReq req) { place1(req); } |
分析:
在高並的場景下,假設庫存只有 2 件 ,兩個請求同時進來,搶購改商品,購買數量都是 2.
A請求 此時去獲取庫存,發現庫存剛好足夠,執行扣庫存下單操作。
在 A 請求為完成的時候(事務未提交),B請求 此時也去獲取庫存,發現庫存還有2. 此時也去執行扣庫存,下單操作。
庫存剩 2 ,但是賣出了 4 。最終數據庫庫存數量將變為 -2 ,所以庫存是不安全的。
方法2 :
這個操作可以保證庫存數據是安全的。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
/** * 方法 2 * 減可用 * 加預占 * 庫存數據不安全 * * @param req */ @Override @Transactional public void placeOrder(PlaceOrderReq req) { place2(req); } |
分析: 在方法1 的基礎上 ,更新庫存的語句,增加了可用庫存數量 大於 0, availableNum - num >= 0 ;實質是使用了數據庫的樂觀鎖來控制庫存安全,在並發量不是很大的情況下可以這么做。但是如果是秒殺,搶購,瞬時流量很高的話,壓力會都到數據庫,可能拖垮數據庫。
方法3:
該方法也可以保證庫存數量安全。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
/** * 方法 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 :
可以保證庫存安全,滿足高並發處理,但是相對復雜一點。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
/** * 方法 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 的時候 不能下單,次數庫存不足。並且需要 回加剛剛減去的庫存數量,否則會導致剛才減扣的數量 一直買不出去。數據庫與緩存的庫存不一致。
次方法可以滿足 高並搶購等一些方案,真正減扣庫存和下單可以異步執行。