5種限流演算法,7種限流方式,擋住突發流量?

程式猿阿朗發表於2022-03-15

大家好啊,我是阿朗,最近工作中需要用到限流,這篇文章介紹常見的限流方式。

文章持續更新,可以關注公眾號程式猿阿朗或訪問未讀程式碼部落格
本文 Github.com/niumoo/JavaNotes 已經收錄,歡迎Star。

前言

最近幾年,隨著微服務的流行,服務和服務之間的依賴越來越強,呼叫關係越來越複雜,服務和服務之間的穩定性越來越重要。在遇到突發的請求量激增,惡意的使用者訪問,亦或請求頻率過高給下游服務帶來較大壓力時,我們常常需要通過快取、限流、熔斷降級、負載均衡等多種方式保證服務的穩定性。其中限流是不可或缺的一環,這篇文章介紹限流相關知識。

1. 限流

限流顧名思義,就是對請求或併發數進行限制;通過對一個時間視窗內的請求量進行限制來保障系統的正常執行。如果我們的服務資源有限、處理能力有限,就需要對呼叫我們服務的上游請求進行限制,以防止自身服務由於資源耗盡而停止服務。

在限流中有兩個概念需要了解。

  • 閾值:在一個單位時間內允許的請求量。如 QPS 限制為10,說明 1 秒內最多接受 10 次請求。
  • 拒絕策略:超過閾值的請求的拒絕策略,常見的拒絕策略有直接拒絕、排隊等待等。

2. 固定視窗演算法

固定視窗演算法又叫計數器演算法,是一種簡單方便的限流演算法。主要通過一個支援原子操作的計數器來累計 1 秒內的請求次數,當 1 秒內計數達到限流閾值時觸發拒絕策略。每過 1 秒,計數器重置為 0 開始重新計數。

2.1. 程式碼實現

下面是簡單的程式碼實現,QPS 限制為 2,這裡的程式碼做了一些優化,並沒有單獨開一個執行緒去每隔 1 秒重置計數器,而是在每次呼叫時進行時間間隔計算來確定是否先重置計數器。

/**
 * @author https://www.wdbyte.com
 */
public class RateLimiterSimpleWindow {
    // 閾值
    private static Integer QPS = 2;
    // 時間視窗(毫秒)
    private static long TIME_WINDOWS = 1000;
    // 計數器
    private static AtomicInteger REQ_COUNT = new AtomicInteger();
    
    private static long START_TIME = System.currentTimeMillis();

    public synchronized static boolean tryAcquire() {
        if ((System.currentTimeMillis() - START_TIME) > TIME_WINDOWS) {
            REQ_COUNT.set(0);
            START_TIME = System.currentTimeMillis();
        }
        return REQ_COUNT.incrementAndGet() <= QPS;
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            Thread.sleep(250);
            LocalTime now = LocalTime.now();
            if (!tryAcquire()) {
                System.out.println(now + " 被限流");
            } else {
                System.out.println(now + " 做點什麼");
            }
        }
    }
}

執行結果:

20:53:43.038922 做點什麼
20:53:43.291435 做點什麼
20:53:43.543087 被限流
20:53:43.796666 做點什麼
20:53:44.050855 做點什麼
20:53:44.303547 被限流
20:53:44.555008 被限流
20:53:44.809083 做點什麼
20:53:45.063828 做點什麼
20:53:45.314433 被限流

從輸出結果中可以看到大概每秒操作 3 次,由於限制 QPS 為 2,所以平均會有一次被限流。看起來可以了,不過我們思考一下就會發現這種簡單的限流方式是有問題的,雖然我們限制了 QPS 為 2,但是當遇到時間視窗的臨界突變時,如 1s 中的後 500 ms 和第 2s 的前 500ms 時,雖然是加起來是 1s 時間,卻可以被請求 4 次。

固定視窗演算法

簡單修改測試程式碼,可以進行驗證:

// 先休眠 400ms,可以更快的到達時間視窗。
Thread.sleep(400);
for (int i = 0; i < 10; i++) {
    Thread.sleep(250);
    if (!tryAcquire()) {
        System.out.println("被限流");
    } else {
        System.out.println("做點什麼");
    }
}

得到輸出中可以看到連續 4 次請求,間隔 250 ms 沒有卻被限制。:

20:51:17.395087 做點什麼
20:51:17.653114 做點什麼
20:51:17.903543 做點什麼
20:51:18.154104 被限流
20:51:18.405497 做點什麼
20:51:18.655885 做點什麼
20:51:18.906177 做點什麼
20:51:19.158113 被限流
20:51:19.410512 做點什麼
20:51:19.661629 做點什麼

3. 滑動視窗演算法

我們已經知道固定視窗演算法的實現方式以及它所存在的問題,而滑動視窗演算法是對固定視窗演算法的改進。既然固定視窗演算法在遇到時間視窗的臨界突變時會有問題,那麼我們在遇到下一個時間視窗前也調整時間視窗不就可以了嗎?

下面是滑動視窗的示意圖。

滑動視窗演算法

上圖的示例中,每 500ms 滑動一次視窗,可以發現視窗滑動的間隔越短,時間視窗的臨界突變問題發生的概率也就越小,不過只要有時間視窗的存在,還是有可能發生時間視窗的臨界突變問題

3.1. 程式碼實現

下面是基於以上滑動視窗思路實現的簡單的滑動視窗限流工具類。

package com.wdbyte.rate.limiter;

import java.time.LocalTime;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 滑動視窗限流工具類
 *
 * @author https://www.wdbyte.com
 */
public class RateLimiterSlidingWindow {
    /**
     * 閾值
     */
    private int qps = 2;
    /**
     * 時間視窗總大小(毫秒)
     */
    private long windowSize = 1000;
    /**
     * 多少個子視窗
     */
    private Integer windowCount = 10;
    /**
     * 視窗列表
     */
    private WindowInfo[] windowArray = new WindowInfo[windowCount];

    public RateLimiterSlidingWindow(int qps) {
        this.qps = qps;
        long currentTimeMillis = System.currentTimeMillis();
        for (int i = 0; i < windowArray.length; i++) {
            windowArray[i] = new WindowInfo(currentTimeMillis, new AtomicInteger(0));
        }
    }

    /**
     * 1. 計算當前時間視窗
     * 2. 更新當前視窗計數 & 重置過期視窗計數
     * 3. 當前 QPS 是否超過限制
     *
     * @return
     */
    public synchronized boolean tryAcquire() {
        long currentTimeMillis = System.currentTimeMillis();
        // 1. 計算當前時間視窗
        int currentIndex = (int)(currentTimeMillis % windowSize / (windowSize / windowCount));
        // 2.  更新當前視窗計數 & 重置過期視窗計數
        int sum = 0;
        for (int i = 0; i < windowArray.length; i++) {
            WindowInfo windowInfo = windowArray[i];
            if ((currentTimeMillis - windowInfo.getTime()) > windowSize) {
                windowInfo.getNumber().set(0);
                windowInfo.setTime(currentTimeMillis);
            }
            if (currentIndex == i && windowInfo.getNumber().get() < qps) {
                windowInfo.getNumber().incrementAndGet();
            }
            sum = sum + windowInfo.getNumber().get();
        }
        // 3. 當前 QPS 是否超過限制
        return sum <= qps;
    }

    private class WindowInfo {
        // 視窗開始時間
        private Long time;
        // 計數器
        private AtomicInteger number;

        public WindowInfo(long time, AtomicInteger number) {
            this.time = time;
            this.number = number;
        }
        // get...set...
    }
}

下面是測試用例,設定 QPS 為 2,測試次數 20 次,每次間隔 300 毫秒,預計成功次數在 12 次左右。

public static void main(String[] args) throws InterruptedException {
    int qps = 2, count = 20, sleep = 300, success = count * sleep / 1000 * qps;
    System.out.println(String.format("當前QPS限制為:%d,當前測試次數:%d,間隔:%dms,預計成功次數:%d", qps, count, sleep, success));
    success = 0;
    RateLimiterSlidingWindow myRateLimiter = new RateLimiterSlidingWindow(qps);
    for (int i = 0; i < count; i++) {
        Thread.sleep(sleep);
        if (myRateLimiter.tryAcquire()) {
            success++;
            if (success % qps == 0) {
                System.out.println(LocalTime.now() + ": success, ");
            } else {
                System.out.print(LocalTime.now() + ": success, ");
            }
        } else {
            System.out.println(LocalTime.now() + ": fail");
        }
    }
    System.out.println();
    System.out.println("實際測試成功次數:" + success);
}

下面是測試的結果。

