使用RateLimiter完成簡單的大流量限流,搶購秒殺限流

RateLimiter是guava提供的基於令牌桶算法的實現類,能夠很是簡單的完成限流特技,而且根據系統的實際狀況來調整生成token的速率。java

一般可應用於搶購限流防止沖垮系統;限制某接口、服務單位時間內的訪問量,譬如一些第三方服務會對用戶訪問量進行限制;限制網速,單位時間內只容許上傳下載多少字節等。mysql

下面來看一些簡單的實踐,須要先引入guava的maven依賴。web

一 有不少任務,但但願每秒不超過N個

import com.google.common.util.concurrent.RateLimiter;  
  
import java.util.ArrayList;  
import java.util.List;  
import java.util.concurrent.ExecutorService;  
import java.util.concurrent.Executors;  
  
/** 
 * Created by wuwf on 17/7/11. 
 * 有不少個任務,但但願每秒不超過X個,可用此類 
 */  
public class Demo1 {  
  
    public static void main(String[] args) {  
        //0.5表明一秒最多多少個  
        RateLimiter rateLimiter = RateLimiter.create(0.5);  
        List<Runnable> tasks = new ArrayList<Runnable>();  
        for (int i = 0; i < 10; i++) {  
            tasks.add(new UserRequest(i));  
        }  
        ExecutorService threadPool = Executors.newCachedThreadPool();  
        for (Runnable runnable : tasks) {  
            System.out.println("等待時間:" + rateLimiter.acquire());  
            threadPool.execute(runnable);  
        }  
    }  
  
    private static class UserRequest implements Runnable {  
        private int id;  
  
        public UserRequest(int id) {  
            this.id = id;  
        }  
  
        public void run() {  
            System.out.println(id);  
        }  
    }  
  
}  
該例子是多個線程依次執行,限制每2秒最多執行一個。運行看結果

咱們限制了2秒放行一個,能夠看到第一個是直接執行了,後面的每2秒會放行一個。
 
rateLimiter.acquire()該方法會阻塞線程,直到令牌桶中能取到令牌爲止才繼續向下執行,並返回等待的時間。

二 搶購場景限流

譬如咱們預估數據庫能承受併發10,超過了可能會形成故障,咱們就能夠對該請求接口進行限流。
package com.tianyalei.controller;  
  
import com.google.common.util.concurrent.RateLimiter;  
import com.tianyalei.model.GoodInfo;  
import com.tianyalei.service.GoodInfoService;  
import org.springframework.web.bind.annotation.RequestMapping;  
import org.springframework.web.bind.annotation.RestController;  
  
import javax.annotation.Resource;  
  
/** 
 * Created by wuwf on 17/7/11. 
 */  
@RestController  
public class IndexController {  
    @Resource(name = "db")  
    private GoodInfoService goodInfoService;  
  
    RateLimiter rateLimiter = RateLimiter.create(10);  
  
    @RequestMapping("/miaosha")  
    public Object miaosha(int count, String code) {  
        System.out.println("等待時間" + rateLimiter.acquire());  
        if (goodInfoService.update(code, count) > 0) {  
            return "購買成功";  
        }  
        return "購買失敗";  
    }  
  
  
  
