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


image

前言

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

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

本篇文章主要內容

  • 為何我們需要對下訂單采用異步處理
  • 簡單的訂單異步處理實現
  • 非異步與異步下單接口的性能對比
  • 一個用戶搶購體驗更好的實現方式

前文回顧

歡迎關注我的公眾號關注最新的動態:后端技術漫談

項目源碼

再也不用擔心看完文章不會代碼實現啦:

https://github.com/qqxx6661/miaosha

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

整個項目源碼倉庫使用了Maven + Springboot進行編寫,並且上傳了SQL文件,支持SpringBoot一鍵啟動,方便大家調試。

我努力將整個倉庫的代碼盡量做到整潔和可復用,在代碼中我盡量做好每個方法的文檔,並且盡量最小化方法的功能,比如下面這樣:

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)模塊一樣,盡量讓小伙伴們可以復制粘貼,整合到自己的代碼里,稍作修改適配便可以使用。

正文

秒殺系統介紹

可以翻閱該系列的第一篇文章,這里不再回顧:

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

簡單的訂單異步處理實現

介紹

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

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

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

  • 同步方式:大量請求快速占滿數據庫框架開啟的數據庫連接池,同時修改數據庫,導致數據庫讀寫性能驟減。
  • 異步方式:一條條消息以順序的方式寫入數據庫,連接數幾乎不變(當然,也取決於消息隊列消費者的數量)。

這種實現可以理解為是一中流量削峰:讓數據庫按照他的處理能力,從消息隊列中拿取消息進行處理。

結合之前的四篇秒殺系統文章,這樣整個流程圖我們就實現了:

image

代碼實現

我們在源碼倉庫里,新增一個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帶寬小水管雲數據庫打穿了!

image

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

我們改成1000個線程並發,商品庫存為500個,使用常規的非異步下單接口

image

對比1000個線程並發,使用異步訂單接口

image

可以看到,非異步的情況下,吞吐量是37個請求/秒,而異步情況下,我們的接只是做了兩個事情,檢查緩存中庫存+發消息給消息隊列,所以吞吐量為600個請求/秒。

在發送完請求后,消息隊列中立刻開始處理消息:

image

image

我截圖了在500個庫存剛剛好消耗完的時候的日志,可以看到,一旦庫存沒有了,消息隊列就完成不了扣減庫存的操作,就不會將訂單寫入數據庫,也不會向緩存中記錄用戶已經購買了該商品的消息。

image

更加優雅的實現

那么問題來了,我們實現了上面的異步處理后,用戶那邊得到的結果是怎么樣的呢?

用戶點擊了提交訂單,收到了消息:您的訂單已經提交成功。然后用戶啥也沒看見,也沒有訂單號,用戶開始慌了,點到了自己的個人中心——已付款。發現居然沒有訂單!(因為可能還在隊列中處理)

這樣的話,用戶可能馬上就要開始投訴了!太不人性化了,我們不能只為了開發方便,舍棄了用戶體驗!

所以我們要改進一下,如何改進呢?其實很簡單:

  • 讓前端在提交訂單后,顯示一個“排隊中”,就像我們在小米官網搶小米手機那樣
  • 同時,前端不斷請求 檢查用戶和商品是否已經有訂單 的接口,如果得到訂單已經處理完成的消息,頁面跳轉搶購成功。

是不是很小米(滑稽.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

image

可以看到,第一次請求,下單成功了,第二次請求,則會返回已經搶購過。

因為這時候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

image

第一次請求,返回信息如上圖。

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

image

一直刷刷刷接口,10秒之后,接口返回”恭喜您,搶購成功“,如下圖:

image

整個流程就走完了。

結束語

這篇文章介紹了如何在保證用戶體驗的情況下完成訂單異步處理的流程。內容其實不多,深度沒有前一篇那么難理解。(我拖更也有一部分原因是因為我覺得上一篇的深度我很難隨隨便便達到,就不敢隨意寫文章,有壓力。)

希望大家喜歡,目前來看,整個秒殺下訂單的主流程我們全部介紹完了。當然里面很多東西都非常基礎,比如數據庫設計我一直停留在那幾個破字段,比如訂單的編號,其實不可能用主鍵id來做等等。

所以之后我文章的重點會更加關注某個特定的方面,比如:

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

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

我的公眾號包括博客流量非常小,看見最近那么多公眾號都很快的發展龐大起來,我也很羡慕,希望大家多多轉發支持,在這里謝謝大家啦。

希望大家多多支持我的公號:后端技術漫談

參考

關注我

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

各大平台都可以找到我

原創文章主要內容

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

個人公眾號:后端技術漫談

個人公眾號:后端技術漫談

如果文章對你有幫助,不妨收藏,轉發,在看起來~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM