引言
高併發的系統通常有三把利器:快取、降級和限流。
快取:快取是提高系統訪問速度,緩解CPU處理壓力的關鍵,同時可以提高系統的處理容量。
降級:降級是在突然的壓力劇增的情況,根據業務以及流量對一些服務和頁面的策略降級,以此釋放伺服器資源。
限流:限流是對於併發訪問/請求進行限速,或者一個時間視窗內限速保護系統,一旦到達限制速度可以拒絕服務、排隊或者等待。
限流演算法
令牌桶和和漏桶,比如Google的Guava的RateLimiter進行令牌痛控制。
漏桶演算法
漏桶演算法是把流量比作水,水先放在桶裡面並且以限定的速度出水,水過多會直接溢位,就會拒絕服務。
漏洞存在出水口、進水口,出水口以一定速率出水,並且有最大出水率。
在漏斗沒有水的時候:
- 進水的速率小於等於最大出水率,那麼出水速率等於進水速率,此時不會積水。
- 如果進水速率大於最大出水速率,那麼,漏斗以最大速率出水,此時,多餘的水會積在漏斗中。
如果漏斗有水的時候:
- 出水為最大速率。
- 如果漏斗未滿並且有進水,那麼這些水會積在漏斗。
- 如果漏斗已滿並且有進水,那麼水會溢位到漏斗外。
令牌桶演算法
對於很多應用場景來說,除了要求能夠限制資料的平均傳輸速率外,還要求允許某種程度的突發傳輸。這個時候使用令牌桶演算法比較合適。
令牌桶演算法以恆定的速率產生令牌,之後再把令牌放回到桶當中,令牌桶有一個容量,當令牌桶滿了的時候,再向其中放令牌會被直接丟棄,
RateLimiter 用法
https://github.com/google/guava
首先新增Maven依賴:
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
acquire(int permits)
函式主要用於獲取 permits
個令牌,並計算需要等待多長時間,進而掛起等待,並將該值返回。
import com.google.common.util.concurrent.ListeningExecutorService;
import com.google.common.util.concurrent.MoreExecutors;
import com.google.common.util.concurrent.RateLimiter;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.Executors;
public class Test {
public static void main(String[] args) {
ListeningExecutorService executorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(100));
// 指定每秒放1個令牌
RateLimiter limiter = RateLimiter.create(1);
for (int i = 1; i < 50; i++) {
// 請求RateLimiter, 超過permits會被阻塞
//acquire(int permits)函式主要用於獲取permits個令牌,並計算需要等待多長時間,進而掛起等待,並將該值返回
Double acquire = null;
if (i == 1) {
// `acquire 1` 時,並沒有任何等待 0.0 秒 直接預消費了1個令牌
acquire = limiter.acquire(1);
} else if (i == 2) {
// `acquire 10`時,由於之前預消費了 1 個令牌,故而等待了1秒,之後又預消費了10個令牌
acquire = limiter.acquire(10);
} else if (i == 3) {
// `acquire 2` 時,由於之前預消費了 10 個令牌,故而等待了10秒,之後又預消費了2個令牌
acquire = limiter.acquire(2);
} else if (i == 4) {
//`acquire 20` 時,由於之前預消費了 2 個令牌,故而等待了2秒,之後又預消費了20個令牌
acquire = limiter.acquire(20);
} else {
// `acquire 2` 時,由於之前預消費了 2 個令牌,故而等待了2秒,之後又預消費了2個令牌
acquire = limiter.acquire(2);
}
executorService.submit(new Task("獲取令牌成功,獲取耗:" + acquire + " 第 " + i + " 個任務執行"));
}
}
}
class Task implements Runnable {
String str;
public Task(String str) {
this.str = str;
}
@Override
public void run() {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");
System.out.println(sdf.format(new Date()) + " | " + Thread.currentThread().getName() + str);
}
}
一個RateLimiter主要定義了發放permits的速率。如果沒有額外的配置,permits將以固定的速度分配,單位是每秒多少permits。預設情況下,Permits將會被穩定的平緩的發放。
RateLimiter 的執行結果如下
2023-01-03 06:18:53.684 | pool-1-thread-1獲取令牌成功,獲取耗:0.0 第 1 個任務執行
2023-01-03 06:18:54.653 | pool-1-thread-2獲取令牌成功,獲取耗:0.985086 第 2 個任務執行
2023-01-03 06:19:04.640 | pool-1-thread-3獲取令牌成功,獲取耗:9.986942 第 3 個任務執行
2023-01-03 06:19:06.643 | pool-1-thread-4獲取令牌成功,獲取耗:2.000365 第 4 個任務執行
2023-01-03 06:19:26.641 | pool-1-thread-5獲取令牌成功,獲取耗:19.99702 第 5 個任務執行
2023-01-03 06:19:28.640 | pool-1-thread-6獲取令牌成功,獲取耗:1.999456 第 6 個任務執行
2023-01-03 06:19:30.651 | pool-1-thread-7獲取令牌成功,獲取耗:2.000317 第 7 個任務執行
2023-01-03 06:19:32.640 | pool-1-thread-8獲取令牌成功,獲取耗:1.988647 第 8 個任務執行
從上面的結果可以知道,令牌桶具備預消費能力。
`acquire 1` 時,並沒有任何等待 0.0 秒 直接預消費了1個令牌
`acquire 10`時,由於之前預消費了 1 個令牌,故而等待了1秒,之後又預消費了10個令牌
`acquire 2` 時,由於之前預消費了 10 個令牌,故而等待了10秒,之後又預消費了2個令牌
`acquire 20` 時,由於之前預消費了 2 個令牌,故而等待了2秒,之後又預消費了20個令牌
`acquire 2` 時,由於之前預消費了 20 個令牌,故而等待了20秒,之後又預消費了2個令牌
`acquire 2` 時,由於之前預消費了 2 個令牌,故而等待了2秒,之後又預消費了2個令牌
`acquire 2` 時 .....
預消費能力是一個類似前人挖坑,後人填坑的過程,從上面的執行結果來看,如果上一次請求獲取的permit
數越多,那麼下一次再獲取授權時更待的時候會更長,反之,如果上一次獲取的少,那麼時間向後推移的就少,下一次獲得許可的時間更短。
Redis 限流
基於Redis的setnx的操作
限流的主要目的就是為了在單位時間內,有且僅有N數量的請求能夠訪問我的程式碼程式,依靠setnx
可以輕鬆完成CAS操作,同時被獲取的相同Key設定過期時間(expire),比如10秒內限定20個請求,那麼我們在setnx的時候可以設定過期時間10,當請求的setnx數量達到20時候即達到了限流效果。
setnx
的操作的弊端是無法進行限流統計,如果需要統計10秒內獲取了多少個“桶”,需要在統計的過程中所有的桶都被持有。
基於Redis的資料結構zset
限流涉及的最主要的就是滑動視窗,上面也提到1-10怎麼變成2-11。其實也就是起始值和末端值都各+1即可。
zset陣列可以實現類似滑動陣列的方式,每次請求進入的時候,可以生成唯一的UUID作為value,score可以用當前時間戳表示,因為score我們可以用來計算當前時間戳之內有多少的請求數量,同時Zset的資料結構提供range方法可以獲取兩個時間戳範圍內有多少個請求。
具體實現程式碼如下:
public Response limitFlow(){
Long currentTime = new Date().getTime();
System.out.println(currentTime);
if(redisTemplate.hasKey("limit")) {
Integer count = redisTemplate.opsForZSet().rangeByScore("limit", currentTime - intervalTime, currentTime).size(); // intervalTime是限流的時間
System.out.println(count);
if (count != null && count > 5) {
return Response.ok("每分鐘最多隻能訪問5次");
}
}
redisTemplate.opsForZSet().add("limit",UUID.randomUUID().toString(),currentTime);
return Response.ok("訪問成功");
}
zset的實現方式也比較簡單,每N秒可以產生M個請求,缺點是zset會隨著構建資料不斷增長。
基於Redis的令牌桶演算法
我們根據前文介紹的令牌桶可以得知,當輸出的速率大於輸入的速率,會出現“溢位”的情況。guava透過acquire
方法掛起等待獲取令牌,這種方法雖然可以做到精確的流量控制,但是會出現“前人挖坑,後人埋坑”的情況,並且只能用於單JVM記憶體。
面對分散式專案,我們可以透過Redis的List結構進行改造,實現方式同樣非常簡單。
依靠List的leftPop來獲取令牌:
// 輸出令牌
public Response limitFlow2(Long id){
Object result = redisTemplate.opsForList().leftPop("limit_list");
if(result == null){
return Response.ok("當前令牌桶中無令牌");
}
return Response.ok(articleDescription2);
}
leftPop
語法:LPOP key [count]
移除並返回儲存在.key的列表中的第一個元素。預設情況下,該命令從列表的開頭彈出一個元素。
案例:
redis> RPUSH mylist "one" "two" "three" "four" "five"
(integer) 5
redis> LPOP mylist
"one"
redis> LPOP mylist 2
1) "two"
2) "three"
再依靠Java的定時任務,定時往List中rightPush令牌,為了保證分散式環境的強唯一性,可以使用redission生成唯一ID或者使用雪花演算法生成ID,這樣的結果更為靠譜。
上面程式碼的整合可以使用AOP或者Filter中加入限流程式碼即可。較為完美的方案是依靠Redis的限流,這樣可以做到部署多個JVM也可以進行正常工作。
如果是單JVM則使用UUID的結果即可:
// 10S的速率往令牌桶中新增UUID,只為保證唯一性 @Scheduled(fixedDelay = 10_000,initialDelay = 0) public void setIntervalTimeTask(){ redisTemplate.opsForList().rightPush("limit_list",UUID.randomUUID().toString()); }
令牌桶演算法VS漏桶演算法VSRedis限流
漏桶演算法的出水速度是恆定的,那麼意味如果出現大流量會把大部分請求同時丟棄(水溢位)。令牌桶的演算法也是恆定的,請求獲取令牌沒有限制,對於大流量可以短時間產生大量令牌,同樣獲取令牌的過程消耗不是很大。
Redis的限流不依賴JVM,是較為靠譜和推薦的方式,具體的實現可以依賴Redis本身的資料結構和Redis命令天然的原子性實現,唯一需要注意的是在具體程式語言接入的時候能否寫出具備執行緒安全的程式碼。
小結
注意本文介紹的限流演算法都是在JVM級別的限流,限流的令牌都是在記憶體中生成的,需要注意在分散式的環境下不能使用,如果要分散式限流,可以用redis限流。
使用guava限流是比較常見的,而Redis的限流是依賴中介軟體完成的,實現起來更為簡單也更推薦。