秒殺系統實戰(五)| 如何優雅的實現訂單異步處理

秒殺系統實戰(五)| 如何優雅的實現訂單異步處理

前言

我回來啦,前段時間忙得不可開交。這段時間終於能喘口氣了,繼續把以前挖的坑填起來。寫完上一篇秒殺系統(四):數據庫與緩存雙寫一致性深刻分析後,感受文章深度一會兒被我擡高了一些,如今構思新文章的時候,反而畏手畏腳,不敢隨便寫了。對於將來文章內容的想法,我寫在了本文的末尾。前端

本文咱們來聊聊秒殺系統中的訂單異步處理。git

本篇文章主要內容

  • 爲什麼咱們須要對下訂單採用異步處理
  • 簡單的訂單異步處理實現
  • 非異步與異步下單接口的性能對比
  • 一個用戶搶購體驗更好的實現方式

前文回顧

  • 零基礎實現秒殺系統(一):防止超賣
  • 零基礎實現秒殺系統(二):令牌桶限流 + 再談超賣
  • 零基礎實現秒殺系統(三):搶購接口隱藏 + 單用戶限制頻率
  • 零基礎實現秒殺系統(四):數據庫與緩存雙寫一致性深刻分析
  • 零基礎上手秒殺系統(五):如何優雅的完成訂單異步處理(本文)
  • ...

項目源碼

不再用擔憂看完文章不會代碼實現啦:github

https://github.com/qqxx6661/miaosha面試

我發現該倉庫的star數不知不覺已經超過100啦。❞redis

我努力將整個倉庫的代碼儘可能作到整潔和可複用,在代碼中我儘可能作好每一個方法的文檔,而且儘可能最小化方法的功能,好比下面這樣:算法

public interface StockService {
    /**
     * 查詢庫存:經過緩存查詢庫存
     * 緩存命中:返回庫存
     * 緩存未命中:查詢數據庫寫入緩存並返回
     * @param id
     * @return
     */
    Integer getStockCount(int id);

    /**
     * 獲取剩餘庫存:查數據庫
     * @param id
     * @return
     */
    int getStockCountByDB(int id);

    /**
     * 獲取剩餘庫存: 查緩存
     * @param id
     * @return
     */
    Integer getStockCountByCache(int id);

    /**
     * 將庫存插入緩存
     * @param id
     * @return
     */
    void setStockCountCache(int id, int count);

    /**
     * 刪除庫存緩存
     * @param id
     */
    void delStockCountCache(int id);

    /**
     * 根據庫存 ID 查詢數據庫庫存信息
     * @param id
     * @return
     */
    Stock getStockById(int id);

    /**
     * 根據庫存 ID 查詢數據庫庫存信息(悲觀鎖)
     * @param id
     * @return
     */
    Stock getStockByIdForUpdate(int id);

    /**
     * 更新數據庫庫存信息
     * @param stock
     * return
     */
    int updateStockById(Stock stock);

    /**
     * 更新數據庫庫存信息(樂觀鎖)
     * @param stock
     * @return
     */
    public int updateStockByOptimistic(Stock stock);

}

「這樣就像一個可拔插(plug-in)模塊同樣,儘可能讓小夥伴們能夠複製粘貼,整合到本身的代碼裏,稍做修改適配即可以使用。」spring

正文

秒殺系統介紹

能夠翻閱該系列的第一篇文章,這裏再也不回顧:數據庫

零基礎實現秒殺系統(一):防止超賣json

簡單的訂單異步處理實現

介紹

前面幾篇文章,咱們從「限流角度,緩存角度」來優化了用戶下單的速度,減小了服務器和數據庫的壓力。這些處理對於一個秒殺系統都是很是重要的,而且效果立竿見影,那還有什麼操做也能有立竿見影的效果呢?答案是對於下單的異步處理。後端

在秒殺系統用戶進行搶購的過程當中,因爲在同一時間會有大量請求涌入服務器,若是每一個請求都當即訪問數據庫進行扣減庫存+寫入訂單的操做,對數據庫的壓力是巨大的。

