請求限流

請求限流


開發高併發系統時,有三把利器用來保護系統:緩存、降級和限流

通過限流,我們可以很好地控制系統的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. 無法歸納出下一次請求對上一次請求的時間變化 這點是不合理的
  2. 由於原因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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章