【秒殺系統】零基礎上手秒殺系統(二):令牌桶限流 + 再談超賣

前言

本文是秒殺系統的第二篇,經過實際代碼講解,幫助你快速的瞭解秒殺系統的關鍵點,上手實際項目。mysql

本篇主要講解接口限流措施,接口限流其實定義也很是廣,接口限流自己也是系統安全防禦的一種措施,暫時列舉這幾種容易理解的:git

  • 令牌桶限流
  • 單用戶訪問頻率限流
  • 搶購接口隱藏

此外,前文發出後不少同窗對於樂觀鎖在高併發時沒法賣出所有商品提出了「嚴正抗議」,因此仍是在本篇中補充講解下樂觀鎖與悲觀鎖。github

前文回顧和文章規劃:面試

  • 從零開始打造簡易秒殺系統:防止超賣
  • 從零開始打造簡易秒殺系統:接口限流(令牌桶限流)+ 再談超賣
  • 從零開始打造簡易秒殺系統:接口限流(單用戶限流 + 搶購接口隱藏)
  • 從零開始打造簡易秒殺系統:使用Redis緩存熱點數據
  • 從零開始打造簡易秒殺系統:消息隊列異步處理訂單
  • ...

歡迎關注個人我的公衆號獲取最全的原創文章:後端技術漫談(二維碼見文章底部)算法

正文

項目源碼在這裏

媽媽不再用擔憂只看文章不會實現啦:sql

github.com/qqxx6661/mi…數據庫

秒殺系統介紹

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

從零開始搭建簡易秒殺系統(一):防止超賣後端

接口限流

在面臨高併發的請購請求時,咱們若是不對接口進行限流,可能會對後臺系統形成極大的壓力。尤爲是對於下單的接口,過多的請求打到數據庫會對系統的穩定性形成影響。設計模式

因此秒殺系統會盡可能選擇獨立於公司其餘後端系統以外進行單獨部署,以避免秒殺業務崩潰影響到其餘系統。

除了獨立部署秒殺業務以外,咱們可以作的就是儘可能讓後臺系統穩定優雅的處理大量請求。

接口限流實戰:令牌桶限流算法

令牌桶限流算法網上已經有了不少介紹,我摘抄一篇介紹過來:

令牌桶算法最初來源於計算機網絡。在網絡傳輸數據時,爲了防止網絡擁塞,需限制流出網絡的流量,使流量以比較均勻的速度向外發送。令牌桶算法就實現了這個功能,可控制發送到網絡上數據的數目,並容許突發數據的發送。

大小固定的令牌桶可自行以恆定的速率源源不斷地產生令牌。若是令牌不被消耗,或者被消耗的速度小於產生的速度,令牌就會不斷地增多,直到把桶填滿。後面再產生的令牌就會從桶中溢出。最後桶中能夠保存的最大令牌數永遠不會超過桶的大小。

令牌桶算法與漏桶算法

漏桶算法思路很簡單,水(請求)先進入到漏桶裏,漏桶以必定的速度出水,當水流入速度過大會直接溢出,能夠看出漏桶算法能強行限制數據的傳輸速率。

令牌桶算法不能與另一種常見算法漏桶算法相混淆。這兩種算法的主要區別在於:

漏桶算法可以強行限制數據的傳輸速率,而令牌桶算法在可以限制數據的平均傳輸速率外,還容許某種程度的突發傳輸。在令牌桶算法中,只要令牌桶中存在令牌,那麼就容許突發地傳輸數據直到達到用戶配置的門限,所以它適合於具備突發特性的流量

使用Guava的RateLimiter實現令牌桶限流接口

Guava是Google開源的Java工具類,裏面一應俱全,也提供了限流工具類RateLimiter,該類裏面實現了令牌桶算法。

咱們拿出源碼,在以前講過的樂觀鎖搶購接口上增長該令牌桶限流代碼:

OrderController:

@Controller
public class OrderController {

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

    @Autowired
    private StockService stockService;

    @Autowired
    private OrderService orderService;
    
    //每秒放行10個請求
    RateLimiter rateLimiter = RateLimiter.create(10);

    @RequestMapping("/createWrongOrder/{sid}")
    @ResponseBody
    public String createWrongOrder(@PathVariable int sid) {
        int id = 0;
        try {
            id = orderService.createWrongOrder(sid);
            LOGGER.info("建立訂單id: [{}]", id);
        } catch (Exception e) {
            LOGGER.error("Exception", e);
        }
        return String.valueOf(id);
    }

    /**
     * 樂觀鎖更新庫存 + 令牌桶限流
     * @param sid
     * @return
     */
    @RequestMapping("/createOptimisticOrder/{sid}")
    @ResponseBody
    public String createOptimisticOrder(@PathVariable int sid) {
        // 阻塞式獲取令牌
        //LOGGER.info("等待時間" + rateLimiter.acquire());
        // 非阻塞式獲取令牌
        if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {
            LOGGER.warn("你被限流了,真不幸,直接返回失敗");
            return "購買失敗,庫存不足";
        }
        int id;
        try {
            id = orderService.createOptimisticOrder(sid);
            LOGGER.info("購買成功,剩餘庫存爲: [{}]", id);
        } catch (Exception e) {
            LOGGER.error("購買失敗:[{}]", e.getMessage());
            return "購買失敗,庫存不足";
        }
        return String.format("購買成功,剩餘庫存爲:%d", id);
    }
}
複製代碼