當前QPS限制為:2,當前測試次數:20,間隔:300ms,預計成功次數:12
16:04:27.077782: success, 16:04:27.380715: success, 
16:04:27.684244: fail
16:04:27.989579: success, 16:04:28.293347: success, 
16:04:28.597658: fail
16:04:28.901688: fail
16:04:29.205262: success, 16:04:29.507117: success, 
16:04:29.812188: fail
16:04:30.115316: fail
16:04:30.420596: success, 16:04:30.725897: success, 
16:04:31.028599: fail
16:04:31.331047: fail
16:04:31.634127: success, 16:04:31.939411: success, 
16:04:32.242380: fail
16:04:32.547626: fail
16:04:32.847965: success, 
實際測試成功次數:11

4. 滑動日誌演算法

滑動日誌演算法是實現限流的另一種方法,這種方法比較簡單。基本邏輯就是記錄下所有的請求時間點,新請求到來時先判斷最近指定時間範圍內的請求數量是否超過指定閾值,由此來確定是否達到限流,這種方式沒有了時間視窗突變的問題,限流比較準確,但是因為要記錄下每次請求的時間點,所以佔用的記憶體較多

4.1. 程式碼實現

下面是簡單實現的 一個滑動日誌演算法,因為滑動日誌要每次請求單獨儲存一條記錄,可能佔用記憶體過多。所以下面這個實現其實不算嚴謹的滑動日誌,更像一個把 1 秒時間切分成 1000 個時間視窗的滑動視窗演算法。

package com.wdbyte.rate.limiter;

import java.time.LocalTime;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeMap;

/**
 * 滑動日誌方式限流
 * 設定 QPS 為 2.
 *
 * @author https://www.wdbyte.com
 */
public class RateLimiterSildingLog {

    /**
     * 閾值
     */
    private Integer qps = 2;
    /**
     * 記錄請求的時間戳,和數量
     */
    private TreeMap<Long, Long> treeMap = new TreeMap<>();

    /**
     * 清理請求記錄間隔, 60 秒
     */
    private long claerTime = 60 * 1000;

    public RateLimiterSildingLog(Integer qps) {
        this.qps = qps;
    }

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        // 清理過期的資料老資料,最長 60 秒清理一次
        if (!treeMap.isEmpty() && (treeMap.firstKey() - now) > claerTime) {
            Set<Long> keySet = new HashSet<>(treeMap.subMap(0L, now - 1000).keySet());
            for (Long key : keySet) {
                treeMap.remove(key);
            }
        }
        // 計算當前請求次數
        int sum = 0;
        for (Long value : treeMap.subMap(now - 1000, now).values()) {
            sum += value;
        }
        // 超過QPS限制,直接返回 false
        if (sum + 1 > qps) {
            return false;
        }
        // 記錄本次請求
        if (treeMap.containsKey(now)) {
            treeMap.compute(now, (k, v) -> v + 1);
        } else {
            treeMap.put(now, 1L);
        }
        return sum <= qps;
    }

    public static void main(String[] args) throws InterruptedException {
        RateLimiterSildingLog rateLimiterSildingLog = new RateLimiterSildingLog(3);
        for (int i = 0; i < 10; i++) {
            Thread.sleep(250);
            LocalTime now = LocalTime.now();
            if (rateLimiterSildingLog.tryAcquire()) {
                System.out.println(now + " 做點什麼");
            } else {
                System.out.println(now + " 被限流");
            }
        }
    }
}

程式碼中把閾值 QPS 設定為 3,執行可以得到如下日誌:

20:51:17.395087 做點什麼
20:51:17.653114 做點什麼
20:51:17.903543 做點什麼
20:51:18.154104 被限流
20:51:18.405497 做點什麼
20:51:18.655885 做點什麼
20:51:18.906177 做點什麼
20:51:19.158113 被限流
20:51:19.410512 做點什麼
20:51:19.661629 做點什麼

5. 漏桶演算法

漏桶演算法中的漏桶是一個形象的比喻,這裡可以用生產者消費者模式進行說明,請求是一個生產者,每一個請求都如一滴水,請求到來後放到一個佇列(漏桶)中,而桶底有一個孔,不斷的漏出水滴,就如消費者不斷的在消費佇列中的內容,消費的速率(漏出的速度)等於限流閾值。即假如 QPS 為 2,則每 1s / 2= 500ms 消費一次。漏桶的桶有大小,就如佇列的容量,當請求堆積超過指定容量時,會觸發拒絕策略。

下面是漏桶演算法的示意圖。

漏桶演算法

