限流的6大狠招,附詳細程式碼和評測結果
為了上班方便,去年我把自己在北郊的房子租出去了,搬到了南郊,這樣離我上班的地方就近了,它為我節約了很多的時間成本,我可以用它來做很多有意義的事,最起碼不會因為堵車而鬧心了,幸福感直線上升。
但即使這樣,生活也有其他的煩惱。南郊的居住密度比較大,因此停車就成了頭痛的事,我租的是路兩邊的非固定車位,每次只要下班回來,一定是沒有車位停了,因此我只能和別人的車並排停著,但這樣帶來的問題是,我每天早上都要被挪車的電話給叫醒,心情自然就不用說了。
但後來幾天,我就慢慢變聰明了,我頭天晚上停車的時候,會找第二天限行的車並排停著,這樣我第二天就不用挪車了,這真是限行給我帶來的「巨大紅利」啊。
而車輛限行就是一種生活中很常見的限流策略,他除了給我帶來了以上的好處之外,還給我們美好的生活環境帶來了一絲改善,並且快速增長的私家車已經給我們的交通帶來了巨大的「負擔」,如果再不限行,可能所有的車都要被堵在路上,這就是限流給我們的生活帶來的巨大好處。
從生活回到程式中,假設一個系統只能為 10W 人提供服務,突然有一天因為某個熱點事件,造成了系統短時間內的訪問量迅速增加到了 50W,那麼導致的直接結果是系統崩潰,任何人都不能用系統了,顯然只有少人數能用遠比所有人都不能用更符合我們的預期,因此這個時候我們要使用「限流」了。
限流分類
限流的實現方案有很多種,磊哥這裡稍微理了一下,限流的分類如下所示:
- 合法性驗證限流:比如驗證碼、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 框架的引用,配置如下:
<!-- //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 引用,配置如下:
<!-- //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),但前提是能滿足你的業務需求。
好了,文章到這裡就結束了,期待我們下期再會~
最後的話
原創不易,如果覺得本文對你有用,請隨手點擊一個「贊」,這是對作者最大的支援與鼓勵,謝謝你!
參考 & 鳴謝
//www.cnblogs.com/biglittleant/p/8979915.html