如何減輕數據庫的壓力呢,「咱們將每一條秒殺的請求存入消息隊列(例如RabbitMQ)中,放入消息隊列後,給用戶返回相似「搶購請求發送成功」的結果。而在消息隊列中,咱們將收到的下訂單請求一個個的寫入數據庫中」,比起多線程同步修改數據庫的操做,大大緩解了數據庫的鏈接壓力,最主要的好處就表如今數據庫鏈接的減小:

  • 同步方式:大量請求快速佔滿數據庫框架開啓的數據庫鏈接池,同時修改數據庫,致使數據庫讀寫性能驟減。
  • 異步方式:一條條消息以順序的方式寫入數據庫,鏈接數幾乎不變(固然,也取決於消息隊列消費者的數量)。
    「這種實現能夠理解爲是一中流量削峯:讓數據庫按照他的處理能力,從消息隊列中拿取消息進行處理。」

結合以前的四篇秒殺系統文章,這樣整個流程圖咱們就實現了:
秒殺系統實戰(五)| 如何優雅的實現訂單異步處理

代碼實現

咱們在源碼倉庫裏,新增一個controller對外接口:

/**
 * 下單接口:異步處理訂單
 * @param sid
 * @return
 */
@RequestMapping(value = "/createUserOrderWithMq", method = {RequestMethod.GET})
@ResponseBody
public String createUserOrderWithMq(@RequestParam(value = "sid") Integer sid,
                              @RequestParam(value = "userId") Integer userId) {
    try {
        // 檢查緩存中該用戶是否已經下單過
        Boolean hasOrder = orderService.checkUserOrderInfoInCache(sid, userId);
        if (hasOrder != null && hasOrder) {
            LOGGER.info("該用戶已經搶購過");
            return "你已經搶購過了,不要太貪心.....";
        }
        // 沒有下單過,檢查緩存中商品是否還有庫存
        LOGGER.info("沒有搶購過,檢查緩存中商品是否還有庫存");
        Integer count = stockService.getStockCount(sid);
        if (count == 0) {
            return "秒殺請求失敗,庫存不足.....";
        }

        // 有庫存,則將用戶id和商品id封裝爲消息體傳給消息隊列處理
        // 注意這裏的有庫存和已經下單都是緩存中的結論,存在不可靠性,在消息隊列中會查表再次驗證
        LOGGER.info("有庫存:[{}]", count);
        JSONObject jsonObject = new JSONObject();
        jsonObject.put("sid", sid);
        jsonObject.put("userId", userId);
        sendToOrderQueue(jsonObject.toJSONString());
        return "秒殺請求提交成功";
    } catch (Exception e) {
        LOGGER.error("下單接口:異步處理訂單異常:", e);
        return "秒殺請求失敗,服務器正忙.....";
    }
}

createUserOrderWithMq接口總體流程以下:

  • 檢查緩存中該用戶是否已經下單過:在消息隊列下單成功後寫入redis一條用戶id和商品id綁定的數據
  • 沒有下單過,檢查緩存中商品是否還有庫存
  • 緩存中若是有庫存,則將用戶id和商品id封裝爲消息體「傳給消息隊列處理」
  • 注意:這裏的「有庫存和已經下單」都是緩存中的結論,存在不可靠性,在消息隊列中會查表再次驗證,「做爲兜底邏輯」

消息隊列是如何接收消息的呢?咱們新建一個消息隊列,採用第四篇文中使用過的RabbitMQ,我再稍微貼一下整個建立RabbitMQ的流程把:

  1. pom.xml新增RabbitMq的依賴:
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
  1. 寫一個RabbitMqConfig:
@Configuration
public class RabbitMqConfig {

    @Bean
    public Queue orderQueue() {
        return new Queue("orderQueue");
    }

}
  1. 添加一個消費者:
