1. 程式人生 > >使用RateLimiter完成簡單的大流量限流

使用RateLimiter完成簡單的大流量限流

公司券系統壓力過大,採用了限流的方法。

public Integer couponSendWithLimiter(CouponLogQC couponLogQC) {
    //限制每秒請求數 30秒每個使用者只能請求一次
    String uniqueCode = this.uniqueCode(couponLogQC);
    boolean isDuplicated = deDuplicationService.isSent(uniqueCode);
    if (isDuplicated) {
        return 0;
    }
    if (!rateLimiterService.tryAcquire()) {
        // 轉發至MQ
        amqpTemplate.convertAndSend(......);
        return 1;
    } else {
        return this.couponSend(couponLogQC);
    }
}

自己又研究了一下rateLimiter限流的原理:

RateLimiter是guava提供的基於令牌桶演算法的實現類,可以非常簡單的完成限流特技,並且根據系統的實際情況來調整生成token的速率。

通常可應用於搶購限流防止沖垮系統;限制某介面、服務單位時間內的訪問量,譬如一些第三方服務會對使用者訪問量進行限制;限制網速,單位時間內只允許上傳下載多少位元組等。

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

一 有很多工,但希望每秒不超過N個

[java] view plaincopyprint?

  1. import com.google.common.util.concurrent.RateLimiter;  
  2. import java.util.ArrayList;  
  3. import java.util.List;  
  4. import java.util.concurrent.ExecutorService;  
  5. import java.util.concurrent.Executors;  
  6. /** 
  7.  * Created by wuwf on 17/7/11. 
  8.  * 有很多個任務,但希望每秒不超過X個,可用此類 
  9.  */  
  10. public class Demo1 {  
  11.     public static void main(String[] args) {  
  12.         //0.5代表一秒最多多少個  
  13.         RateLimiter rateLimiter = RateLimiter.create(0.5);  
  14.         List<Runnable> tasks = new ArrayList<Runnable>();  
  15.         for (int i = 0; i < 10; i++) {  
  16.             tasks.add(new UserRequest(i));  
  17.         }  
  18.         ExecutorService threadPool = Executors.newCachedThreadPool();  
  19.         for (Runnable runnable : tasks) {  
  20.             System.out.println("等待時間:" + rateLimiter.acquire());  
  21.             threadPool.execute(runnable);  
  22.         }  
  23.     }  
  24.     private static class UserRequest implements Runnable {  
  25.         private int id;  
  26.         public UserRequest(int id) {  
  27.             this.id = id;  
  28.         }  
  29.         public void run() {  
  30.             System.out.println(id);  
  31.         }  
  32.     }  
  33. }  

該例子是多個執行緒依次執行,限制每2秒最多執行一個。執行看結果

我們限制了2秒放行一個,可以看到第一個是直接執行了,後面的每2秒會放行一個。

rateLimiter.acquire()該方法會阻塞執行緒,直到令牌桶中能取到令牌為止才繼續向下執行,並返回等待的時間。

二 搶購場景限流

譬如我們預估資料庫能承受併發10,超過了可能會造成故障,我們就可以對該請求介面進行限流。

[java] view plaincopyprint?

  1. package com.tianyalei.controller;  
  2. import com.google.common.util.concurrent.RateLimiter;  
  3. import com.tianyalei.model.GoodInfo;  
  4. import com.tianyalei.service.GoodInfoService;  
  5. import org.springframework.web.bind.annotation.RequestMapping;  
  6. import org.springframework.web.bind.annotation.RestController;  
  7. import javax.annotation.Resource;  
  8. /** 
  9.  * Created by wuwf on 17/7/11. 
  10.  */  
  11. @RestController  
  12. public class IndexController {  
  13.     @Resource(name = "db")  
  14.     private GoodInfoService goodInfoService;  
  15.     RateLimiter rateLimiter = RateLimiter.create(10);  
  16.     @RequestMapping("/miaosha")  
  17.     public Object miaosha(int count, String code) {  
  18.         System.out.println("等待時間" + rateLimiter.acquire());  
  19.         if (goodInfoService.update(code, count) > 0) {  
  20.             return "購買成功";  
  21.         }  
  22.         return "購買失敗";  
  23.     }  
  24.     @RequestMapping("/add")  
  25.     public Object add() {  
  26.         for (int i = 0; i < 100; i++) {  
  27.             GoodInfo goodInfo = new GoodInfo();  
  28.             goodInfo.setCode("iphone" + i);  
  29.             goodInfo.setAmount(100);  
  30.             goodInfoService.add(goodInfo);  
  31.         }  
  32.         return "新增成功";  
  33.     }  
  34. }  

這個是接著之前的文章(秒殺系統db,http://blog.csdn.net/tianyaleixiaowu/article/details/74389273)加了個Controller

程式碼很簡單,就是請求過來時,呼叫RateLimiter.acquire,如果每秒超過了10個請求,就阻塞等待。我們使用jmeter進行模擬100個併發。

建立一個執行緒數為100,啟動間隔時間為0的執行緒組,代表100個併發請求。

 

啟動jmeter請求,看控制檯結果

初始化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時間,也無法取得令牌。這個是不需要等待的。

看實現:

[java] view plaincopyprint?

  1. /** 
  2.      * tryAcquire(long timeout, TimeUnit unit) 
  3.      * 從RateLimiter 獲取許可如果該許可可以在不超過timeout的時間內獲取得到的話, 
  4.      * 或者如果無法在timeout 過期之前獲取得到許可的話,那麼立即返回false(無需等待) 
  5.      */  
  6.     @RequestMapping("/buy")  
  7.     public Object miao(int count, String code) {  
  8.         //判斷能否在1秒內得到令牌,如果不能則立即返回false,不會阻塞程式  
  9.         if (!rateLimiter.tryAcquire(1000, TimeUnit.MILLISECONDS)) {  
  10.             System.out.println("短期無法獲取令牌,真不幸,排隊也瞎排");  
  11.             return "失敗";  
  12.         }  
  13.         if (goodInfoService.update(code, count) > 0) {  
  14.             System.out.println("購買成功");  
  15.             return "成功";  
  16.         }  
  17.         System.out.println("資料不足,失敗");  
  18.         return "失敗";  
  19.     }  

在不看執行結果的情況下,我們可以先分析一下,一秒出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上升了不少。

測試不太正規,就大概跑了跑。