前言
之前一直有小伙伴私信我問我高並發場景下的訂單和庫存處理方案,我最近也是因為加班的原因比較忙,就一直沒來得及回復。今天好不容易閑了下來想了想不如寫篇文章把這些都列出來的,讓大家都能學習到,說一千道一萬都不如滿滿的干貨來的實在,干貨都下面了!
介紹
前提:分布式系統,高並發場景
商品A只有100庫存,現在有1000或者更多的用戶購買。如何保證庫存在高並發的場景下是安全的。
預期結果:1.不超賣 2.不少賣 3.下單響應快 4.用戶體驗好
下單思路:
- 下單時生成訂單,減庫存,同時記錄庫存流水,在這里需要先進行庫存操作再生成訂單數據,這樣庫存修改成功,響應超時的特殊情況也可以通過第四步定時校驗庫存流水來完成最終一致性。
- 支付成功刪除庫存流水,處理完成刪除可以讓庫存流水數據表數據量少,易於維護。
- 未支付取消訂單,還庫存+刪除庫存流水
- 定時校驗庫存流水,結合訂單狀態進行響應處理,保證最終一致性
(退單有單獨的庫存流水,申請退單插入流水,退單完成刪除流水+還庫存)
什么時候進行減庫存
- 方案一:加購時減庫存。
- 方案二:確認訂單頁減庫存。
- 方案三:提交訂單時減庫存。
- 方案四:支付時減庫存。
分析:
- 方案一:在這個時間內加入購物車並不代表用戶一定會購買,如果這個時候處理庫存,會導致想購買的用戶顯示無貨。而不想購買的人一直占着庫存。顯然這種做法是不可取的。唯品會購物車鎖庫存,但是他們是另一種做法,加入購物車后會有一定時效,超時會從購物車清除。
- 方案二:確認訂單頁用戶有購買欲望,但是此時沒有提交訂單,減庫存會增加很大的復雜性,而且確認訂單頁的功能是讓用戶確認信息,減庫存不合理,希望大家對該方案發表一下觀點,本人暫時只想到這么多。
- 方案三:提交訂單時減庫存。用戶選擇提交訂單,說明用戶有強烈的購買欲望。生成訂單會有一個支付時效,例如半個小時。超過半個小時后,系統自動取消訂單,還庫存。
- 方案四:支付時去減庫存。比如:只有100個用戶可以支付,900個用戶不能支付。用戶體驗太差,同時生成了900個無效訂單數據。
所以綜上所述:
選擇方案三比較合理。
重復下單問題
- 用戶點擊過快,重復提交。
- 網絡延時,用戶重復提交。
- 網絡延時高的情況下某些框架自動重試,導致重復請求。
- 用戶惡意行為。
解決辦法
-
前端攔截,點擊后按鈕置灰。
-
后台:
(1)redis 防重復點擊,在下單前獲取用戶token,下單的時候后台系統校驗這個 token是否有效,導致的問題是一個用戶多個設備不能同時下單。
//key , 等待獲取鎖的時間 ,鎖的時間 redis.lock("shop-oms-submit" + token, 1L, 10L);
redis的key用token + 設備編號 一個用戶多個設備可以同時下單。
//key , 等待獲取鎖的時間 ,鎖的時間 redis.lock("shop-oms-submit" + token + deviceType, 1L, 10L);
(2)防止惡意用戶,惡意攻擊 : 一分鍾調用下單超過50次 ,加入臨時黑名單 ,10分鍾后才可繼續操作,一小時允許一次跨時段弱校驗。使用reids的list結構,過期時間一小時
/** * @param token * @return true 可下單 */ public boolean judgeUserToken(String token) { //獲取用戶下單次數 1分鍾50次 String blackUser = "shop-oms-submit-black-" + token; if (redis.get(blackUser) != null) { return false; } String keyCount = "shop-oms-submit-count-" + token; Long nowSecond = LocalDateTime.now().toEpochSecond(ZoneOffset.of("+8")); //每一小時清一次key 過期時間1小時 Long count = redis.rpush(keyCount, String.valueOf(nowSecond), 60 * 60); if (count < 50) { return true; } //獲取第50次的時間 List<String> secondString = redis.lrange(keyCount, count - 50, count - 49); Long oldSecond = Long.valueOf(secondString.get(0)); //now > oldSecond + 60 用戶可下單 boolean result = nowSecond.compareTo(oldSecond + 60) > 0; if (!result) { //觸發限制,加入黑名單,過期時間10分鍾 redis.set(blackUser, String.valueOf(nowSecond), 10 * 60); } return result; }
如何安全的減庫存
多用戶搶購時,如何做到並發安全減庫存?
- 方案1: 數據庫操作商品庫存采用樂觀鎖防止超賣:
sql:update sku_stock set stock = stock - num where sku_code = '' and stock - num > 0;
分析:
高並發場景下,假設庫存只有 1件 ,兩個請求同時進來,搶購該商品.
數據庫層面會限制只有一個用戶扣庫存成功。在並發量不是很大的情況下可以這么做。但是如果是秒殺,搶購,瞬時流量很高的話,壓力會都到數據庫,可能拖垮數據庫。
- 方案2:利用Redis單線程 強制串行處理
/** * 缺點並發不高,同時只能一個用戶搶占操作,用戶體驗不好! * * @param orderSkuAo */ public boolean subtractStock(OrderSkuAo orderSkuAo) { String lockKey = "shop-product-stock-subtract" + orderSkuAo.getOrderCode(); if(redis.get(lockKey)){ return false; } try { lock.lock(lockKey, 1L, 10L); //處理邏輯 }catch (Exception e){ LogUtil.error("e=",e); }finally { lock.unLock(lockKey); } return true; }
分析:
利用Redis 分布式鎖,強制控制同一個商品處理請求串行化,缺點並發不高 ,處理比較慢,不適合搶購,高並發場景。用戶體驗差,但是減輕了數據庫的壓力。
- 方案3 :redis + mq + mysql 保證庫存安全,滿足高並發處理,但相對復雜。
/** * 扣庫存操作,秒殺的處理方案 * @param orderCode * @param skuCode * @param num * @return */ public boolean subtractStock(String orderCode,String skuCode, Integer num) { String key = "shop-product-stock" + skuCode; Object value = redis.get(key); if (value == null) { //前提 提前將商品庫存放入緩存 ,如果緩存不存在,視為沒有該商品 return false; } //先檢查 庫存是否充足 Integer stock = (Integer) value; if (stock < num) { LogUtil.info("庫存不足"); return false; } //不可在這里直接操作數據庫減庫存,否則導致數據不安全 //因為此時可能有其他線程已經將redis的key修改了 //redis 減少庫存,然后才能操作數據庫 Long newStock = redis.increment(key, -num.longValue()); //庫存充足 if (newStock >= 0) { LogUtil.info("成功搶購"); //TODO 真正扣庫存操作 可用MQ 進行 redis 和 mysql 的數據同步,減少響應時間 } else { //庫存不足,需要增加剛剛減去的庫存 redis.increment(key, num.longValue()); LogUtil.info("庫存不足,並發"); return false; } return true; }
分析:
利用Redis increment 的原子操作,保證庫存安全,利用MQ保證高並發響應時間。但是事需要把庫存的信息保存到Redis,並保證Redis 和 Mysql 數據同步。缺點是redis宕機后不能下單。
increment 是個原子操作。
綜上所述:
方案三滿足秒殺、高並發搶購等熱點商品的處理,真正減扣庫存和下單可以異步執行。在並發情況不高,平常商品或者正常購買流程,可以采用方案一數據庫樂觀鎖的處理,或者對方案三進行重新設計,設計成支持單訂單多商品即可,但復雜性提高,同時redis和mysql數據一致性需要定期檢查。
訂單時效問題
超過訂單有效時間,訂單取消,可利用MQ或其他方案回退庫存。
設置定時檢查
Spring task 的cron表達式定時任務
MQ消息延時隊列
訂單與庫存涉及的幾個重要知識
TCC 模型:Try/Confirm/Cancel:不使用強一致性的處理方案,最終一致性即可,下單減庫存,成功后生成訂單數據,如果此時由於超時導致庫存扣成功但是返回失敗,則通過定時任務檢查進行數據恢復,如果本條數據執行次數超過某個限制,人工回滾。還庫存也是這樣。
冪等性:分布式高並發系統如何保證對外接口的冪等性,記錄庫存流水是實現庫存回滾,支持冪等性的一個解決方案,訂單號+skuCode為唯一主鍵(該表修改頻次高,少建索引)
樂觀鎖:where stock + num>0
消息隊列:實現分布式事務 和 異步處理(提升響應速度)
redis:限制請求頻次,高並發解決方案,提升響應速度
分布式鎖:防止重復提交,防止高並發,強制串行化
分布式事務:最終一致性,同步處理(Dubbo)/異步處理(MQ)修改 + 補償機制
寫在最后的話
大家看完有什么不懂的可以在下方留言討論,也可以私信問我一般看到后我都會回復的。也歡迎大家關注我的公眾號:前程有光,金三銀四跳槽面試季,整理了1000多道將近500多頁pdf文檔的Java面試題資料,文章都會在里面更新,整理的資料也會放在里面。最后覺得文章對你有幫助的話記得點個贊哦,點點關注不迷路,每天都有新鮮的干貨分享!