由介紹可以知道,漏桶模式中的消費處理總是能以恆定的速度進行,可以很好的保護自身系統不被突如其來的流量沖垮;但是這也是漏桶模式的缺點,假設 QPS 為 2,同時 2 個請求進來,2 個請求並不能同時進行處理響應,因為每 1s / 2= 500ms 只能處理一個請求。

6. 令牌桶演算法

令牌桶演算法同樣是實現限流是一種常見的思路,最為常用的 Google 的 Java 開發工具包 Guava 中的限流工具類 RateLimiter 就是令牌桶的一個實現。令牌桶的實現思路類似於生產者和消費之間的關係。

系統服務作為生產者,按照指定頻率向桶(容器)中新增令牌,如 QPS 為 2,每 500ms 向桶中新增一個令牌,如果桶中令牌數量達到閾值,則不再新增。

請求執行作為消費者,每個請求都需要去桶中拿取一個令牌,取到令牌則繼續執行;如果桶中無令牌可取,就觸發拒絕策略,可以是超時等待,也可以是直接拒絕本次請求,由此達到限流目的。

下面是令牌桶限流演算法示意圖。

令牌桶演算法

思考令牌桶的實現可以以下特點。

  1. 1s / 閾值(QPS) = 令牌新增時間間隔。
  2. 桶的容量等於限流的閾值,令牌數量達到閾值時,不再新增。
  3. 可以適應流量突發,N 個請求到來只需要從桶中獲取 N 個令牌就可以繼續處理。
  4. 有啟動過程,令牌桶啟動時桶中無令牌,然後按照令牌新增時間間隔新增令牌,若啟動時就有閾值數量的請求過來,會因為桶中沒有足夠的令牌而觸發拒絕策略,不過如 RateLimiter 限流工具已經優化了這類問題。

6.1. 程式碼實現

Google 的 Java 開發工具包 Guava 中的限流工具類 RateLimiter 就是令牌桶的一個實現,日常開發中我們也不會手動實現了,這裡直接使用 RateLimiter 進行測試。

引入依賴:

<exclusion>
      <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
      <version>31.0.1-jre</version>
</exclusion>

RateLimiter 限流體驗:

// qps 2
RateLimiter rateLimiter = RateLimiter.create(2);
for (int i = 0; i < 10; i++) {
    String time = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_TIME);
    System.out.println(time + ":" + rateLimiter.tryAcquire());
    Thread.sleep(250);
}

程式碼中限制 QPS 為 2,也就是每隔 500ms 生成一個令牌,但是程式每隔 250ms 獲取一次令牌,所以兩次獲取中只有一次會成功。

17:19:06.797557:true
17:19:07.061419:false
17:19:07.316283:true
17:19:07.566746:false
17:19:07.817035:true
17:19:08.072483:false
17:19:08.326347:true
17:19:08.577661:false
17:19:08.830252:true
17:19:09.085327:false

6.2. 思考

雖然演示了 Google Guava 工具包中的 RateLimiter 的實現,但是我們需要思考一個問題,就是令牌的新增方式,如果按照指定間隔新增令牌,那麼需要開一個執行緒去定時新增,如果有很多個介面很多個 RateLimiter 例項,執行緒數會隨之增加,這顯然不是一個好的辦法。顯然 Google 也考慮到了這個問題,在 RateLimiter 中,是在每次令牌獲取時才進行計算令牌是否足夠的。它通過儲存的下一個令牌生成的時間,和當前獲取令牌的時間差,再結合閾值,去計算令牌是否足夠,同時再記錄下一個令牌的生成時間以便下一次呼叫。

下面是 Guava 中 RateLimiter 類的子類 SmoothRateLimiter 的 resync() 方法的程式碼分析,可以看到其中的令牌計算邏輯。

void resync(long nowMicros) { // 當前微秒時間
    // 當前時間是否大於下一個令牌生成時間
    if (nowMicros > this.nextFreeTicketMicros) { 
          // 可生成的令牌數 newPermits = (當前時間 - 下一個令牌生成時間)/ 令牌生成時間間隔。
          // 如果 QPS 為2,這裡的 coolDownIntervalMicros 就是 500000.0 微秒(500ms)
        double newPermits = (double)(nowMicros - this.nextFreeTicketMicros) / this.coolDownIntervalMicros();
                // 更新令牌庫存 storedPermits。
          this.storedPermits = Math.min(this.maxPermits, this.storedPermits + newPermits);
                // 更新下一個令牌生成時間 nextFreeTicketMicros
          this.nextFreeTicketMicros = nowMicros;
    }
}

