前言
之前一直有小伙伴私信我問我高并發場景下的訂單和庫存處理方案,我最近也是因為加班的原因比較忙,就一直沒來得及回復。今天好不容易閑了下來想了想不如寫篇文章把這些都列出來的,讓大家都能學習到,說一千道一萬都不如滿滿的干貨來的實在,干貨都下面了!
介紹
前提:分布式系統,高并發場景
商品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)修改 + 補償機制
而高并發下的系統設計場景一般發生比較多的就是秒殺的時候,當然了,淘寶這樣的神仙級別的網站另當別論,我一直覺得淘寶的秒殺有點超越網站的范疇了
這里也給大家分享兩份文檔,一份阿里內部設計文檔,因為之前的時候在我的個人公眾號已經分享過很多次了,這一次,我就不詳細介紹了,介紹的是另外一份秒殺系統設計方案
一、秒殺系統架構設計都有哪些關鍵點?
二、設計秒殺系統時應該注意的5個架構原則
三、如何才能做好動靜分離?有哪些方案可選?
四、二八原則:有針對性地處理好系統的“熱點數據”
五、流量削峰這事應該怎么做?
六、影響性能的因素有哪些?又該如何提高系統的性能?
七、秒殺系統"減庫存"設計的核心邏輯
八、如何設計兜底方案?
兩份資料都是從阿里內部流出的設計方案,我想哪怕沒有參考,在實現公司業務需求的時候可能會對你有所幫助,更重要的是培養一個架構思維,我覺得這才是現在制約很多程序員發展的內容之一
個人公眾號:Java架構師聯盟,每日更新技術好文,更可查看資料獲取方式