    @RequestMapping("/add")  
    public Object add() {  
        for (int i = 0; i < 100; i++) {  
            GoodInfo goodInfo = new GoodInfo();  
            goodInfo.setCode("iphone" + i);  
            goodInfo.setAmount(100);  
            goodInfoService.add(goodInfo);  
        }  
  
        return "添加成功";  
    }  
}  
這個是接着以前的文章(秒殺系統db,http://blog.csdn.net/tianyaleixiaowu/article/details/74389273)加了個Controller
代碼很簡單,就是請求過來時,調用RateLimiter.acquire,若是每秒超過了10個請求,就阻塞等待。咱們使用jmeter進行模擬100個併發。
建立一個線程數爲100,啓動間隔時間爲0的線程組,表明100個併發請求。


 
初始化10個的容量,因此前10個請求無需等待直接成功,後面的開始被1秒10次限流了,基本上每0.1秒放行一個。

三 搶購場景降級

上面的例子雖然限制了單位時間內對DB的操做,可是對用戶是不友好的,由於他須要等待,不能迅速的獲得響應。當你有1萬個併發請求,一秒只能處理10個,那剩餘的用戶都會陷入漫長的等待。因此咱們須要對應用降級,一旦判斷出某些請求是得不到令牌的,就迅速返回失敗,避免無謂的等待。
因爲RateLimiter是屬於單位時間內生成多少個令牌的方式,譬如0.1秒生成1個,那搶購就要看運氣了,你恰好是在剛生成1個時進來了,那麼你就能搶到,在這0.1秒內其餘的請求就算白瞎了,只能寄但願於下一個0.1秒,而從用戶體驗上來講,不能讓他在那一直阻塞等待,因此就須要迅速判斷,該用戶在某段時間內,還有沒有機會獲得令牌,這裏就須要使用tryAcquire(long timeout, TimeUnit unit)方法,指定一個超時時間,一旦判斷出在timeout時間內還沒法取得令牌,就返回false。注意,這裏並非真正的等待了timeout時間,而是被判斷爲即使過了timeout時間,也沒法取得令牌。這個是不須要等待的。
 
看實現:
/** 
     * tryAcquire(long timeout, TimeUnit unit) 
     * 從RateLimiter 獲取許可若是該許可能夠在不超過timeout的時間內獲取獲得的話, 
     * 或者若是沒法在timeout 過時以前獲取獲得許可的話,那麼當即返回false(無需等待) 
     */  
    @RequestMapping("/buy")  
    public Object miao(int count, String code) {  
        //判斷可否在1秒內獲得令牌,若是不能則當即返回false,不會阻塞程序  
        if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {  
            System.out.println("短時間沒法獲取令牌,真不幸,排隊也瞎排");  
            return "失敗";  
        }  
        if (goodInfoService.update(code, count) > 0) {  
            System.out.println("購買成功");  
            return "成功";  
        }  
        System.out.println("數據不足,失敗");  
        return "失敗";  
    }  
在不看執行結果的狀況下,咱們能夠先分析一下,一秒出10個令牌,0.1秒出一個,100個請求進來,假如100個是同時到達,那麼最終只能成交10個,90個都會由於超時而失敗。事實上,並不會徹底同時到達,必然會出如今0.1秒後到達的,就會被納入下一個週期。這是一個挺複雜的數學問題,每個請求都會被計算將來可能獲取到令牌的機率。
還好,RateLimiter有本身的方法去作判斷。
咱們運行看結果


多執行幾回,發現每次這個順序都不太同樣。
通過我屢次試驗,當設置線程組的間隔時間爲0時,最終購買成功的數量老是22.其餘的78個都是失敗。但基本都是開始和結束時連續成功,中間的大段失敗。
我修改一下jmeter線程組這100個請求的產生時間爲1秒時,結果以下
 
除了前面幾個和最後幾個請求連續成功,中間的就比較穩定了,都是隔8個9個就會成功一次。
 
當我修改成2秒內產生100個請求時,結果就更平均了
 
基本上就是前10個成功,後面的就開始按照固定的速率而成功了。
這種場景更符合實際的應用場景,按照固定的單位時間進行分割,每一個單位時間產生一個令牌,可供購買。
看到這裏是否是有點明白搶小米的狀況了,不少時候並非你網速快,手速快就能搶到,你須要看後臺系統的分配狀況。因此你可否搶到,最好是開不少個帳號,而不是一直用一個帳號在猛點,由於你點也白點,後臺已經把你的資格排除在外了。
固然了,真正的搶購不是這麼簡單,瞬間的流量洪峯會沖垮服務器的負載,當100萬人搶1萬個小米時,鏈接口都請求不進來,更別提接口裏的令牌分配了。
此時就須要作上一層的限流,咱們能夠選擇在上一層作分佈式,開多個服務,先作一次限流,淘汰掉絕大多數運氣很差的用戶,甚至能夠隨機丟棄某些規則的用戶,迅速攔截90%的請求,讓你去網頁看單機排隊動畫,還剩10萬。10萬也太大,足以沖垮數據層,那就進隊列MQ,用MQ削峯後,而後才放進業務邏輯裏,再進行RateLimiter的限流,此時又能攔截掉90%的不幸者,還剩1萬,1萬去交給業務邏輯和數據層,用redis和DB來處理庫存。恭喜,你就是那個漏網之魚。
重點在於迅速攔截掉99%的不幸者,避免讓他們去接觸到數據層。並且不能等待時間太長,最好是請求的瞬間就能肯定你是永遠看單機動畫最好。
 
/***************************************************************************************************/
補充:
只在本地時效果不怎麼明顯,我把這個小工程部署到線上服務器壓測了一下。
首先試了一下去掉了RateLimiter,只用db的Service處理數據的狀況,發現mysql的服務佔CPU約20%,整體請求失敗率較高。可能是Tomcat超時。
使用RateLimiter阻塞後,數據庫CPU基本沒動靜,壓力幾乎沒有,Tomcat超時還有一些,由於仍是併發數大,處理不了。
使用RateLimiter非阻塞,超時和請求失敗極少,整體QPS上升了很多。
相關文章
相關標籤/搜索