代碼中,RateLimiter rateLimiter = RateLimiter.create(10);這裏初始化了令牌桶類,每秒放行10個請求。

在接口中,能夠看到有兩種使用方法:

  • 阻塞式獲取令牌:請求進來後,若令牌桶裏沒有足夠的令牌,就在這裏阻塞住,等待令牌的發放。
  • 非阻塞式獲取令牌:請求進來後,若令牌桶裏沒有足夠的令牌,會嘗試等待設置好的時間(這裏寫了1000ms),其會自動判斷在1000ms後,這個請求能不能拿到令牌,若是不能拿到,直接返回搶購失敗。若是timeout設置爲0,則等於阻塞時獲取令牌。

咱們使用JMeter設置200個線程,來同時搶購數據庫裏庫存100個的iphone。(數據庫結構和JMeter使用請查看從零開始搭建簡易秒殺系統(一):防止超賣

咱們將請求響應結果爲「你被限流了,真不幸,直接返回失敗」的請求單專斷言出來:

咱們使用rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS),非阻塞式的令牌桶算法,來看看購買結果:

能夠看到,綠色的請求表明被令牌桶攔截掉的請求,紅色的則是購買成功下單的請求。經過JMeter的請求彙總報告,能夠得知,在這種狀況下請求可以沒被限流的比率在15%左右。

能夠看到,200個請求中沒有被限流的請求裏,因爲樂觀鎖的緣由,會出現一些併發更新數據庫失敗的問題,致使商品沒有被賣出。這也是上一篇小夥伴問的最多的問題。因此我想再談一談樂觀鎖與悲觀鎖。

再談鎖以前,咱們再試一試令牌桶算法的阻塞式使用,咱們將代碼換成rateLimiter.acquire();,而後將數據庫恢復成100個庫存,訂單表清零。開始請求:

此次的結果很是有意思,先放幾張結果圖(按順序截圖的),愛思考的同窗們能夠先推測下我接下來想說啥。

總結:

  • 首先,全部請求進入了處理流程,可是被限流成每秒處理10個請求。
  • 在剛開始的請求裏,令牌桶裏一會兒被取了10個令牌,因此出現了第二張圖中的,樂觀鎖併發更新失敗,然而在後面的請求中,因爲令牌一旦生成就被拿走,因此請求進來的很均勻,沒有再出現併發更新庫存的狀況。這也符合「令牌桶」的定義,能夠應對突發請求(只是因爲樂觀鎖,因此購買衝突了)。而非「漏桶」的永遠恆定的請求限制。
  • 200個請求,在樂觀鎖的狀況下,賣出了所有100個商品,若是沒有該限流,而請求又過於集中的話,會賣不出去幾個。就像第一篇文章中的那種狀況同樣。

Guava中RateLimiter實現原理

令牌桶的實現原理,本文中再也不班門弄斧了,仍是以實戰爲主。

畢竟Guava是隻提供了令牌桶的一種實現,實際項目中確定還要根據需求來使用或者本身實現,你們能夠看看這篇文章:

segmentfault.com/a/119000001…

再談防止超賣

講完了令牌桶限流算法,咱們再回頭思考超賣的問題,在海量請求的場景下,若是像第一篇文章那樣的使用樂觀鎖,會致使大量的請求返回搶購失敗,用戶體驗極差。

然而使用悲觀鎖,好比數據庫事務,則可讓數據庫一個個處理庫存數修改,修改爲功後再迎接下一個請求,因此在不一樣狀況下,應該根據實際狀況使用悲觀鎖和樂觀鎖。

悲觀鎖(Pessimistic Lock), 顧名思義,就是很悲觀,每次去拿數據的時候都認爲別人會修改,因此每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會block直到它拿到鎖。傳統的關係型數據庫裏邊就用到了不少這種鎖機制,好比行鎖,表鎖等,讀鎖,寫鎖等,都是在作操做以前先上鎖。

樂觀鎖(Optimistic Lock), 顧名思義,就是很樂觀,每次去拿數據的時候都認爲別人不會修改,因此不會上鎖,可是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可使用版本號等機制。樂觀鎖適用於多讀的應用類型,這樣能夠提升吞吐量,像數據庫若是提供相似於write_condition機制的其實都是提供的樂觀鎖。

兩種鎖各有優缺點,不能單純的定義哪一個好於哪一個。

  • 樂觀鎖比較適合數據修改比較少,讀取比較頻繁的場景,即便出現了少許的衝突,這樣也省去了大量的鎖的開銷,故而提升了系統的吞吐量。
  • 可是若是常常發生衝突(寫數據比較多的狀況下),上層應用不不斷的retry,這樣反而下降了性能,對於這種狀況使用悲觀鎖就更合適。