@Component
@RabbitListener(queues = "orderQueue")
public class OrderMqReceiver {

    private static final Logger LOGGER = LoggerFactory.getLogger(OrderMqReceiver.class);

    @Autowired
    private StockService stockService;

    @Autowired
    private OrderService orderService;

    @RabbitHandler
    public void process(String message) {
        LOGGER.info("OrderMqReceiver收到消息開始用戶下單流程: " + message);
        JSONObject jsonObject = JSONObject.parseObject(message);
        try {
            orderService.createOrderByMq(jsonObject.getInteger("sid"),jsonObject.getInteger("userId"));
        } catch (Exception e) {
            LOGGER.error("消息處理異常:", e);
        }
    }
}

真正的下單的操做,在service中完成,咱們在orderService中新建createOrderByMq方法:

@Override
public void createOrderByMq(Integer sid, Integer userId) throws Exception {

    Stock stock;
    //校驗庫存(不要學我在trycatch中作邏輯處理,這樣是不優雅的。這裏這樣處理是爲了兼容以前的秒殺系統文章)
    try {
        stock = checkStock(sid);
    } catch (Exception e) {
        LOGGER.info("庫存不足!");
        return;
    }
    //樂觀鎖更新庫存
    boolean updateStock = saleStockOptimistic(stock);
    if (!updateStock) {
        LOGGER.warn("扣減庫存失敗,庫存已經爲0");
        return;
    }

    LOGGER.info("扣減庫存成功,剩餘庫存:[{}]", stock.getCount() - stock.getSale() - 1);
    stockService.delStockCountCache(sid);
    LOGGER.info("刪除庫存緩存");

    //建立訂單
    LOGGER.info("寫入訂單至數據庫");
    createOrderWithUserInfoInDB(stock, userId);
    LOGGER.info("寫入訂單至緩存供查詢");
    createOrderWithUserInfoInCache(stock, userId);
    LOGGER.info("下單完成");

}

真正的下單的操做流程爲:

  • 校驗數據庫庫存
  • 樂觀鎖更新庫存(其餘以前講到的鎖也能夠啦)
  • 寫入訂單至數據庫
  • 「寫入訂單和用戶信息至緩存供查詢」:寫入後,在外層接口即可以經過判斷redis中是否存在用戶和商品的搶購信息,來直接給用戶返回「你已經搶購過」的消息。
    「我是如何在redis中記錄商品和用戶的關係的呢,我使用了set集合,key是商品id,而value則是用戶id的集合,固然這樣有一些不合理之處:」

  • 這種結構默認了一個用戶只能搶購一次這個商品
  • 使用set集合,在用戶過多後,每次檢查須要遍歷set,用戶過多有性能問題
    你們知道須要作這種操做就好,具體如何在生產環境的redis中存儲這種關係,你們能夠深刻優化下。
@Override
    public Boolean checkUserOrderInfoInCache(Integer sid, Integer userId) throws Exception {
        String key = CacheKey.USER_HAS_ORDER.getKey() + "_" + sid;
        LOGGER.info("檢查用戶Id:[{}] 是否搶購過商品Id:[{}] 檢查Key:[{}]", userId, sid, key);
        return stringRedisTemplate.opsForSet().isMember(key, userId.toString());
    }

「整個上述實現只考慮最精簡的流程,不把前幾篇文章的限流,驗證用戶等加入進來,而且默認考慮的是每一個用戶搶購一個商品就再也不容許搶購,個人想法是保證每篇文章的獨立性和代碼的任務最小化,至於最後的整合我相信小夥伴們本身能夠作到。」

非異步與異步下單接口的性能對比

接下來就是喜聞樂見的「非正規」性能測試環節,咱們來對異步處理和非異步處理作一個性能對比。

首先,爲了測試方便,我把用戶購買限制先取消掉,否則我用Jmeter(JMeter併發測試的使用方式參考秒殺系統第一篇文章)還要來模擬多個用戶id,太麻煩了,不是咱們的重點。咱們把上面的controller接口這一部分註釋掉:

// 檢查緩存中該用戶是否已經下單過
Boolean hasOrder = orderService.checkUserOrderInfoInCache(sid, userId);
if (hasOrder != null && hasOrder) {
    LOGGER.info("該用戶已經搶購過");
    return "你已經搶購過了,不要太貪心.....";
}

這樣咱們能夠用JMeter模擬搶購的狀況了。

「咱們先玩票大的!」 在我這個1c4g1m帶寬的雲數據庫上,「設置商品數量5000個,同時併發訪問10000次」。

服務器先跑起來,訪問接口是http://localhost:8080/createUserOrderWithMq?sid=1&userId=1

啓動!

10000個線程併發,直接把個人1M帶寬小水管雲數據庫打穿了!
秒殺系統實戰(五)| 如何優雅的實現訂單異步處理

對不起對不起,打擾了,咱們仍是老實一點,不要對這麼低配置的數據庫有不切實際的幻想。

咱們改爲1000個線程併發,商品庫存爲500個,「使用常規的非異步下單接口」:

秒殺系統實戰(五)| 如何優雅的實現訂單異步處理
對比1000個線程併發,「使用異步訂單接口」:
秒殺系統實戰(五)| 如何優雅的實現訂單異步處理

「能夠看到,非異步的狀況下,吞吐量是37個請求/秒,而異步狀況下,咱們的接只是作了兩個事情,檢查緩存中庫存+發消息給消息隊列,因此吞吐量爲600個請求/秒。」

在發送完請求後,消息隊列中馬上開始處理消息:
秒殺系統實戰(五)| 如何優雅的實現訂單異步處理
秒殺系統實戰(五)| 如何優雅的實現訂單異步處理

我截圖了在500個庫存剛恰好消耗完的時候的日誌,能夠看到,一旦庫存沒有了,消息隊列就完成不了扣減庫存的操做,就不會將訂單寫入數據庫,也不會向緩存中記錄用戶已經購買了該商品的消息。
秒殺系統實戰(五)| 如何優雅的實現訂單異步處理

更加優雅的實現

那麼問題來了,咱們實現了上面的異步處理後,用戶那邊獲得的結果是怎麼樣的呢?

用戶點擊了提交訂單,收到了消息:您的訂單已經提交成功。而後用戶啥也沒看見,也沒有訂單號,用戶開始慌了,點到了本身的我的中心——已付款。發現竟然沒有訂單!(由於可能還在隊列中處理)

這樣的話,用戶可能立刻就要開始投訴了!太不人性化了,咱們不能只爲了開發方便,捨棄了用戶體驗!

因此咱們要改進一下,如何改進呢?其實很簡單:

  • 讓前端在提交訂單後,顯示一個「排隊中」,「就像咱們在小米官網搶小米手機那樣」
  • 同時,前端不斷請求 檢查用戶和商品是否已經有訂單 的接口,若是獲得訂單已經處理完成的消息,頁面跳轉搶購成功。
    「是否是很小米(滑稽.jpg),暴露了我是miboy的事實」

實現起來,咱們只要在後端加一個獨立的接口:

/**
 * 檢查緩存中用戶是否已經生成訂單
 * @param sid
 * @return
 */
@RequestMapping(value = "/checkOrderByUserIdInCache", method = {RequestMethod.GET})
@ResponseBody
public String checkOrderByUserIdInCache(@RequestParam(value = "sid") Integer sid,
                              @RequestParam(value = "userId") Integer userId) {
    // 檢查緩存中該用戶是否已經下單過
    try {
        Boolean hasOrder = orderService.checkUserOrderInfoInCache(sid, userId);
        if (hasOrder != null && hasOrder) {
            return "恭喜您,已經搶購成功!";
        }
    } catch (Exception e) {
        LOGGER.error("檢查訂單異常:", e);
    }
    return "很抱歉,你的訂單還沒有生成,繼續排隊吧您嘞。";
}

