請求限流
開發高併發系統時,有三把利器用來保護系統:緩存、降級和限流
通過限流,我們可以很好地控制系統的qps,從而達到保護系統的目的。
計數算法(不常用)
// 模擬的僞代碼
public static void main(String[] args) throws InterruptedException {
AtomicLong atomicLong = new AtomicLong(0L);
long interval = 1000;
long maxNums = 50;
String request = "/test/app";
long targetTime = System.currentTimeMillis();
int pass = 0;
int fail = 0;
for (int i = 0; i < 1000; i++) {
boolean response = getResponse(targetTime, interval, atomicLong, maxNums, request);
if (response) {
pass++;
} else {
fail++;
}
if (i == 200) {
Thread.sleep(1000);
}
}
System.out.println("Pass: " + pass + " fail: " + fail);
}
/***
* @param targetTime 請求時間
* @param interval 間隔時間
* @param atomicLong 計數器
* @param maxNums 最大請求量
* @param request 請求|模擬接口名
*
* 計數器算法 - 故名思意
* 通過一段時間內對指定接口進行計數,判斷是否超過最大限制
*/
private static boolean getResponse (long targetTime, long interval, AtomicLong atomicLong, long maxNums, String request) {
long now = System.currentTimeMillis();
if (targetTime <= now && now <= targetTime + interval) {
long index = atomicLong.getAndAdd(1);
return index <= maxNums;
} else {
atomicLong.set(0L);
}
return true;
}
計數算法的實現和我們想當然的結果一致,比如 15s - 16s 這個區間,我們想當然的以某一次請求爲起點,比如15s,則設置間隔爲1秒,通過判斷請求時間是否在 15 - 16 之間,如果在則累加,判斷是否超過最大限制,不在則數據重置
但它存在的致命的問題即:
- 無法歸納出下一次請求對上一次請求的時間變化 這點是不合理的
- 由於原因1的影響,用戶很有可能在 15.59999的極限請求100次,在16.00001的極限請求100次
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-kgsS8g8P-1570515353938)(限流篇.assets/4f9b2b97db30494a5b2f0a1da9560451)]
因此: 計數算法存在嚴重的臨界值問題,原因是由於計數依據 -> 時間導致的
滑動窗口(不常用)
滑動窗口的本質和計數一致,只不過通過把一個時間分區,即擴大精度,讓請求數量的計算更加合理
在上圖中,整個紅色的矩形框表示一個時間窗口,在我們的例子中,一個時間窗口就是一分鐘。然後我們將時間窗口進行劃分,比如圖中,我們就將滑動窗口 劃成了6格,所以每格代表的是10秒鐘。每過10秒鐘,我們的時間窗口就會往右滑動一格。每一個格子都有自己獨立的計數器counter,比如當一個請求 在0:35秒的時候到達,那麼0:30~0:39對應的counter就會加1。
那麼滑動窗口怎麼解決剛纔的臨界問題的呢?我們可以看上圖,0:59到達的100個請求會落在灰色的格子中,而1:00到達的請求會落在橘黃色的格 子中。當時間到達1:00時,我們的窗口會往右移動一格,那麼此時時間窗口內的總請求數量一共是200個,超過了限定的100個,所以此時能夠檢測出來觸發了限流
漏桶算法
漏桶算法有兩種實現:
一,不允許突發流量的情況,即以速率爲標準是否進行限流
二, 允許突發流量的情況,即以容量爲標準是否進行限流(
這樣請求其實在等待,因此處理速率是定值,一般不採用
)但無論啥哪種方式,漏水的速率是一定的,因此我們說 —》漏桶算法可以平滑網絡上的突發流量(對於突發處理效率一般)
// 模擬的僞代碼 -> 允許突發流量
public class LeakyBucket {
public static void main(String[] args) throws InterruptedException {
Integer pass = 0;
Integer fail = 0;
Bucket bucket = new Bucket();
bucket.start();
for (int i = 0; i < 100; i++) {
Integer num = Bucket.current;
if (num++ < Bucket.maxValue) {
Bucket.current++;
pass++;
} else {
fail++;
}
Thread.sleep(50);
}
System.out.println("Pass: " + pass + " fail: " + fail);
}
private static class Bucket extends Thread {
// 容量
private static Integer maxValue = 20;
// 速率 3次/s
private static Integer rate = 3;
// 當前量
private static Integer current = 0;
@Override
public void run() {
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (current > 0) {
current = Math.max(0, current -= rate);
}
}
}
}
}
令牌桶算法
和漏桶算法相反,令牌桶算法的本質是,有一個令牌的桶,以恆定的速率(變種算法也可以根據情況改變速率)往一個桶裏面丟令牌,如果可以獲取到令牌,則可執行,否則被限流等待
好處:可以很好的解決突發情況
輪子 -> Guava RateLimiter
<!-- guava庫 -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>27.0.1-jre</version>
</dependency>
// RateLimiter具有預消費的能力 -> 即可以一次性拿走超過當前最大令牌的數量,但是下次等待時間會額外增加
public class GuavaRateLimiter {
public static void main(String[] args){
// 線程池
ExecutorService exec = Executors.newCachedThreadPool();
// 速率是每秒只有3個許可
final RateLimiter rateLimiter = RateLimiter.create(3.0);
for (int i = 0; i < 100; i++) {
final int no = i;
Runnable runnable = () -> {
try {
//獲取許可
rateLimiter.acquire();
System.out.println("Accessing: " + no + ",time:" + new SimpleDateFormat("yy-MM-dd HH:mm:ss").format(new Date()));
} catch (Exception e) {
e.printStackTrace();
}
};
//執行線程
exec.execute(runnable);
}
//退出線程池
exec.shutdown();
}
}
根據令牌桶算法,桶中的令牌是持續生成存放的,有請求時需要先從桶中拿到令牌才能開始執行,誰來持續生成令牌存放呢?
一種解法是,開啓一個定時任務,由定時任務持續生成令牌。這樣的問題在於會極大的消耗系統資源,如,某接口需要分別對每個用戶做訪問頻率限制,假設系統中存在6W用戶,則至多需要開啓6W個定時任務來維持每個桶中的令牌數,這樣的開銷是巨大的。
另一種解法則是延遲計算,其實現思路爲,若當前時間晚於nextFreeTicketMicros,則計算該段時間內可以生成多少令牌,將生成的令牌加入令牌桶中並更新數據。這樣一來,只需要在獲取令牌時計算一次即可
分佈式限流
分佈式環境下限流方案
如nginx 採取 hash ip策略,則用單機方式可以
如輪詢策略,則可以借用第三方實現分佈式限流,如 redis -> 基本思路即利用 lua 腳本,通過原子性的方式獲取請求是否超過限制 lua腳本邏輯也很簡單,即利用redis的 過期設置key-value
-- Demo: 下標從 1 開始
local key = KEYS[1]
local now = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local expired = tonumber(ARGV[3])
-- 最大訪問量
local max = tonumber(ARGV[4])
-- 清除過期的數據
-- 移除指定分數區間內的所有元素,expired 即已經過期的 score
-- 根據當前時間毫秒數 - 超時毫秒數,得到過期時間 expired
redis.call('zremrangebyscore', key, 0, expired)
-- 獲取 zset 中的當前元素個數
local current = tonumber(redis.call('zcard', key))
local next = current + 1
if next > max then
-- 達到限流大小 返回 0
return 0;
else
-- 往 zset 中添加一個值、得分均爲當前時間戳的元素,[value,score]
redis.call("zadd", key, now, now)
-- 每次訪問均重新設置 zset 的過期時間,單位毫秒
redis.call("pexpire", key, ttl)
return next
end
// 調用方法Demo 僅僅舉例而已 對應上述lua腳本
private boolean shouldLimited(String key, long max, long timeout, TimeUnit timeUnit) {
// 最終的 key 格式爲:
// limit:自定義key:IP
// limit:類名.方法名:IP
key = REDIS_LIMIT_KEY_PREFIX + key;
// 統一使用單位毫秒
long ttl = timeUnit.toMillis(timeout);
// 當前時間毫秒數
long now = Instant.now().toEpochMilli();
long expired = now - ttl;
// 注意這裏必須轉爲 String,否則會報錯 java.lang.Long cannot be cast to java.lang.String
Long executeTimes = stringRedisTemplate.execute(limitRedisScript, Collections.singletonList(key), now + "", ttl + "", expired + "", max + "");
if (executeTimes != null) {
if (executeTimes == 0) {
log.error("【{}】在單位時間 {} 毫秒內已達到訪問上限,當前接口上限 {}", key, ttl, max);
return true;
} else {
log.info("【{}】在單位時間 {} 毫秒內訪問 {} 次", key, ttl, executeTimes);
return false;
}
}
return false;
}
拓展
在接口請求時候,我們可以用上述的算法控制限流,在代碼層,如批量生成excel文件等業務中,爲了避免同一時間文件產生過多導致IO,CPU飆增,也可以用限流的思路,通過JUC的信號量控制線程的數量,達到類似限流的目的
參考博文
開源中國:https://segmentfault.com/a/1190000012875897
簡書:https://www.jianshu.com/p/5d4fe4b2a726
掘金:https://juejin.im/post/5d8036a3e51d4561ff6668c3
GitHub:限流Demo
- https://github.com/xkcoding/spring-boot-demo?utm_source=gold_browser_extension
- https://github.com/kkzhilu/KerwinBoots/tree/boot_ratelimit_guava