限流分類
限流的實現方案有很多種,磊哥這裏稍微理了一下,限流的分類如下所示:
- 合法性驗證限流:比如驗證碼、IP 黑名單等,這些手段可以有效的防止惡意攻擊和爬蟲採集;
- 容器限流:比如 Tomcat、Nginx 等限流手段,其中 Tomcat 可以設置最大線程數(maxThreads),當併發超過最大線程數會排隊等待執行;而 Nginx 提供了兩種限流手段:一是控制速率,二是控制併發連接數;
- 服務端限流:比如我們在服務器端通過限流算法實現限流,此項也是我們本文介紹的重點。
合法性驗證限流爲最常規的業務代碼,就是普通的驗證碼和 IP 黑名單系統,本文就不做過多的敘述了,我們重點來看下後兩種限流的實現方案:容器限流和服務端限流。
容器限流
Tomcat 限流
Tomcat 8.5 版本的最大線程數在 conf/server.xml 配置中,如下所示:
<Connector port="8080" protocol="HTTP/1.1"
connectionTimeout="20000"
maxThreads="150"
redirectPort="8443" />
其中 maxThreads 就是 Tomcat 的最大線程數,當請求的併發大於此值(maxThreads)時,請求就會排隊執行,這樣就完成了限流的目的。
小貼士:maxThreads 的值可以適當的調大一些,此值默認爲 150(Tomcat 版本 8.5.42),但這個值也不是越大越好,要看具體的硬件配置,需要注意的是每開啓一個線程需要耗用 1MB 的 JVM 內存空間用於作爲線程棧之用,並且線程越多 GC 的負擔也越重。最後需要注意一下,操作系統對於進程中的線程數有一定的限制,Windows 每個進程中的線程數不允許超過 2000,Linux 每個進程中的線程數不允許超過 1000。
Nginx 限流
Nginx 提供了兩種限流手段:一是控制速率,二是控制併發連接數。
控制速率
我們需要使用 limit_req_zone 用來限制單位時間內的請求數,即速率限制,示例配置如下:
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit;
}
}
以上配置表示,限制每個 IP 訪問的速度爲 2r/s,因爲 Nginx 的限流統計是基於毫秒的,我們設置的速度是 2r/s,轉換一下就是 500ms 內單個 IP 只允許通過 1 個請求,從 501ms 開始才允許通過第 2 個請求。
我們使用單 IP 在 10ms 內發併發送了 6 個請求的執行結果如下:
從以上結果可以看出他的執行符合我們的預期,只有 1 個執行成功了,其他的 5 個被拒絕了(第 2 個在 501ms 纔會被正常執行)。
速率限制升級版
上面的速率控制雖然很精準但是應用於真實環境未免太苛刻了,真實情況下我們應該控制一個 IP 單位總時間內的總訪問次數,而不是像上面那麼精確但毫秒,我們可以使用 burst 關鍵字開啓此設置,示例配置如下:
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server {
location / {
limit_req zone=mylimit burst=4;
}
}
burst=4 表示每個 IP 最多允許4個突發請求,如果單個 IP 在 10ms 內發送 6 次請求的結果如下:
從以上結果可以看出,有 1 個請求被立即處理了,4 個請求被放到 burst 隊列裏排隊執行了,另外 1 個請求被拒絕了。
控制併發數
利用 limit_conn_zone 和 limit_conn 兩個指令即可控制併發數,示例配置如下:
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_conn_zone $server_name zone=perserver:10m;
server {
...
limit_conn perip 10;
limit_conn perserver 100;
}
其中 limit_conn perip 10 表示限制單個 IP 同時最多能持有 10 個連接;limit_conn perserver 100 表示 server 同時能處理併發連接的總數爲 100 個。
小貼士:只有當 request header 被後端處理後,這個連接才進行計數。
服務端限流
服務端限流需要配合限流的算法來執行,而算法相當於執行限流的“大腦”,用於指導限制方案的實現。
有人看到「算法」兩個字可能就暈了,覺得很深奧,其實並不是。算法就相當於操作某個事務的具體實現步驟彙總,其實並不難懂,不要被它的表象給嚇到哦~
限流的常見算法有以下三種:
- 時間窗口算法
- 漏桶算法
- 令牌算法
接下來我們分別看來。
1.時間窗口算法
所謂的滑動時間算法指的是以當前時間爲截止時間,往前取一定的時間,比如往前取 60s 的時間,在這 60s 之內運行最大的訪問數爲 100,此時算法的執行邏輯爲,先清除 60s 之前的所有請求記錄,再計算當前集合內請求數量是否大於設定的最大請求數 100,如果大於則執行限流拒絕策略,否則插入本次請求記錄並返回可以正常執行的標識給客戶端。
滑動時間窗口如下圖所示:
其中每一小個表示 10s,被紅色虛線包圍的時間段則爲需要判斷的時間間隔,比如 60s 秒允許 100 次請求,那麼紅色虛線部分則爲 60s。
我們可以藉助 Redis 的有序集合 ZSet 來實現時間窗口算法限流,實現的過程是先使用 ZSet 的 key 存儲限流的 ID,score 用來存儲請求的時間,每次有請求訪問來了之後,先清空之前時間窗口的訪問量,統計現在時間窗口的個數和最大允許訪問量對比,如果大於等於最大訪問量則返回 false 執行限流操作,負責允許執行業務邏輯,並且在 ZSet 中添加一條有效的訪問記錄,具體實現代碼如下。
我們藉助 Jedis 包來操作 Redis,實現在 pom.xml 添加 Jedis 框架的引用,配置如下:
<!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.3.0</version>
</dependency>
具體的 Java 實現代碼如下:
import redis.clients.jedis.Jedis;
public class RedisLimit {
// Redis 操作客戶端
static Jedis jedis = new Jedis("127.0.0.1", 6379);
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 15; i++) {
boolean res = isPeriodLimiting("java", 3, 10);
if (res) {
System.out.println("正常執行請求:" + i);
} else {
System.out.println("被限流:" + i);
}
}
// 休眠 4s
Thread.sleep(4000);
// 超過最大執行時間之後,再從發起請求
boolean res = isPeriodLimiting("java", 3, 10);
if (res) {
System.out.println("休眠後,正常執行請求");
} else {
System.out.println("休眠後,被限流");
}
}
/**
* 限流方法(滑動時間算法)
* @param key 限流標識
* @param period 限流時間範圍(單位:秒)
* @param maxCount 最大運行訪問次數
* @return
*/
private static boolean isPeriodLimiting(String key, int period, int maxCount) {
long nowTs = System.currentTimeMillis(); // 當前時間戳
// 刪除非時間段內的請求數據(清除老訪問數據,比如 period=60 時,標識清除 60s 以前的請求記錄)
jedis.zremrangeByScore(key, 0, nowTs - period * 1000);
long currCount = jedis.zcard(key); // 當前請求次數
if (currCount >= maxCount) {
// 超過最大請求次數,執行限流
return false;
}
// 未達到最大請求數,正常執行業務
jedis.zadd(key, nowTs, "" + nowTs); // 請求記錄 +1
return true;
}
}
以上程序的執行結果爲:
正常執行請求:0
正常執行請求:1
正常執行請求:2
正常執行請求:3
正常執行請求:4
正常執行請求:5
正常執行請求:6
正常執行請求:7
正常執行請求:8
正常執行請求:9
被限流:10
被限流:11
被限流:12
被限流:13
被限流:14
休眠後,正常執行請求
此實現方式存在的缺點有兩個:
- 使用 ZSet 存儲有每次的訪問記錄,如果數據量比較大時會佔用大量的空間,比如 60s 允許 100W 訪問時;
- 此代碼的執行非原子操作,先判斷後增加,中間空隙可穿插其他業務邏輯的執行,最終導致結果不準確。
2.漏桶算法
漏桶算法的靈感源於漏斗,如下圖所示:
滑動時間算法有一個問題就是在一定範圍內,比如 60s 內只能有 10 個請求,當第一秒時就到達了 10 個請求,那麼剩下的 59s 只能把所有的請求都給拒絕掉,而漏桶算法可以解決這個問題。
漏桶算法類似於生活中的漏斗,無論上面的水流倒入漏斗有多大,也就是無論請求有多少,它都是以均勻的速度慢慢流出的。當上面的水流速度大於下面的流出速度時,漏斗會慢慢變滿,當漏斗滿了之後就會丟棄新來的請求;當上面的水流速度小於下面流出的速度的話,漏斗永遠不會被裝滿,並且可以一直流出。
漏洞算法的實現步驟是,先聲明一個隊列用來保存請求,這個隊列相當於漏斗,當隊列容量滿了之後就放棄新來的請求,然後重新聲明一個線程定期從任務隊列中獲取一個或多個任務進行執行,這樣就實現了漏桶算法。
上面我們演示 Nginx 的控制速率其實使用的就是漏桶算法,當然我們也可以藉助 Redis 很方便的實現漏洞算法。
我們可以使用 Redis 4.0 版本中提供的 Redis-Cell 模塊,該模塊使用的是漏斗算法,並且提供了原子的限流指令,而且依靠 Redis 這個天生的分佈式程序就可以實現比較完美的限流了。
Redis-Cell 實現限流的方法也很簡單,只需要使用一條指令 cl.throttle 即可,使用示例如下:
> cl.throttle mylimit 15 30 60
1)(integer)0 # 0 表示獲取成功,1 表示拒絕
2)(integer)15 # 漏斗容量
3)(integer)14 # 漏斗剩餘容量
4)(integer)-1 # 被拒絕之後,多長時間之後再試(單位:秒)-1 表示無需重試
5)(integer)2 # 多久之後漏斗完全空出來
其中 15 爲漏斗的容量,30 / 60s 爲漏斗的速率。
3.令牌算法
在令牌桶算法中有一個程序以某種恆定的速度生成令牌,並存入令牌桶中,而每個請求需要先獲取令牌才能執行,如果沒有獲取到令牌的請求可以選擇等待或者放棄執行,如下圖所示:
我們可以使用 Google 開源的 guava 包,很方便的實現令牌桶算法,首先在 pom.xml 添加 guava 引用,配置如下:
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.2-jre</version>
</dependency>
具體實現代碼如下:
import com.google.common.util.concurrent.RateLimiter;
import java.time.Instant;
/**
* Guava 實現限流
*/
public class RateLimiterExample {
public static void main(String[] args) {
// 每秒產生 10 個令牌(每 100 ms 產生一個)
RateLimiter rt = RateLimiter.create(10);
for (int i = 0; i < 11; i++) {
new Thread(() -> {
// 獲取 1 個令牌
rt.acquire();
System.out.println("正常執行方法,ts:" + Instant.now());
}).start();
}
}
}
以上程序的執行結果爲:
正常執行方法,ts:2020-05-15T14:46:37.175Z
正常執行方法,ts:2020-05-15T14:46:37.237Z
正常執行方法,ts:2020-05-15T14:46:37.339Z
正常執行方法,ts:2020-05-15T14:46:37.442Z
正常執行方法,ts:2020-05-15T14:46:37.542Z
正常執行方法,ts:2020-05-15T14:46:37.640Z
正常執行方法,ts:2020-05-15T14:46:37.741Z
正常執行方法,ts:2020-05-15T14:46:37.840Z
正常執行方法,ts:2020-05-15T14:46:37.942Z
正常執行方法,ts:2020-05-15T14:46:38.042Z
正常執行方法,ts:2020-05-15T14:46:38.142Z
從以上結果可以看出令牌確實是每 100ms 產生一個,而 acquire() 方法爲阻塞等待獲取令牌,它可以傳遞一個 int 類型的參數,用於指定獲取令牌的個數。它的替代方法還有 tryAcquire(),此方法在沒有可用令牌時就會返回 false 這樣就不會阻塞等待了。當然 tryAcquire() 方法也可以設置超時時間,未超過最大等待時間會阻塞等待獲取令牌,如果超過了最大等待時間,還沒有可用的令牌就會返回 false。
注意:使用 guava 實現的令牌算法屬於程序級別的單機限流方案,而上面使用 Redis-Cell 的是分佈式的限流方案。
總結
本文提供了 6 種具體的實現限流的手段,他們分別是:Tomcat 使用 maxThreads 來實現限流;Nginx 提供了兩種限流方式,一是通過 limit_req_zone 和 burst 來實現速率限流,二是通過 limit_conn_zone 和 limit_conn 兩個指令控制併發連接的總數。最後我們講了時間窗口算法藉助 Redis 的有序集合可以實現,還有漏桶算法可以使用 Redis-Cell 來實現,以及令牌算法可以解決 Google 的 guava 包來實現。
需要注意的是藉助 Redis 實現的限流方案可用於分佈式系統,而 guava 實現的限流只能應用於單機環境。如果你嫌棄服務器端限流麻煩,甚至可以在不改代碼的情況下直接使用容器限流(Nginx 或 Tomcat),但前提是能滿足你的業務需求。
分佈式限流的解決方案
https://blog.csdn.net/icangfeng/article/details/81202007
業務背景介紹
對於web應用的限流,光看標題,似乎過於抽象,難以理解,那我們還是以具體的某一個應用場景來引入這個話題吧。
在日常生活中,我們肯定收到過不少不少這樣的短信,“雙11約嗎?,千款….”,“您有幸獲得唱讀卡,趕快戳鏈接…”。這種類型的短信是屬於推廣性質的短信。爲什麼我要說這個呢?聽我慢慢道來。
一般而言,對於推廣營銷類短信,它們針對某一羣體(譬如註冊會員)進行定點推送,有時這個羣體的成員量比較大,譬如京東的會員,可以達到千萬級別。因此相應的,發送推廣短信的量也會增大。然而,要完成這些短信發送,我們是需要調用服務商的接口來完成的。倘若一次發送的量在200萬條,而我們的服務商接口每秒能處理的短信發送量有限,只能達到200條每秒。那麼這個時候就會產生問題了,我們如何能控制好程序發送短信時的速度暱?於是限流這個功能就得加上了
生產環境背景
1、服務商接口所能提供的服務上限是400條/s
2、業務方調用短信發送接口的速度未知,QPS可能達到800/s,1200/s,或者更高
3、當服務商接口訪問頻率超過400/s時,超過的量將拒絕服務,多出的信息將會丟失
4、線上爲多節點佈置,但調用的是同一個服務商接口
需求分析
1、鑑於業務方對短信發送接口的調用頻率未知,而服務商的接口服務有上限,爲保證服務的可用性,業務層需要對接口調用方的流量進行限制—–接口限流
方案一、在提供給業務方的Controller層進行控制。
1、使用guava提供工具庫裏的RateLimiter類(內部採用令牌捅算法實現)進行限流
-
<!--核心代碼片段-->
-
private RateLimiter rateLimiter = RateLimiter.create(400);//400表示每秒允許處理的量是400
-
if(rateLimiter.tryAcquire()) {
-
//短信發送邏輯可以在此處
-
-
}
2、使用Java自帶delayqueue的延遲隊列實現(編碼過程相對麻煩,此處省略代碼)
3、使用Redis實現,存儲兩個key,一個用於計時,一個用於計數。請求每調用一次,計數器增加1,若在計時器時間內計數器未超過閾值,則可以處理任務
-
if(!cacheDao.hasKey(API_WEB_TIME_KEY)) { cacheDao.putToValue(API_WEB_TIME_KEY,0,(long)1, TimeUnit.SECONDS);
-
} if(cacheDao.hasKey(API_WEB_TIME_KEY)&&cacheDao.incrBy(API_WEB_COUNTER_KEY,(long)1) > (long)400) {
-
LOGGER.info("調用頻率過快");
-
}
-
//短信發送邏輯
方案二、在短信發送至服務商時做限流處理
這裏省略。。。。。。。。
方案三、同時使用方案一和方案二
-
可行性分析
最快捷且有效的方式是使用RateLimiter實現,但是這很容易踩到一個坑,單節點模式下,使用RateLimiter進行限流一點問題都沒有。但是…線上是分佈式系統,佈署了多個節點,而且多個節點最終調用的是同一個短信服務商接口。雖然我們對單個節點能做到將QPS限制在400/s,但是多節點條件下,如果每個節點均是400/s,那麼到服務商那邊的總請求就是節點數x400/s,於是限流效果失效。使用該方案對單節點的閾值控制是難以適應分佈式環境的,至少目前我還沒想到更爲合適的方式。
對於第二種,使用delayqueue方式。其實主要存在兩個問題,1:短信系統本身就用了一層消息隊列,有用kafka,或者rabitmq,如果再加一層延遲隊列,從設計上來說是不太合適的。2:實現delayqueue的過程相對較麻煩,耗時可能比較長,而且達不到精準限流的效果
對於第三種,使用redis進行限流,其很好地解決了分佈式環境下多實例所導致的併發問題。因爲使用redis設置的計時器和計數器均是全局唯一的,不管多少個節點,它們使用的都是同樣的計時器和計數器,因此可以做到非常精準的流控。同時,這種方案編碼並不複雜,可能需要的代碼不超過10行。 -
實施方案
根據可行性分析可知,整個系統採取redis限流處理是成本最低且最高效的。
具體實現1、在Controller層設置兩個全局key,一個用於計數,另一個用於計時
-
private static final String API_WEB_TIME_KEY = "time_key";
-
-
private static final String API_WEB_COUNTER_KEY = "counter_key";
2、對時間key的存在與否進行判斷,並對計數器是否超過閾值進行判斷
-
if(!cacheDao.hasKey(API_WEB_TIME_KEY)) {
-
-
cacheDao.putToValue(API_WEB_TIME_KEY,0,(long)1, TimeUnit.SECONDS);
-
cacheDao.putToValue(API_WEB_COUNTER_KEY,0,(long)2, TimeUnit.SECONDS);//時間到就重新初始化爲
-
-
}
-
-
if(cacheDao.hasKey(API_WEB_TIME_KEY)&&cacheDao.incrBy(API_WEB_COUNTER_KEY,(long)1) > (long)400) {
-
-
-
LOGGER.info("調用頻率過快");
-
-
}
-
//短信發送邏輯
集羣限流
一、場景描述
很多做服務接口的人或多或少的遇到這樣的場景,由於業務應用系統的負載能力有限,爲了防止非預期的請求對系統壓力過大而拖垮業務應用系統。
也就是面對大流量時,如何進行流量控制?
服務接口的流量控制策略:分流、降級、限流等。本文討論下限流策略,雖然降低了服務接口的訪問頻率和併發量,卻換取服務接口和業務應用系統的高可用。
實際場景中常用的限流策略:
- Nginx前端限流
按照一定的規則如帳號、IP、系統調用邏輯等在Nginx層面做限流
- 業務應用系統限流
1、客戶端限流
2、服務端限流
- 數據庫限流
紅線區,力保數據庫
二、常用的限流算法
常用的限流算法由:樓桶算法和令牌桶算法。本文不具體的詳細說明兩種算法的原理,原理會在接下來的文章中做說明。
1、漏桶算法
漏桶(Leaky Bucket)算法思路很簡單,水(請求)先進入到漏桶裏,漏桶以一定的速度出水(接口有響應速率),當水流入速度過大會直接溢出(訪問頻率超過接口響應速率),然後就拒絕請求,可以看出漏桶算法能強行限制數據的傳輸速率.示意圖如下:
可見這裏有兩個變量,一個是桶的大小,支持流量突發增多時可以存多少的水(burst),另一個是水桶漏洞的大小(rate)。
因爲漏桶的漏出速率是固定的參數,所以,即使網絡中不存在資源衝突(沒有發生擁塞),漏桶算法也不能使流突發(burst)到端口速率.因此,漏桶算法對於存在突發特性的流量來說缺乏效率.
2、令牌桶算法
令牌桶算法(Token Bucket)和 Leaky Bucket 效果一樣但方向相反的算法,更加容易理解.隨着時間流逝,系統會按恆定1/QPS時間間隔(如果QPS=100,則間隔是10ms)往桶裏加入Token(想象和漏洞漏水相反,有個水龍頭在不斷的加水),如果桶已經滿了就不再加了.新請求來臨時,會各自拿走一個Token,如果沒有Token可拿了就阻塞或者拒絕服務.
令牌桶的另外一個好處是可以方便的改變速度. 一旦需要提高速率,則按需提高放入桶中的令牌的速率. 一般會定時(比如100毫秒)往桶中增加一定數量的令牌, 有些變種算法則實時的計算應該增加的令牌的數量.
三、基於Redis功能的實現
簡陋的設計思路:假設一個用戶(用IP判斷)每分鐘訪問某一個服務接口的次數不能超過10次,那麼我們可以在Redis中創建一個鍵,並此時我們就設置鍵的過期時間爲60秒,每一個用戶對此服務接口的訪問就把鍵值加1,在60秒內當鍵值增加到10的時候,就禁止訪問服務接口。在某種場景中添加訪問時間間隔還是很有必要的。
1)使用Redis的incr命令,將計數器作爲Lua腳本
1 local current 2 current = redis.call("incr",KEYS[1]) 3 if tonumber(current) == 1 then 4 redis.call("expire",KEYS[1],1) 5 end
Lua腳本在Redis中運行,保證了incr和expire兩個操作的原子性。
2)使用Reids的列表結構代替incr命令
1 FUNCTION LIMIT_API_CALL(ip) 2 current = LLEN(ip) 3 IF current > 10 THEN 4 ERROR "too many requests per second" 5 ELSE 6 IF EXISTS(ip) == FALSE 7 MULTI 8 RPUSH(ip,ip) 9 EXPIRE(ip,1) 10 EXEC 11 ELSE 12 RPUSHX(ip,ip) 13 END 14 PERFORM_API_CALL() 15 END
Rate Limit使用Redis的列表作爲容器,LLEN用於對訪問次數的檢查,一個事物中包含了RPUSH和EXPIRE兩個命令,用於在第一次執行計數是創建列表並設置過期時間,
RPUSHX在後續的計數操作中進行增加操作。
四、基於令牌桶算法的實現
令牌桶算法可以很好的支撐突然額流量的變化即滿令牌桶數的峯值。
import java.io.BufferedWriter; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStreamWriter; import java.util.Random; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import com.google.common.base.Preconditions; import com.netease.datastream.util.framework.LifeCycle; 20 public class TokenBucket implements LifeCycle { // 默認桶大小個數 即最大瞬間流量是64M private static final int DEFAULT_BUCKET_SIZE = 1024 * 1024 * 64; // 一個桶的單位是1字節 private int everyTokenSize = 1; // 瞬間最大流量 private int maxFlowRate; // 平均流量 private int avgFlowRate; // 隊列來緩存桶數量:最大的流量峯值就是 = everyTokenSize*DEFAULT_BUCKET_SIZE 64M = 1 * 1024 * 1024 * 64 private ArrayBlockingQueue<Byte> tokenQueue = new ArrayBlockingQueue<Byte>(DEFAULT_BUCKET_SIZE); private ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); private volatile boolean isStart = false; private ReentrantLock lock = new ReentrantLock(true); private static final byte A_CHAR = 'a'; public TokenBucket() { } public TokenBucket(int maxFlowRate, int avgFlowRate) { this.maxFlowRate = maxFlowRate; this.avgFlowRate = avgFlowRate; } public TokenBucket(int everyTokenSize, int maxFlowRate, int avgFlowRate) { this.everyTokenSize = everyTokenSize; this.maxFlowRate = maxFlowRate; this.avgFlowRate = avgFlowRate; } public void addTokens(Integer tokenNum) { // 若是桶已經滿了,就不再家如新的令牌 for (int i = 0; i < tokenNum; i++) { tokenQueue.offer(Byte.valueOf(A_CHAR)); } } public TokenBucket build() { start(); return this; } /** * 獲取足夠的令牌個數 * * @return */ public boolean getTokens(byte[] dataSize) { Preconditions.checkNotNull(dataSize); Preconditions.checkArgument(isStart, "please invoke start method first !"); int needTokenNum = dataSize.length / everyTokenSize + 1;// 傳輸內容大小對應的桶個數 final ReentrantLock lock = this.lock; lock.lock(); try { boolean result = needTokenNum <= tokenQueue.size(); // 是否存在足夠的桶數量 if (!result) { return false; } int tokenCount = 0; for (int i = 0; i < needTokenNum; i++) { Byte poll = tokenQueue.poll(); if (poll != null) { tokenCount++; } } return tokenCount == needTokenNum; } finally { lock.unlock(); } } @Override public void start() { // 初始化桶隊列大小 if (maxFlowRate != 0) { tokenQueue = new ArrayBlockingQueue<Byte>(maxFlowRate); } // 初始化令牌生產者 TokenProducer tokenProducer = new TokenProducer(avgFlowRate, this); scheduledExecutorService.scheduleAtFixedRate(tokenProducer, 0, 1, TimeUnit.SECONDS); isStart = true; } @Override public void stop() { isStart = false; scheduledExecutorService.shutdown(); } @Override public boolean isStarted() { return isStart; } class TokenProducer implements Runnable { private int avgFlowRate; private TokenBucket tokenBucket; public TokenProducer(int avgFlowRate, TokenBucket tokenBucket) { this.avgFlowRate = avgFlowRate; this.tokenBucket = tokenBucket; } @Override public void run() { tokenBucket.addTokens(avgFlowRate); } } public static TokenBucket newBuilder() { return new TokenBucket(); } public TokenBucket everyTokenSize(int everyTokenSize) { this.everyTokenSize = everyTokenSize; return this; } public TokenBucket maxFlowRate(int maxFlowRate) { this.maxFlowRate = maxFlowRate; return this; } public TokenBucket avgFlowRate(int avgFlowRate) { this.avgFlowRate = avgFlowRate; return this; } private String stringCopy(String data, int copyNum) { StringBuilder sbuilder = new StringBuilder(data.length() * copyNum); for (int i = 0; i < copyNum; i++) { sbuilder.append(data); } return sbuilder.toString(); } public static void main(String[] args) throws IOException, InterruptedException { tokenTest(); } private static void arrayTest() { ArrayBlockingQueue<Integer> tokenQueue = new ArrayBlockingQueue<Integer>(10); tokenQueue.offer(1); tokenQueue.offer(1); tokenQueue.offer(1); System.out.println(tokenQueue.size()); System.out.println(tokenQueue.remainingCapacity()); } private static void tokenTest() throws InterruptedException, IOException { TokenBucket tokenBucket = TokenBucket.newBuilder().avgFlowRate(512).maxFlowRate(1024).build(); BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(new FileOutputStream("/tmp/ds_test"))); String data = "xxxx";// 四個字節 for (int i = 1; i <= 1000; i++) { Random random = new Random(); int i1 = random.nextInt(100); boolean tokens = tokenBucket.getTokens(tokenBucket.stringCopy(data, i1).getBytes()); TimeUnit.MILLISECONDS.sleep(100); if (tokens) { bufferedWriter.write("token pass --- index:" + i1); System.out.println("token pass --- index:" + i1); } else { bufferedWriter.write("token rejuect --- index" + i1); System.out.println("token rejuect --- index" + i1); } bufferedWriter.newLine(); bufferedWriter.flush(); } bufferedWriter.close(); } }
參考:
http://xiaobaoqiu.github.io/blog/2015/07/02/ratelimiter/
http://redisdoc.com/string/incr.html
http://www.cnblogs.com/zhengyun_ustc/archive/2012/11/17/topic1.html
http://www.cnblogs.com/exceptioneye/p/4783904.html
在開發高併發系統時有三把利器用來保護系統:緩存、降級和限流。緩存的目的是提升系統訪問速度和增大系統能處理的容量,可謂是抗高併發流量的銀彈;而降級是當服務出問題或者影響到核心流程的性能則需要暫時屏蔽掉,待高峯或者問題解決後再打開;而有些場景並不能用緩存和降級來解決,比如稀缺資源(秒殺、搶購)、寫服務(如評論、下單)、頻繁的複雜查詢(評論的最後幾頁),因此需有一種手段來限制這些場景的併發/請求量,即限流。
限流的目的是通過對併發訪問/請求進行限速或者一個時間窗口內的的請求進行限速來保護系統,一旦達到限制速率則可以拒絕服務(定向到錯誤頁或告知資源沒有了)、排隊或等待(比如秒殺、評論、下單)、降級(返回兜底數據或默認數據,如商品詳情頁庫存默認有貨)。
一般開發高併發系統常見的限流有:限制總併發數(比如數據庫連接池、線程池)、限制瞬時併發數(如nginx的limit_conn模塊,用來限制瞬時併發連接數)、限制時間窗口內的平均速率(如Guava的RateLimiter、nginx的limit_req模塊,限制每秒的平均速率);其他還有如限制遠程接口調用速率、限制MQ的消費速率。另外還可以根據網絡連接數、網絡流量、CPU或內存負載等來限流。
先有緩存這個銀彈,後有限流來應對618、雙十一高併發流量,在處理高併發問題上可以說是如虎添翼,不用擔心瞬間流量導致系統掛掉或雪崩,最終做到有損服務而不是不服務;限流需要評估好,不可亂用,否則會正常流量出現一些奇怪的問題而導致用戶抱怨。
在實際應用時也不要太糾結算法問題,因爲一些限流算法實現是一樣的只是描述不一樣;具體使用哪種限流技術還是要根據實際場景來選擇,不要一味去找最佳模式,白貓黑貓能解決問題的就是好貓。
因在實際工作中遇到過許多人來問如何進行限流,因此本文會詳細介紹各種限流手段。那麼接下來我們從限流算法、應用級限流、分佈式限流、接入層限流來詳細學習下限流技術手段。
限流算法
常見的限流算法有:令牌桶、漏桶。計數器也可以進行粗暴限流實現。
令牌桶算法
令牌桶算法是一個存放固定容量令牌的桶,按照固定速率往桶裏添加令牌。令牌桶算法的描述如下:
-
假設限制2r/s,則按照500毫秒的固定速率往桶中添加令牌;
-
桶中最多存放b個令牌,當桶滿時,新添加的令牌被丟棄或拒絕;
-
當一個n個字節大小的數據包到達,將從桶中刪除n個令牌,接着數據包被髮送到網絡上;
-
如果桶中的令牌不足n個,則不會刪除令牌,且該數據包將被限流(要麼丟棄,要麼緩衝區等待)。
漏桶算法
漏桶作爲計量工具(The Leaky Bucket Algorithm as a Meter)時,可以用於流量整形(Traffic Shaping)和流量控制(TrafficPolicing),漏桶算法的描述如下:
-
一個固定容量的漏桶,按照常量固定速率流出水滴;
-
如果桶是空的,則不需流出水滴;
-
可以以任意速率流入水滴到漏桶;
-
如果流入水滴超出了桶的容量,則流入的水滴溢出了(被丟棄),而漏桶容量是不變的。
令牌桶和漏桶對比:
-
令牌桶是按照固定速率往桶中添加令牌,請求是否被處理需要看桶中令牌是否足夠,當令牌數減爲零時則拒絕新的請求;
-
漏桶則是按照常量固定速率流出請求,流入請求速率任意,當流入的請求數累積到漏桶容量時,則新流入的請求被拒絕;
-
令牌桶限制的是平均流入速率(允許突發請求,只要有令牌就可以處理,支持一次拿3個令牌,4個令牌),並允許一定程度突發流量;
-
漏桶限制的是常量流出速率(即流出速率是一個固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),從而平滑突發流入速率;
-
令牌桶允許一定程度的突發,而漏桶主要目的是平滑流入速率;
-
兩個算法實現可以一樣,但是方向是相反的,對於相同的參數得到的限流效果是一樣的。
另外有時候我們還使用計數器來進行限流,主要用來限制總併發數,比如數據庫連接池、線程池、秒殺的併發數;只要全局總請求數或者一定時間段的總請求數設定的閥值則進行限流,是簡單粗暴的總數量限流,而不是平均速率限流。
到此基本的算法就介紹完了,接下來我們首先看看應用級限流。
應用級限流
限流總併發/連接/請求數
對於一個應用系統來說一定會有極限併發/請求數,即總有一個TPS/QPS閥值,如果超了閥值則系統就會不響應用戶請求或響應的非常慢,因此我們最好進行過載保護,防止大量請求湧入擊垮系統。
如果你使用過Tomcat,其Connector 其中一種配置有如下幾個參數:
acceptCount:如果Tomcat的線程都忙於響應,新來的連接會進入隊列排隊,如果超出排隊大小,則拒絕連接;
maxConnections: 瞬時最大連接數,超出的會排隊等待;
maxThreads:Tomcat能啓動用來處理請求的最大線程數,如果請求處理量一直遠遠大於最大線程數則可能會僵死。
詳細的配置請參考官方文檔。另外如Mysql(如max_connections)、Redis(如tcp-backlog)都會有類似的限制連接數的配置。
限流總資源數
如果有的資源是稀缺資源(如數據庫連接、線程),而且可能有多個系統都會去使用它,那麼需要限制應用;可以使用池化技術來限制總資源數:連接池、線程池。比如分配給每個應用的數據庫連接是100,那麼本應用最多可以使用100個資源,超出了可以等待或者拋異常。
限流某個接口的總併發/請求數
如果接口可能會有突發訪問情況,但又擔心訪問量太大造成崩潰,如搶購業務;這個時候就需要限制這個接口的總併發/請求數總請求數了;因爲粒度比較細,可以爲每個接口都設置相應的閥值。可以使用Java中的AtomicLong進行限流:
=================================
try { if(atomic.incrementAndGet() > 限流數) { //拒絕請求 } //處理請求 } finally { atomic.decrementAndGet(); }
=================================
適合對業務無損的服務或者需要過載保護的服務進行限流,如搶購業務,超出了大小要麼讓用戶排隊,要麼告訴用戶沒貨了,對用戶來說是可以接受的。而一些開放平臺也會限制用戶調用某個接口的試用請求量,也可以用這種計數器方式實現。這種方式也是簡單粗暴的限流,沒有平滑處理,需要根據實際情況選擇使用;
限流某個接口的時間窗請求數
即一個時間窗口內的請求數,如想限制某個接口/服務每秒/每分鐘/每天的請求數/調用量。如一些基礎服務會被很多其他系統調用,比如商品詳情頁服務會調用基礎商品服務調用,但是怕因爲更新量比較大將基礎服務打掛,這時我們要對每秒/每分鐘的調用量進行限速;一種實現方式如下所示:
=================================
LoadingCache<Long, AtomicLong> counter = CacheBuilder.newBuilder() .expireAfterWrite(2, TimeUnit.SECONDS) .build(new CacheLoader<Long, AtomicLong>() { @Override public AtomicLong load(Long seconds) throws Exception { return new AtomicLong(0); } }); long limit = 1000; while(true) { //得到當前秒 long currentSeconds = System.currentTimeMillis() / 1000; if(counter.get(currentSeconds).incrementAndGet() > limit) { System.out.println("限流了:" + currentSeconds); continue; } //業務處理 }
=================================
我們使用Guava的Cache來存儲計數器,過期時間設置爲2秒(保證1秒內的計數器是有的),然後我們獲取當前時間戳然後取秒數來作爲KEY進行計數統計和限流,這種方式也是簡單粗暴,剛纔說的場景夠用了。
平滑限流某個接口的請求數
之前的限流方式都不能很好地應對突發請求,即瞬間請求可能都被允許從而導致一些問題;因此在一些場景中需要對突發請求進行整形,整形爲平均速率請求處理(比如5r/s,則每隔200毫秒處理一個請求,平滑了速率)。這個時候有兩種算法滿足我們的場景:令牌桶和漏桶算法。Guava框架提供了令牌桶算法實現,可直接拿來使用。
Guava RateLimiter提供了令牌桶算法實現:平滑突發限流(SmoothBursty)和平滑預熱限流(SmoothWarmingUp)實現。
SmoothBursty
=================================
RateLimiter limiter = RateLimiter.create(5); System.out.println(limiter.acquire()); System.out.println(limiter.acquire()); System.out.println(limiter.acquire()); System.out.println(limiter.acquire()); System.out.println(limiter.acquire()); System.out.println(limiter.acquire());
將得到類似如下的輸出:
0.0
0.198239
0.196083
0.200609
0.199599
0.19961
=================================
1、RateLimiter.create(5) 表示桶容量爲5且每秒新增5個令牌,即每隔200毫秒新增一個令牌;
2、limiter.acquire()表示消費一個令牌,如果當前桶中有足夠令牌則成功(返回值爲0),如果桶中沒有令牌則暫停一段時間,比如發令牌間隔是200毫秒,則等待200毫秒後再去消費令牌(如上測試用例返回的爲0.198239,差不多等待了200毫秒桶中才有令牌可用),這種實現將突發請求速率平均爲了固定請求速率。
再看一個突發示例:
=================================
RateLimiter limiter = RateLimiter.create(5);
System.out.println(limiter.acquire(5));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1))
將得到類似如下的輸出:
0.0
0.98745
0.183553
0.199909
=================================
limiter.acquire(5)表示桶的容量爲5且每秒新增5個令牌,令牌桶算法允許一定程度的突發,所以可以一次性消費5個令牌,但接下來的limiter.acquire(1)將等待差不多1秒桶中才能有令牌,且接下來的請求也整形爲固定速率了。
=================================
RateLimiter limiter = RateLimiter.create(5);
System.out.println(limiter.acquire(10));
System.out.println(limiter.acquire(1));
System.out.println(limiter.acquire(1));
將得到類似如下的輸出:
0.0
1.997428
0.192273
0.200616
=================================
同上邊的例子類似,第一秒突發了10個請求,令牌桶算法也允許了這種突發(允許消費未來的令牌),但接下來的limiter.acquire(1)將等待差不多2秒桶中才能有令牌,且接下來的請求也整形爲固定速率了。
接下來再看一個突發的例子:
=================================
RateLimiter limiter = RateLimiter.create(2);
System.out.println(limiter.acquire());
Thread.sleep(2000L);
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
System.out.println(limiter.acquire());
將得到類似如下的輸出:
0.0
0.0
0.0
0.0
0.499876
0.495799
=================================
1、創建了一個桶容量爲2且每秒新增2個令牌;
2、首先調用limiter.acquire()消費一個令牌,此時令牌桶可以滿足(返回值爲0);
3、然後線程暫停2秒,接下來的兩個limiter.acquire()都能消費到令牌,第三個limiter.acquire()也同樣消費到了令牌,到第四個時就需要等待500毫秒了。
此處可以看到我們設置的桶容量爲2(即允許的突發量),這是因爲SmoothBursty中有一個參數:最大突發秒數(maxBurstSeconds)默認值是1s,突發量/桶容量=速率*maxBurstSeconds,所以本示例桶容量/突發量爲2,例子中前兩個是消費了之前積攢的突發量,而第三個開始就是正常計算的了。令牌桶算法允許將一段時間內沒有消費的令牌暫存到令牌桶中,留待未來使用,並允許未來請求的這種突發。
SmoothBursty通過平均速率和最後一次新增令牌的時間計算出下次新增令牌的時間的,另外需要一個桶暫存一段時間內沒有使用的令牌(即可以突發的令牌數)。另外RateLimiter還提供了tryAcquire方法來進行無阻塞或可超時的令牌消費。
因爲SmoothBursty允許一定程度的突發,會有人擔心如果允許這種突發,假設突然間來了很大的流量,那麼系統很可能扛不住這種突發。因此需要一種平滑速率的限流工具,從而系統冷啓動後慢慢的趨於平均固定速率(即剛開始速率小一些,然後慢慢趨於我們設置的固定速率)。Guava也提供了SmoothWarmingUp來實現這種需求,其可以認爲是漏桶算法,但是在某些特殊場景又不太一樣。
SmoothWarmingUp創建方式:RateLimiter.create(doublepermitsPerSecond, long warmupPeriod, TimeUnit unit)
permitsPerSecond表示每秒新增的令牌數,warmupPeriod表示在從冷啓動速率過渡到平均速率的時間間隔。
示例如下:
=================================
RateLimiter limiter = RateLimiter.create(5, 1000, TimeUnit.MILLISECONDS);
for(int i = 1; i < 5;i++) {
System.out.println(limiter.acquire());
}
Thread.sleep(1000L);
for(int i = 1; i < 5;i++) {
System.out.println(limiter.acquire());
}
將得到類似如下的輸出:
0.0
0.51767
0.357814
0.219992
0.199984
0.0
0.360826
0.220166
0.199723
0.199555
=================================
速率是梯形上升速率的,也就是說冷啓動時會以一個比較大的速率慢慢到平均速率;然後趨於平均速率(梯形下降到平均速率)。可以通過調節warmupPeriod參數實現一開始就是平滑固定速率。
到此應用級限流的一些方法就介紹完了。假設將應用部署到多臺機器,應用級限流方式只是單應用內的請求限流,不能進行全侷限流。因此我們需要分佈式限流和接入層限流來解決這個問題。
分佈式限流
分佈式限流最關鍵的是要將限流服務做成原子化,而解決方案可以使使用redis+lua或者nginx+lua技術進行實現,通過這兩種技術可以實現的高併發和高性能。
首先我們來使用redis+lua實現時間窗內某個接口的請求數限流,實現了該功能後可以改造爲限流總併發/請求數和限制總資源數。Lua本身就是一種編程語言,也可以使用它實現複雜的令牌桶或漏桶算法。
redis+lua實現中的lua腳本:
=================================
local key = KEYS[1] --限流KEY(一秒一個)
local limit = tonumber(ARGV[1]) --限流大小
local current = tonumber(redis.call("INCRBY", key, "1")) --請求數+1
if current > limit then --如果超出限流大小
return 0
elseif current == 1 then --只有第一次訪問需要設置2秒的過期時間
redis.call("expire", key,"2")
end
return 1
=================================
如上操作因是在一個lua腳本中,又因Redis是單線程模型,因此是線程安全的。如上方式有一個缺點就是當達到限流大小後還是會遞增的,可以改造成如下方式實現:
=================================
local key = KEYS[1] --限流KEY(一秒一個)
local limit = tonumber(ARGV[1]) --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
return 0
else --請求數+1,並設置2秒過期
redis.call("INCRBY", key,"1")
redis.call("expire", key,"2")
return 1
end
=================================
如下是Java中判斷是否需要限流的代碼:
=================================
public static boolean acquire() throws Exception {
String luaScript = Files.toString(new File("limit.lua"), Charset.defaultCharset());
Jedis jedis = new Jedis("192.168.147.52", 6379);
String key = "ip:" + System.currentTimeMillis()/ 1000; //此處將當前時間戳取秒數
Stringlimit = "3"; //限流大小
return (Long)jedis.eval(luaScript,Lists.newArrayList(key), Lists.newArrayList(limit)) == 1;
}
=================================
因爲Redis的限制(Lua中有寫操作不能使用帶隨機性質的讀操作,如TIME)不能在Redis Lua中使用TIME獲取時間戳,因此只好從應用獲取然後傳入,在某些極端情況下(機器時鐘不準的情況下),限流會存在一些小問題。
使用Nginx+Lua實現的Lua腳本:
=================================
local locks = require "resty.lock"
local function acquire()
local lock =locks:new("locks")
local elapsed, err =lock:lock("limit_key") --互斥鎖
local limit_counter =ngx.shared.limit_counter --計數器
local key = "ip:" ..os.time()
local limit = 5 --限流大小
local current =limit_counter:get(key)
if current ~= nil and current + 1> limit then --如果超出限流大小
lock:unlock()
return 0
end
if current == nil then
limit_counter:set(key, 1, 1) --第一次需要設置過期時間,設置key的值爲1,過期時間爲1秒
else
limit_counter:incr(key, 1) --第二次開始加1即可
end
lock:unlock()
return 1
end
ngx.print(acquire())
=================================
實現中我們需要使用lua-resty-lock互斥鎖模塊來解決原子性問題(在實際工程中使用時請考慮獲取鎖的超時問題),並使用ngx.shared.DICT共享字典來實現計數器。如果需要限流則返回0,否則返回1。使用時需要先定義兩個共享字典(分別用來存放鎖和計數器數據):
=================================
http {
……
lua_shared_dict locks 10m;
lua_shared_dict limit_counter 10m;
}
=================================
有人會糾結如果應用併發量非常大那麼redis或者nginx是不是能抗得住;不過這個問題要從多方面考慮:你的流量是不是真的有這麼大,是不是可以通過一致性哈希將分佈式限流進行分片,是不是可以當併發量太大降級爲應用級限流;對策非常多,可以根據實際情況調節;像在京東使用Redis+Lua來限流搶購流量,一般流量是沒有問題的。
對於分佈式限流目前遇到的場景是業務上的限流,而不是流量入口的限流;流量入口限流應該在接入層完成,而接入層筆者一般使用Nginx。
參考資料
https://en.wikipedia.org/wiki/Token_bucket
https://en.wikipedia.org/wiki/Leaky_bucket
http://redis.io/commands/incr
http://nginx.org/en/docs/http/ngx_http_limit_req_module.html
http://nginx.org/en/docs/http/ngx_http_limit_conn_module.html
https://github.com/openresty/lua-resty-limit-traffic
http://nginx.org/en/docs/http/ngx_http_core_module.html#limit_rate
http://www.blogjava.net/stevenjohn/archive/2016/06/14/430882.html
http://www.mincoder.com/article/2943.shtml