7. Redis 分散式限流

Redis 是一個開源的記憶體資料庫,可以用來作為資料庫、快取、訊息中介軟體等。Redis 是單執行緒的,又在記憶體中操作,所以速度極快,得益於 Redis 的各種特性,所以使用 Redis 實現一個限流工具是十分方便的。

下面的演示都基於Spring Boot 專案,並需要以下依賴。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

配置 Redis 資訊。

spring:
  redis:
    database: 0
    password: 
    port: 6379
    host: 127.0.0.1
    lettuce:
      shutdown-timeout: 100ms
      pool:
        min-idle: 5
        max-idle: 10
        max-active: 8
        max-wait: 1ms

7.1. 固定視窗限流

Redis 中的固定視窗限流是使用 incr 命令實現的,incr 命令通常用來自增計數;如果我們使用時間戳資訊作為 key,自然就可以統計每秒的請求量了,以此達到限流目的。

這裡有兩點要注意。

  1. 對於不存在的 key,第一次新增時,value 始終為 1。
  2. INCR 和 EXPIRE 命令操作應該在一個原子操作中提交,以保證每個 key 都正確設定了過期時間,不然會有 key 值無法自動刪除而導致的記憶體溢位。

由於 Redis 中實現事務的複雜性,所以這裡直接只用 lua 指令碼來實現原子操作。下面是 lua 指令碼內容。

local count = redis.call("incr",KEYS[1])
if count == 1 then
  redis.call('expire',KEYS[1],ARGV[2])
end
if count > tonumber(ARGV[1]) then
  return 0
end
return 1

下面是使用 Spring Boot 中 RedisTemplate 來實現的 lua 指令碼呼叫測試程式碼。

/**
 * @author https://www.wdbyte.com
 */
@SpringBootTest
class RedisLuaLimiterByIncr {
    private static String KEY_PREFIX = "limiter_";
    private static String QPS = "4";
    private static String EXPIRE_TIME = "1";

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    public void redisLuaLimiterTests() throws InterruptedException, IOException {
        for (int i = 0; i < 15; i++) {
            Thread.sleep(200);
            System.out.println(LocalTime.now() + " " + acquire("user1"));
        }
    }

    /**
     * 計數器限流
     *
     * @param key
     * @return
     */
    public boolean acquire(String key) {
        // 當前秒數作為 key
        key = KEY_PREFIX + key + System.currentTimeMillis() / 1000;
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setResultType(Long.class);
        //lua檔案存放在resources目錄下
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("limiter.lua")));
        return stringRedisTemplate.execute(redisScript, Arrays.asList(key), QPS, EXPIRE_TIME) == 1;
    }
}

程式碼中雖然限制了 QPS 為 4,但是因為這種限流實現是把毫秒時間戳作為 key 的,所以會有臨界視窗突變的問題,下面是執行結果,可以看到因為時間視窗的變化,導致了 QPS 超過了限制值 4。

17:38:23.122044 true
17:38:23.695124 true
17:38:23.903220 true
# 此處有時間視窗變化,所以下面繼續 true
17:38:24.106206 true
17:38:24.313458 true
17:38:24.519431 true
17:38:24.724446 true
17:38:24.932387 false
17:38:25.137912 true
17:38:25.355595 true
17:38:25.558219 true
17:38:25.765801 true
17:38:25.969426 false
17:38:26.176220 true
17:38:26.381918 true

7.3. 滑動視窗限流

通過對上面的基於 incr 命令實現的 Redis 限流方式的測試,我們已經發現了固定視窗限流所帶來的問題,在這篇文章的第三部分已經介紹了滑動視窗限流的優勢,它可以大幅度降低因為視窗臨界突變帶來的問題,那麼如何使用 Redis 來實現滑動視窗限流呢?

這裡主要使用 ZSET 有序集合來實現滑動視窗限流,ZSET 集合有下面幾個特點:

  1. ZSET 集合中的 key 值可以自動排序。
  2. ZSET 集合中的 value 不能有重複值。
  3. ZSET 集合可以方便的使用 ZCARD 命令獲取元素個數。
  4. ZSET 集合可以方便的使用 ZREMRANGEBYLEX 命令移除指定範圍的 key 值。

基於上面的四點特性,可以編寫出基於 ZSET 的滑動視窗限流 lua 指令碼。