實現不須要版本號字段的樂觀鎖

上一篇文章中,個人樂觀鎖創建在更新數據庫版本號上,這裏貼出一種不用額外字段的樂觀鎖SQL語句。

<update id="updateByOptimistic" parameterType="cn.monitor4all.miaoshadao.dao.Stock">
    update stock
    <set>
      sale = sale + 1,
    </set>
    WHERE id = #{id,jdbcType=INTEGER}
    AND sale = #{sale,jdbcType=INTEGER}
</update>
複製代碼

實現悲觀鎖

咱們爲了在高流量下,可以更好更快的賣出商品,咱們實現一個悲觀鎖(事務for update更新庫存)。看看悲觀鎖的結果如何。

在Controller中,增長一個悲觀鎖賣商品接口:

/**
 * 事務for update更新庫存
 * @param sid
 * @return
 */
@RequestMapping("/createPessimisticOrder/{sid}")
@ResponseBody
public String createPessimisticOrder(@PathVariable int sid) {
    int id;
    try {
        id = orderService.createPessimisticOrder(sid);
        LOGGER.info("購買成功,剩餘庫存爲: [{}]", id);
    } catch (Exception e) {
        LOGGER.error("購買失敗:[{}]", e.getMessage());
        return "購買失敗,庫存不足";
    }
    return String.format("購買成功,剩餘庫存爲:%d", id);
}
複製代碼

在Service中,給該賣商品流程加上事務:

@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED)
@Override
public int createPessimisticOrder(int sid){
    //校驗庫存(悲觀鎖for update)
    Stock stock = checkStockForUpdate(sid);
    //更新庫存
    saleStock(stock);
    //建立訂單
    int id = createOrder(stock);
    return stock.getCount() - (stock.getSale());
}

/**
 * 檢查庫存 ForUpdate
 * @param sid
 * @return
 */
private Stock checkStockForUpdate(int sid) {
    Stock stock = stockService.getStockByIdForUpdate(sid);
    if (stock.getSale().equals(stock.getCount())) {
        throw new RuntimeException("庫存不足");
    }
    return stock;
}

/**
 * 更新庫存
 * @param stock
 */
private void saleStock(Stock stock) {
    stock.setSale(stock.getSale() + 1);
    stockService.updateStockById(stock);
}

/**
 * 建立訂單
 * @param stock
 * @return
 */
private int createOrder(Stock stock) {
    StockOrder order = new StockOrder();
    order.setSid(stock.getId());
    order.setName(stock.getName());
    int id = orderMapper.insertSelective(order);
    return id;
}
複製代碼

這裏使用Spring的事務,@Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRED),若是遇到回滾,則返回Exception,而且事務傳播使用PROPAGATION_REQUIRED–支持當前事務,若是當前沒有事務,就新建一個事務,關於Spring事務傳播機制能夠自行查閱資料,之後也想出一個總結文章。

咱們依然設置100個商品,清空訂單表,開始用JMeter更改請求的接口/createPessimisticOrder/1,發起200個請求:

查看結果,能夠看到,HMeter給出的彙總報告中,200個請求,100個返回了搶購成功,100個返回了搶購失敗。而且商品賣給了前100個進來的請求,十分的有序。

因此,悲觀鎖在大量請求的請求下,有着更好的賣出成功率。可是須要注意的是,若是請求量巨大,悲觀鎖會致使後面的請求進行了長時間的阻塞等待,用戶就必須在頁面等待,很像是「假死」,能夠經過配合令牌桶限流,或者是給用戶顯著的等待提示來優化。

悲觀鎖真的鎖住庫存了嗎?

最後一個問題,我想證實下個人事務真的在執行for update後鎖住了商品庫存,不讓其餘線程修改庫存。

咱們在idea中打斷點,讓代碼運行到for update執行完成後。而後再mysql命令行中,執行 update stock set count = 50 where id = 1;試圖偷偷修改庫存,再回車以後,你會發現命令行阻塞了,沒有返回任何消息,顯然他在等待行鎖的釋放。

接下里,你手動繼續運行程序,把該事務執行完。在事務執行完成的瞬間,命令行中成功完成了修改,說明鎖已經被線程釋放,其餘的線程可以成功修改庫存了。證實事務的行鎖是有效的!

總結

本項目的代碼開源在了Github,你們隨意使用:

github.com/qqxx6661/mi…

下一篇,將會繼續講解接口限流(單用戶限流 + 搶購接口隱藏)。

如今有點累,休息休息。

但願你們多多支持個人公主號:後端技術漫談。

參考

關注我

我是一名後端開發工程師。

主要關注後端開發,數據安全,物聯網,邊緣計算方向,歡迎交流。

各大平臺均可以找到我

原創博客主要內容

  • 後端開發技術
  • Java面試知識點
  • 設計模式/數據結構
  • LeetCode/劍指offer 算法題解析
  • SpringBoot/SpringCloud入門實戰系列
  • 數據分析/數據爬蟲
  • 逸聞趣事/好書分享/我的生活

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

公衆號:後端技術漫談.jpg

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

相關文章
相關標籤/搜索