咱們來試驗一下,首先咱們請求兩次下單的接口,你們用postman或者瀏覽器就好:

http://localhost:8080/createUserOrderWithMq?sid=1&userId=1

秒殺系統實戰(五)| 如何優雅的實現訂單異步處理
能夠看到,第一次請求,下單成功了,第二次請求,則會返回已經搶購過。

由於這時候redis已經寫入了該用戶下過訂單的數據:

127.0.0.1:6379> smembers miaosha_v1_user_has_order_1
(empty list or set)
127.0.0.1:6379> smembers miaosha_v1_user_has_order_1
1) "1"

咱們爲了模擬消息隊列處理茫茫多請求的行爲,咱們在下單的service方法中,讓線程休息10秒:

@Override
public void createOrderByMq(Integer sid, Integer userId) throws Exception {

    // 模擬多個用戶同時搶購,致使消息隊列排隊等候10秒
    Thread.sleep(10000);

    //完成下面的下單流程(省略)

}

而後咱們清除訂單信息,開始下單:

http://localhost:8080/createUserOrderWithMq?sid=1&userId=1

秒殺系統實戰(五)| 如何優雅的實現訂單異步處理
第一次請求,返回信息如上圖。

緊接着前端顯示排隊中的時候,請求檢查是否已經生成訂單的接口,接口返回」繼續排隊「:

秒殺系統實戰(五)| 如何優雅的實現訂單異步處理
一直刷刷刷接口,10秒以後,接口返回」恭喜您,搶購成功「,以下圖:
秒殺系統實戰(五)| 如何優雅的實現訂單異步處理

整個流程就走完了。

結束語

這篇文章介紹瞭如何在保證用戶體驗的狀況下完成訂單異步處理的流程。內容其實很少,深度沒有前一篇那麼難理解。(我拖更也有一部分緣由是由於我以爲上一篇的深度我很難隨隨便便達到,就不敢隨意寫文章,有壓力。)

但願你們喜歡,目前來看,整個秒殺下訂單的主流程咱們所有介紹完了。固然裏面不少東西都很是基礎,好比數據庫設計我一直停留在那幾個破字段,好比訂單的編號,其實不可能用主鍵id來作等等。

「因此以後我文章的重點會更加關注某個特定的方面」,好比:

  • 分佈式訂單惟一編號的生成
  • 網關層面的接口緩存
  • ...

固然,其餘內容的文章我也會不斷積累總結啦。

「個人公衆號包括博客流量很是小,看見最近那麼多公衆號都很快的發展龐大起來,我也很羨慕,但願你們多多轉發支持,在這裏謝謝你們啦。

關注我

我是一名後端開發工程師。主要關注後端開發,數據安全,爬蟲,物聯網,邊緣計算等方向,歡迎交流。

各大平臺均可以找到我

  • 「微信公衆號:後端技術漫談」
  • 「Github:@qqxx6661」
  • CSDN:@蠻三刀把刀
  • 知乎:@後端技術漫談
  • 簡書:@蠻三刀把刀
  • 掘金:@蠻三刀把刀
  • 騰訊雲+社區:@後端技術漫談

原創文章主要內容

  • 後端開發
  • Java面試
  • 設計模式/數據結構/算法題解
  • 爬蟲/邊緣計算/物聯網
  • 讀書筆記/逸聞趣事/程序人生

    我的公衆號:後端技術漫談

    秒殺系統實戰(五)| 如何優雅的實現訂單異步處理
    我的公衆號:後端技術漫談
    「若是文章對你有幫助,不妨收藏,轉發,在看起來~」

往期推薦系統設計 | 經過Binlog來實現系統間數據同步MySQL | 敖丙的數據庫調優最佳實踐【讀書筆記】《漫畫算法》克服入門算法的恐懼Java | 深刻理解String、StringBuilder 和 StringBuffer開源實戰 | Canal生產環境常見問題總結與分析

相關文章
相關標籤/搜索