--KEYS[1]: 限流 key
--ARGV[1]: 時間戳 - 時間視窗
--ARGV[2]: 當前時間戳(作為score)
--ARGV[3]: 閾值
--ARGV[4]: score 對應的唯一value
-- 1. 移除時間視窗之前的資料
redis.call('zremrangeByScore', KEYS[1], 0, ARGV[1])
-- 2. 統計當前元素數量
local res = redis.call('zcard', KEYS[1])
-- 3. 是否超過閾值
if (res == nil) or (res < tonumber(ARGV[3])) then
    redis.call('zadd', KEYS[1], ARGV[2], ARGV[4])
    return 1
else
    return 0
end

下面是使用 Spring Boot 中 RedisTemplate 來實現的 lua 指令碼呼叫測試程式碼。

@SpringBootTest
class RedisLuaLimiterByZset {

    private String KEY_PREFIX = "limiter_";
    private String QPS = "4";

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Test
    public void redisLuaLimiterTests() throws InterruptedException, IOException {
        for (int i = 0; i < 15; i++) {
            Thread.sleep(200);
            System.out.println(LocalTime.now() + " " + acquire("user1"));
        }
    }

    /**
     * 計數器限流
     *
     * @param key
     * @return
     */
    public boolean acquire(String key) {
        long now = System.currentTimeMillis();
        key = KEY_PREFIX + key;
        String oldest = String.valueOf(now - 1_000);
        String score = String.valueOf(now);
        String scoreValue = score;
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setResultType(Long.class);
        //lua檔案存放在resources目錄下
        redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("limiter2.lua")));
        return stringRedisTemplate.execute(redisScript, Arrays.asList(key), oldest, score, QPS, scoreValue) == 1;
    }
}

程式碼中限制 QPS 為 4,執行結果資訊與之一致。

17:36:37.150370 true
17:36:37.716341 true
17:36:37.922577 true
17:36:38.127497 true
17:36:38.335879 true
17:36:38.539225 false
17:36:38.745903 true
17:36:38.952491 true
17:36:39.159497 true
17:36:39.365239 true
17:36:39.570572 false
17:36:39.776635 true
17:36:39.982022 true
17:36:40.185614 true
17:36:40.389469 true

這裡介紹了 Redis 實現限流的兩種方式,當然使用 Redis 也可以實現漏桶和令牌桶兩種限流演算法,這裡就不做演示了,感興趣的可以自己研究下。

8. 總結

這篇文章介紹實現限流的幾種方式,主要是視窗演算法和桶演算法,兩者各有優勢。

  • 視窗演算法實現簡單,邏輯清晰,可以很直觀的得到當前的 QPS 情況,但是會有時間視窗的臨界突變問題,而且不像桶一樣有佇列可以緩衝。
  • 桶演算法雖然稍微複雜,不好統計 QPS 情況,但是桶演算法也有優勢所在。

    • 漏桶模式消費速率恆定,可以很好的保護自身系統,可以對流量進行整形,但是面對突發流量不能快速響應。
    • 令牌桶模式可以面對突發流量,但是啟動時會有緩慢加速的過程,不過常見的開源工具中已經對此優化。

單機限流與分散式限流

上面演示的基於程式碼形式的視窗演算法和桶演算法限流都適用於單機限流,如果需要分散式限流可以結合註冊中心、負載均衡計算每個服務的限流閾值,但這樣會降低一定精度,如果對精度要求不是太高,可以使用。

而 Redis 的限流,由於 Redis 的單機性,本身就可以用於分散式限流。使用 Redis 可以實現各種可以用於限流演算法,如果覺得麻煩也可以使用開源工具如 redisson,已經封裝了基於 Redis 的限流。

其他限流工具

文中已經提到了 Guava 的限流工具包,不過它畢竟是單機的,開源社群中也有很多分散式限流工具,如阿里開源的 Sentinel 就是不錯的工具,Sentinel 以流量為切入點,從流量控制、熔斷降級、系統負載保護等多個維度保護服務的穩定性。

一如既往,文章中的程式碼存放在:github.com/niumoo/JavaNotes

參考

Redis INCR:https://redis.io/commands/incr

Rate Limiting Wikipedia:https://en.wikipedia.org/wiki/Rate_limiting

SpringBoot Redis:https://www.cnblogs.com/lenve/p/10965667.html

訂閱

可以微信搜一搜程式猿阿朗或訪問未讀程式碼部落格閱讀。
本文 Github.com/niumoo/JavaNotes 已經收錄,歡迎Star。

相關文章