主頁 > 後端開發 > Spring Cloud Gateway 限流實戰,終于有人寫清楚了!

Spring Cloud Gateway 限流實戰,終于有人寫清楚了!

2021-09-26 18:20:33 後端開發

話說在 Spring Cloud Gateway 問世之前,Spring Cloud 的微服務世界里,網關一定非 Netflix Zuul 莫屬,但是由于 Zuul 1.x 存在的一些問題,比如阻塞式的 API,不支持 WebSocket 等,一直被人所詬病,而且 Zuul 升級新版本依賴于 Netflix 公司,經過幾次跳票之后,Spring 開源社區決定推出自己的網關組件,替代 Netflix Zuul,

從 18 年 6 月 Spring Cloud 發布的 Finchley 版本開始,Spring Cloud Gateway 逐漸嶄露頭角,它基于 Spring 5.0、Spring Boot 2.0 和 Project Reactor 等技術開發,不僅支持回應式和無阻塞式的 API,而且支持 WebSocket,和 Spring 框架緊密集成,盡管 Zuul 后來也推出了 2.x 版本,在底層使用了異步無阻塞式的 API,大大改善了其性能,但是目前看來 Spring 并沒有打算繼續集成它的計劃,

根據官網的描述,Spring Cloud Gateway 的主要特性如下:

  • Built on Spring Framework 5, Project Reactor and Spring Boot 2.0
  • Able to match routes on any request attribute
  • Predicates and filters are specific to routes
  • Hystrix Circuit Breaker integration
  • Spring Cloud DiscoveryClient integration
  • Easy to write Predicates and Filters
  • Request Rate Limiting
  • Path Rewriting

可以看出 Spring Cloud Gateway 可以很方便的和 Spring Cloud 生態中的其他組件進行集成(比如:斷路器和服務發現),而且提供了一套簡單易寫的 斷言Predicates,有的地方也翻譯成 謂詞)和 過濾器Filters)機制,可以對每個 路由Routes)進行特殊請求處理,

最近在專案中使用了 Spring Cloud Gateway,并在它的基礎上實作了一些高級特性,如限流和留痕,在網關的使用程序中遇到了不少的挑戰,于是趁著專案結束,抽點時間系統地學習并總結下,這篇文章主要學習限流技術,首先我會介紹一些常見的限流場景和限流演算法,然后介紹一些關于限流的開源專案,學習別人是如何實作限流的,最后介紹我是如何在網關中實作限流的,并分享一些實作程序中的經驗和遇到的坑,

一、常見的限流場景

快取降級限流 被稱為高并發、分布式系統的三駕馬車,網關作為整個分布式系統中的第一道關卡,限流功能自然必不可少,通過限流,可以控制服務請求的速率,從而提高系統應對突發大流量的能力,讓系統更具彈性,限流有著很多實際的應用場景,比如雙十一的秒殺活動, 12306 的搶票等,

1.1 限流的物件

通過上面的介紹,我們對限流的概念可能感徑訓是比較模糊,到底限流限的是什么?顧名思義,限流就是限制流量,但這里的流量是一個比較籠統的概念,如果考慮各種不同的場景,限流是非常復雜的,而且和具體的業務規則密切相關,可以考慮如下幾種常見的場景:

  • 限制某個介面一分鐘內最多請求 100 次
  • 限制某個用戶的下載速度最多 100KB/S
  • 限制某個用戶同時只能對某個介面發起 5 路請求
  • 限制某個 IP 來源禁止訪問任何請求

從上面的例子可以看出,根據不同的請求者和請求資源,可以組合出不同的限流規則,可以根據請求者的 IP 來進行限流,或者根據請求對應的用戶來限流,又或者根據某個特定的請求引數來限流,而限流的物件可以是請求的頻率,傳輸的速率,或者并發量等,其中最常見的兩個限流物件是請求頻率和并發量,他們對應的限流被稱為 請求頻率限流(Request rate limiting)和 并發量限流(Concurrent requests limiting),傳輸速率限流 在下載場景下比較常用,比如一些資源下載站會限制普通用戶的下載速度,只有購買會員才能提速,這種限流的做法實際上和請求頻率限流類似,只不過一個限制的是請求量的多少,一個限制的是請求資料報文的大小,這篇文章主要介紹請求頻率限流和并發量限流,

1.2 限流的處理方式

在系統中設計限流方案時,有一個問題值得設計者去仔細考慮,當請求者被限流規則攔截之后,我們該如何回傳結果,一般我們有下面三種限流的處理方式:

  • 拒絕服務
  • 排隊等待
  • 服務降級

最簡單的做法是拒絕服務,直接拋出例外,回傳錯誤資訊(比如回傳 HTTP 狀態碼 429 Too Many Requests),或者給前端回傳 302 重定向到一個錯誤頁面,提示用戶資源沒有了或稍后再試,但是對于一些比較重要的介面不能直接拒絕,比如秒殺、下單等介面,我們既不希望用戶請求太快,也不希望請求失敗,這種情況一般會將請求放到一個訊息佇列中排隊等待,訊息佇列可以起到削峰和限流的作用,第三種處理方式是服務降級,當觸發限流條件時,直接回傳兜底資料,比如查詢商品庫存的介面,可以默認回傳有貨,

1.3 限流的架構

針對不同的系統架構,需要使用不同的限流方案,如下圖所示,服務部署的方式一般可以分為單機模式和集群模式:

單機模式的限流非常簡單,可以直接基于記憶體就可以實作,而集群模式的限流必須依賴于某個“中心化”的組件,比如網關或 Redis,從而引出兩種不同的限流架構:網關層限流中間件限流

網關作為整個分布式系統的入口,承擔了所有的用戶請求,所以在網關中進行限流是最合適不過的,網關層限流有時也被稱為 接入層限流,除了我們使用的 Spring Cloud Gateway,最常用的網關層組件還有 Nginx,可以通過它的 ngx_http_limit_req_module 模塊,使用 limit_conn_zone、limit_req_zone、limit_rate 等指令很容易的實作并發量限流、請求頻率限流和傳輸速率限流,這里不對 Nginx 作過多的說明,關于這幾個指令的詳細資訊可以 參考 Nginx 的官方檔案,

另一種限流架構是中間件限流,可以將限流的邏輯下沉到服務層,但是集群中的每個服務必須將自己的流量資訊統一匯總到某個地方供其他服務讀取,一般來說用 Redis 的比較多,Redis 提供的過期特性和 lua 腳本執行非常適合做限流,除了 Redis 這種中間件,還有很多類似的分布式快取系統都可以使用,如 Hazelcast、Apache Ignite、Infinispan 等,

我們可以更進一步擴展上面的架構,將網關改為集群模式,雖然這還是網關層限流架構,但是由于網關變成了集群模式,所以網關必須依賴于中間件進行限流,這和上面討論的中間件限流沒有區別,

二、常見的限流演算法

通過上面的學習,我們知道限流可以分為請求頻率限流和并發量限流,根據系統架構的不同,又可以分為網關層限流和分布式限流,在不同的應用場景下,我們需要采用不同的限流演算法,這一節將介紹一些主流的限流演算法,

有一點要注意的是,利用池化技術也可以達到限流的目的,比如執行緒池或連接池,但這不是本文的重點,

2.1 固定視窗演算法(Fixed Window)

固定視窗演算法是一種最簡單的限流演算法,它根據限流的條件,將請求時間映射到一個時間視窗,再使用計數器累加訪問次數,譬如限流條件為每分鐘 5 次,那么就按照分鐘為單位映射時間視窗,假設一個請求時間為 11:00:45,時間視窗就是 11:00:00 ~ 11:00:59,在這個時間視窗內設定一個計數器,每來一個請求計數器加一,當這個時間視窗的計數器超過 5 時,就觸發限流條件,當請求時間落在下一個時間視窗內時(11:01:00 ~ 11:01:59),上一個視窗的計數器失效,當前的計數器清零,重新開始計數,

計數器演算法非常容易實作,在單機場景下可以使用 AtomicLong、LongAdder 或 Semaphore 來實作計數,而在分布式場景下可以通過 Redis 的 INCR 和 EXPIRE 等命令并結合 EVAL 或 lua 腳本來實作,Redis 官網提供了幾種簡單的實作方式,無論是請求頻率限流還是并發量限流都可以使用這個演算法,

不過這個演算法的缺陷也比較明顯,那就是存在嚴重的臨界問題,由于每過一個時間視窗,計數器就會清零,這使得限流效果不夠平滑,惡意用戶可以利用這個特點繞過我們的限流規則,如下圖所示,我們的限流條件本來是每分鐘 5 次,但是惡意用戶在 11:00:00 ~ 11:00:59 這個時間視窗的后半分鐘發起 5 次請求,接下來又在 11:01:00 ~ 11:01:59 這個時間視窗的前半分鐘發起 5 次請求,這樣我們的系統就在 1 分鐘內承受了 10 次請求,

2.2 滑動視窗演算法(Rolling Window 或 Sliding Window)

為了解決固定視窗演算法的臨界問題,可以將時間視窗劃分成更小的時間視窗,然后隨著時間的滑動洗掉相應的小視窗,而不是直接滑過一個大視窗,這就是滑動視窗演算法,我們為每個小時間視窗都設定一個計數器,大時間視窗的總請求次數就是每個小時間視窗的計數器的和,如下圖所示,我們的時間視窗是 5 秒,可以按秒進行劃分,將其劃分成 5 個小視窗,時間每過一秒,時間視窗就滑過一秒:

每次處理請求時,都需要計算所有小時間視窗的計數器的和,考慮到性能問題,劃分的小時間視窗不宜過多,譬如限流條件是每小時 N 個,可以按分鐘劃分為 60 個視窗,而不是按秒劃分成 3600 個,當然如果不考慮性能問題,劃分粒度越細,限流效果就越平滑,相反,如果劃分粒度越粗,限流效果就越不精確,出現臨界問題的可能性也就越大,當劃分粒度為 1 時,滑動視窗演算法就退化成了固定視窗演算法,由于這兩種演算法都使用了計數器,所以也被稱為 計數器演算法(Counters),

進一步思考我們發現,如果劃分粒度最粗,也就是只有一個時間視窗時,滑動視窗演算法退化成了固定視窗演算法;那如果我們把劃分粒度調到最細,又會如何呢?那么怎樣才能讓劃分的時間視窗最細呢?時間視窗細到一定地步時,意味著每個時間視窗中只能容納一個請求,這樣我們可以省略計數器,只記錄每個請求的時間,然后統計一段時間內的請求數有多少個即可,具體的實作可以參考Redis sorted set 技巧 和Sliding window log 演算法,

2.3 漏桶演算法(Leaky Bucket)

除了計數器演算法,另一個很自然的限流思路是將所有的請求快取到一個佇列中,然后按某個固定的速度慢慢處理,這其實就是漏桶演算法(Leaky Bucket),漏桶演算法假設將請求裝到一個桶中,桶的容量為 M,當桶滿時,請求被丟棄,在桶的底部有一個洞,桶中的請求像水一樣按固定的速度(每秒 r 個)漏出來,我們用下面這個形象的圖來表示漏桶演算法:

桶的上面是個水龍頭,我們的請求從水龍頭流到桶中,水龍頭流出的水速不定,有時快有時慢,這種忽快忽慢的流量叫做 Bursty flow,如果桶中的水滿了,多余的水就會溢位去,相當于請求被丟棄,從桶底部漏出的水速是固定不變的,可以看出漏桶演算法可以平滑請求的速率,

漏桶演算法可以通過一個佇列來實作,如下圖所示:

當請求到達時,不直接處理請求,而是將其放入一個佇列,然后另一個執行緒以固定的速率從佇列中讀取請求并處理,從而達到限流的目的,注意的是這個佇列可以有不同的實作方式,比如設定請求的存活時間,或將佇列改造成 PriorityQueue,根據請求的優先級排序而不是先進先出,當然佇列也有滿的時候,如果佇列已經滿了,那么請求只能被丟棄了,漏桶演算法有一個缺陷,在處理突發流量時效率很低,于是人們又想出了下面的令牌桶演算法,

2.4 令牌桶演算法(Token Bucket)

令牌桶演算法(Token Bucket)是目前應用最廣泛的一種限流演算法,它的基本思想由兩部分組成:生成令牌消費令牌

  • 生成令牌:假設有一個裝令牌的桶,最多能裝 M 個,然后按某個固定的速度(每秒 r 個)往桶中放入令牌,桶滿時不再放入;
  • 消費令牌:我們的每次請求都需要從桶中拿一個令牌才能放行,當桶中沒有令牌時即觸發限流,這時可以將請求放入一個緩沖佇列中排隊等待,或者直接拒絕;

令牌桶演算法的圖示如下:

在上面的圖中,我們將請求放在一個緩沖佇列中,可以看出這一部分的邏輯和漏桶演算法幾乎一模一樣,只不過在處理請求上,一個是以固定速率處理,一個是從桶中獲取令牌后才處理,

仔細思考就會發現,令牌桶演算法有一個很關鍵的問題,就是桶大小的設定,正是這個引數可以讓令牌桶演算法具備處理突發流量的能力,譬如將桶大小設定為 100,生成令牌的速度設定為每秒 10 個,那么在系統空閑一段時間的之后(桶中令牌一直沒有消費,慢慢的會被裝滿),突然來了 50 個請求,這時系統可以直接按每秒 50 個的速度處理,隨著桶中的令牌很快用完,處理速度又會慢慢降下來,和生成令牌速度趨于一致,這是令牌桶演算法和漏桶演算法最大的區別,漏桶演算法無論來了多少請求,只會一直以每秒 10 個的速度進行處理,當然,處理突發流量雖然提高了系統性能,但也給系統帶來了一定的壓力,如果桶大小設定不合理,突發的大流量可能會直接壓垮系統,

通過上面對令牌桶的原理分析,一般會有兩種不同的實作方式,第一種方式是啟動一個內部執行緒,不斷的往桶中添加令牌,處理請求時從桶中獲取令牌,和上面圖中的處理邏輯一樣,第二種方式不依賴于內部執行緒,而是在每次處理請求之前先實時計算出要填充的令牌數并填充,然后再從桶中獲取令牌,下面是第二種方式的一種經典實作,其中 capacity 表示令牌桶大小,refillTokensPerOneMillis 表示填充速度,每毫秒填充多少個,availableTokens 表示令牌桶中還剩多少個令牌,lastRefillTimestamp 表示上一次填充時間,

 1public class TokenBucket {
 2
 3    private final long capacity;
 4    private final double refillTokensPerOneMillis;
 5    private double availableTokens;
 6    private long lastRefillTimestamp;
 7
 8    public TokenBucket(long capacity, long refillTokens, long refillPeriodMillis) {
 9        this.capacity = capacity;
10        this.refillTokensPerOneMillis = (double) refillTokens / (double) refillPeriodMillis;
11        this.availableTokens = capacity;
12        this.lastRefillTimestamp = System.currentTimeMillis();
13    }
14
15    synchronized public boolean tryConsume(int numberTokens) {
16        refill();
17        if (availableTokens < numberTokens) {
18            return false;
19        } else {
20            availableTokens -= numberTokens;
21            return true;
22        }
23    }
24
25    private void refill() {
26        long currentTimeMillis = System.currentTimeMillis();
27        if (currentTimeMillis > lastRefillTimestamp) {
28            long millisSinceLastRefill = currentTimeMillis - lastRefillTimestamp;
29            double refill = millisSinceLastRefill * refillTokensPerOneMillis;
30            this.availableTokens = Math.min(capacity, availableTokens + refill);
31            this.lastRefillTimestamp = currentTimeMillis;
32        }
33    }
34}

可以像下面這樣創建一個令牌桶(桶大小為 100,且每秒生成 100 個令牌):

1TokenBucket limiter = new TokenBucket(100, 100, 1000);

從上面的代碼片段可以看出,令牌桶演算法的實作非常簡單也非常高效,僅僅通過幾個變數的運算就實作了完整的限流功能,核心邏輯在于 refill() 這個方法,在每次消費令牌時,計算當前時間和上一次填充的時間差,并根據填充速度計算出應該填充多少令牌,在重新填充令牌后,再判斷請求的令牌數是否足夠,如果不夠,回傳 false,如果足夠,則減去令牌數,并回傳 true,

在實際的應用中,往往不會直接使用這種原始的令牌桶演算法,一般會在它的基礎上作一些改進,比如,填充速率支持動態調整,令牌總數支持透支,基于 Redis 支持分布式限流等,不過總體來說還是符合令牌桶演算法的整體框架,我們在后面學習一些開源專案時對此會有更深的體會,

三、一些開源專案

有很多開源專案中都實作了限流的功能,這一節通過一些開源專案的學習,了解限流是如何實作的,

3.1 Guava 的 RateLimiter

Google Guava 是一個強大的核心庫,包含了很多有用的工具類,例如:集合、快取、并發庫、字串處理、I/O 等等,其中在并發庫中,Guava 提供了兩個和限流相關的類:RateLimiter 和 SmoothRateLimiter,Guava 的 RateLimiter 基于令牌桶演算法實作,不過在傳統的令牌桶演算法基礎上做了點改進,支持兩種不同的限流方式:平滑突發限流(SmoothBursty) 和 平滑預熱限流(SmoothWarmingUp),

下面的方法可以創建一個平滑突發限流器(SmoothBursty):

1RateLimiter limiter = RateLimiter.create(5);

RateLimiter.create(5) 表示這個限流器容量為 5,并且每秒生成 5 個令牌,也就是每隔 200 毫秒生成一個,我們可以使用 limiter.acquire() 消費令牌,如果桶中令牌足夠,回傳 0,如果令牌不足,則阻塞等待,并回傳等待的時間,我們連續請求幾次:

1System.out.println(limiter.acquire());
2System.out.println(limiter.acquire());
3System.out.println(limiter.acquire());
4System.out.println(limiter.acquire());

輸出結果如下:

10.0
20.198239
30.196083
40.200609

可以看出限流器創建之后,初始會有一個令牌,然后每隔 200 毫秒生成一個令牌,所以第一次請求直接回傳 0,后面的請求都會阻塞大約 200 毫秒,另外,SmoothBursty 還具有應對突發的能力,而且 還允許消費未來的令牌,比如下面的例子:

1RateLimiter limiter = RateLimiter.create(5);
2System.out.println(limiter.acquire(10));
3System.out.println(limiter.acquire(1));
4System.out.println(limiter.acquire(1));

會得到類似下面的輸出:

10.0
21.997428
30.192273
40.200616

限流器創建之后,初始令牌只有一個,但是我們請求 10 個令牌竟然也通過了,只不過看后面請求發現,第二次請求花了 2 秒左右的時間把前面的透支的令牌給補上了,

Guava 支持的另一種限流方式是平滑預熱限流器(SmoothWarmingUp),可以通過下面的方法創建:

1RateLimiter limiter = RateLimiter.create(2, 3, TimeUnit.SECONDS);
2System.out.println(limiter.acquire(1));
3System.out.println(limiter.acquire(1));
4System.out.println(limiter.acquire(1));
5System.out.println(limiter.acquire(1));
6System.out.println(limiter.acquire(1));

第一個引數還是每秒創建的令牌數量,這里是每秒 2 個,也就是每 500 毫秒生成一個,后面的引數表示從冷啟動速率過渡到平均速率的時間間隔,也就是所謂的熱身時間間隔(warm up period),我們看下輸出結果:

10.0
21.329289
30.994375
40.662888
50.501287

第一個請求還是立即得到令牌,但是后面的請求和上面平滑突發限流就完全不一樣了,按理來說 500 毫秒就會生成一個令牌,但是我們發現第二個請求卻等了 1.3s,而不是 0.5s,后面第三個和第四個請求也等了一段時間,不過可以看出,等待時間在慢慢的接近 0.5s,直到第五個請求等待時間才開始變得正常,從第一個請求到第五個請求,這中間的時間間隔就是熱身階段,可以算出熱身的時間就是我們設定的 3 秒,

3.2 Bucket4j

Bucket4j是一個基于令牌桶演算法實作的強大的限流庫,它不僅支持單機限流,還支持通過諸如 Hazelcast、Ignite、Coherence、Infinispan 或其他兼容 JCache API (JSR 107) 規范的分布式快取實作分布式限流,

在使用 Bucket4j 之前,我們有必要先了解 Bucket4j 中的幾個核心概念:

  • Bucket
  • Bandwidth
  • Refill

Bucket 介面代表了令牌桶的具體實作,也是我們操作的入口,它提供了諸如 tryConsume 和 tryConsumeAndReturnRemaining 這樣的方法供我們消費令牌,可以通過下面的構造方法來創建Bucket:

1Bucket bucket = Bucket4j.builder().addLimit(limit).build();
2if(bucket.tryConsume(1)) {
3    System.out.println("ok");
4} else {
5    System.out.println("error");
6}

Bandwidth 的意思是帶寬, 可以理解為限流的規則,Bucket4j 提供了兩種方法來創建 Bandwidth:simple 和 classic,下面是 simple 方式創建的 Bandwidth,表示桶大小為 10,填充速度為每分鐘 10 個令牌:

1Bandwidth limit = Bandwidth.simple(10, Duration.ofMinutes(1));

simple方式桶大小和填充速度是一樣的,classic 方式更靈活一點,可以自定義填充速度,下面的例子表示桶大小為 10,填充速度為每分鐘 5 個令牌:

1Refill filler = Refill.greedy(5, Duration.ofMinutes(1));
2Bandwidth limit = Bandwidth.classic(10, filler);

其中,Refill 用于填充令牌桶,可以通過它定義填充速度,Bucket4j 有兩種填充令牌的策略:間隔策略(intervally) 和 貪婪策略(greedy),在上面的例子中我們使用的是貪婪策略,如果使用間隔策略可以像下面這樣創建 Refill:

1Refill filler = Refill.intervally(5, Duration.ofMinutes(1));

所謂間隔策略指的是每隔一段時間,一次性的填充所有令牌,比如上面的例子,會每隔一分鐘,填充 5 個令牌,如下所示:

而貪婪策略會盡可能貪婪的填充令牌,同樣是上面的例子,會將一分鐘劃分成 5 個更小的時間單元,每隔 12 秒,填充 1 個令牌,如下所示:

在了解了 Bucket4j 中的幾個核心概念之后,我們再來看看官網介紹的一些特性:

  • 基于令牌桶演算法
  • 高性能,無鎖實作
  • 不存在精度問題,所有計算都是基于整型的
  • 支持通過符合 JCache API 規范的分布式快取系統實作分布式限流
  • 支持為每個 Bucket 設定多個 Bandwidth
  • 支持同步和異步 API
  • 支持可插拔的監聽 API,用于集成監控和日志
  • 不僅可以用于限流,還可以用于簡單的調度

Bucket4j 提供了豐富的檔案,推薦在使用 Bucket4j 之前,先把官方檔案中的 基本用法 和 高級特性 仔細閱讀一遍,另外,關于 Bucket4j 的使用,推薦這篇文章 Rate limiting Spring MVC endpoints with bucket4j,這篇文章詳細的講解了如何在 Spring MVC 中使用攔截器和 Bucket4j 打造業務無侵入的限流方案,另外還講解了如何使用 Hazelcast 實作分布式限流;另外,Rate Limiting a Spring API Using Bucket4j 這篇文章也是一份很好的入門教程,介紹了 Bucket4j 的基礎知識,在文章的最后還提供了 Spring Boot Starter 的集成方式,結合 Spring Boot Actuator 很容易將限流指標集成到監控系統中,

和 Guava 的限流器相比,Bucket4j 的功能顯然要更勝一籌,畢竟 Guava 的目的只是用作通用工具類,而不是用于限流的,使用 Bucket4j 基本上可以滿足我們的大多數要求,不僅支持單機限流和分布式限流,而且可以很好的集成監控,搭配 Prometheus 和 Grafana 簡直完美,值得一提的是,有很多開源專案譬如 JHipster API Gateway 就是使用 Bucket4j 來實作限流的,

Bucket4j 唯一不足的地方是它只支持請求頻率限流,不支持并發量限流,另外還有一點,雖然 Bucket4j 支持分布式限流,但它是基于 Hazelcast 這樣的分布式快取系統實作的,不能使用 Redis,這在很多使用 Redis 作快取的專案中就很不爽,所以我們還需要在開源的世界里繼續探索,

3.3 Resilience4j

Resilience4j 是一款輕量級、易使用的高可用框架,用過 Spring Cloud 早期版本的同學肯定都聽過 Netflix Hystrix,Resilience4j 的設計靈感就來自于它,自從 Hystrix 停止維護之后,官方也推薦大家使用 Resilience4j 來代替 Hystrix,

Resilience4j 的底層采用 Vavr,這是一個非常輕量級的 Java 函式式庫,使得 Resilience4j 非常適合函式式編程,Resilience4j 以裝飾器模式提供對函式式介面或 lambda 運算式的封裝,提供了一波高可用機制:重試(Retry)熔斷(Circuit Breaker)限流(Rate Limiter)限時(Timer Limiter)隔離(Bulkhead)快取(Caceh)降級(Fallback),我們重點關注這里的兩個功能:限流(Rate Limiter) 和 隔離(Bulkhead),Rate Limiter 是請求頻率限流,Bulkhead 是并發量限流,

Resilience4j 提供了兩種限流的實作:SemaphoreBasedRateLimiterAtomicRateLimiterSemaphoreBasedRateLimiter 基于信號量實作,用戶的每次請求都會申請一個信號量,并記錄申請的時間,申請通過則允許請求,申請失敗則限流,另外有一個內部執行緒會定期掃描過期的信號量并釋放,很顯然這是令牌桶的演算法,AtomicRateLimiter 和上面的經典實作類似,不需要額外的執行緒,在處理每次請求時,根據距離上次請求的時間和生成令牌的速度自動填充,關于這二者的區別可以參考文章 Rate Limiter Internals in Resilience4j,

Resilience4j 也提供了兩種隔離的實作:SemaphoreBulkheadThreadPoolBulkhead,通過信號量或執行緒池控制請求的并發數,具體的用法參考官方檔案,這里不再贅述,

下面是一個同時使用限流和隔離的例子:

 1// 創建一個 Bulkhead,最大并發量為 150
 2BulkheadConfig bulkheadConfig = BulkheadConfig.custom()
 3    .maxConcurrentCalls(150)
 4    .maxWaitTime(100)
 5    .build();
 6Bulkhead bulkhead = Bulkhead.of("backendName", bulkheadConfig);
 7
 8// 創建一個 RateLimiter,每秒允許一次請求
 9RateLimiterConfig rateLimiterConfig = RateLimiterConfig.custom()
10    .timeoutDuration(Duration.ofMillis(100))
11    .limitRefreshPeriod(Duration.ofSeconds(1))
12    .limitForPeriod(1)
13    .build();
14RateLimiter rateLimiter = RateLimiter.of("backendName", rateLimiterConfig);
15
16// 使用 Bulkhead 和 RateLimiter 裝飾業務邏輯
17Supplier<String> supplier = () -> backendService.doSomething();
18Supplier<String> decoratedSupplier = Decorators.ofSupplier(supplier)
19  .withBulkhead(bulkhead)
20  .withRateLimiter(rateLimiter)
21  .decorate();
22
23// 呼叫業務邏輯
24Try<String> try = Try.ofSupplier(decoratedSupplier);
25assertThat(try.isSuccess()).isTrue();

Resilience4j 在功能特性上比 Bucket4j 強大不少,而且還支持并發量限流,不過最大的遺憾是,Resilience4j 不支持分布式限流,

3.4 其他

網上還有很多限流相關的開源專案,不可能一一介紹,這里列出來的只是冰山之一角:

  • https://github.com/mokies/ratelimitj
  • https://github.com/wangzheng0822/ratelimiter4j
  • https://github.com/wukq/rate-limiter
  • https://github.com/marcosbarbero/spring-cloud-zuul-ratelimit
  • https://github.com/onblog/SnowJena
  • https://gitee.com/zhanghaiyang/spring-boot-starter-current-limiting
  • https://github.com/Netflix/concurrency-limits

可以看出,限流技術在實際專案中應用非常廣泛,大家對實作自己的限流演算法樂此不疲,新演算法和新實作層出不窮,但是找來找去,目前還沒有找到一款開源專案完全滿足我的需求,

我的需求其實很簡單,需要同時滿足兩種不同的限流場景:請求頻率限流和并發量限流,并且能同時滿足兩種不同的限流架構:單機限流和分布式限流,下面我們就開始在 Spring Cloud Gateway 中實作這幾種限流,通過前面介紹的那些專案,我們取長補短,基本上都能用比較成熟的技術實作,只不過對于最后一種情況,分布式并發量限流,網上沒有搜到現成的解決方案,在和同事討論了幾個晚上之后,想出一種新型的基于雙視窗滑動的限流演算法,我在這里拋磚引玉,歡迎大家批評指正,如果大家有更好的方法,也歡迎討論,

四、在網關中實作限流

在文章一開始介紹 Spring Cloud Gateway 的特性時,我們注意到其中有一條 Request Rate Limiting,說明網關自帶了限流的功能,但是 Spring Cloud Gateway 自帶的限流有很多限制,譬如不支持單機限流,不支持并發量限流,而且它的請求頻率限流也是不盡人意,這些都需要我們自己動手來解決,

4.1 實作單機請求頻率限流

Spring Cloud Gateway 中定義了關于限流的一個介面 RateLimiter,如下:

1public interface RateLimiter<C> extends StatefulConfigurable<C> {
2    Mono<RateLimiter.Response> isAllowed(String routeId, String id);
3}

這個介面就一個方法 isAllowed,第一個引數 routeId 表示請求路由的 ID,根據 routeId 可以獲取限流相關的配置,第二個引數 id 表示要限流的物件的唯一標識,可以是用戶名,也可以是 IP,或者其他的可以從 ServerWebExchange 中得到的資訊,我們看下 RequestRateLimiterGatewayFilterFactory 中對 isAllowed 的呼叫邏輯:

 1@Override
 2public GatewayFilter apply(Config config) {
 3    // 從配置中得到 KeyResolver
 4    KeyResolver resolver = getOrDefault(config.keyResolver, defaultKeyResolver);
 5    // 從配置中得到 RateLimiter
 6    RateLimiter<Object> limiter = getOrDefault(config.rateLimiter,
 7            defaultRateLimiter);
 8    boolean denyEmpty = getOrDefault(config.denyEmptyKey, this.denyEmptyKey);
 9    HttpStatusHolder emptyKeyStatus = HttpStatusHolder
10            .parse(getOrDefault(config.emptyKeyStatus, this.emptyKeyStatusCode));
11
12    return (exchange, chain) -> resolver.resolve(exchange).defaultIfEmpty(EMPTY_KEY)
13            .flatMap(key -> {
14                // 通過 KeyResolver 得到 key,作為唯一標識 id 傳入 isAllowed() 方法
15                if (EMPTY_KEY.equals(key)) {
16                    if (denyEmpty) {
17                        setResponseStatus(exchange, emptyKeyStatus);
18                        return exchange.getResponse().setComplete();
19                    }
20                    return chain.filter(exchange);
21                }
22                // 獲取當前路由 ID,作為 routeId 引數傳入 isAllowed() 方法
23                String routeId = config.getRouteId();
24                if (routeId == null) {
25                    Route route = exchange
26                            .getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
27                    routeId = route.getId();
28                }
29                return limiter.isAllowed(routeId, key).flatMap(response -> {
30
31                    for (Map.Entry<String, String> header : response.getHeaders()
32                            .entrySet()) {
33                        exchange.getResponse().getHeaders().add(header.getKey(),
34                                header.getValue());
35                    }
36                    // 請求允許,直接走到下一個 filter
37                    if (response.isAllowed()) {
38                        return chain.filter(exchange);
39                    }
40                    // 請求被限流,回傳設定的 HTTP 狀態碼(默認是 429)
41                    setResponseStatus(exchange, config.getStatusCode());
42                    return exchange.getResponse().setComplete();
43                });
44            });
45}

從上面的的邏輯可以看出,通過實作 KeyResolver 介面的 resolve 方法就可以自定義要限流的物件了,

1public interface KeyResolver {
2    Mono<String> resolve(ServerWebExchange exchange);
3}

比如下面的 HostAddrKeyResolver 可以根據 IP 來限流:

 1public interface KeyResolver {
 2    Mono<String> resolve(ServerWebExchange exchange);
 3}
 4比如下面的 HostAddrKeyResolver 可以根據 IP 來限流:
 5public class HostAddrKeyResolver implements KeyResolver {
 6    @Override
 7    public Mono<String> resolve(ServerWebExchange exchange) {
 8        return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
 9    }
10}

我們繼續看 Spring Cloud Gateway 的代碼發現,RateLimiter 介面只提供了一個實作類 RedisRateLimiter:

很顯然是基于 Redis 實作的限流,雖說通過 Redis 也可以實作單機限流,但是總感覺有些大材小用,而且對于那些沒有 Redis 的環境很不友好,所以,我們要實作真正的本地限流,

我們從 Spring Cloud Gateway 的 pull request 中發現了一個新特性 Feature/local-rate-limiter,而且看提交記錄,這個新特性很有可能會合并到 3.0.0 版本中,我們不妨來看下這個 local-rate-limiter 的實作:LocalRateLimiter.java,可以看出它是基于 Resilience4有意思的是,這個類 還有一個早期版本,是基于 Bucket4j 實作的:

 1public Mono<Response> isAllowed(String routeId, String id) {
 2    Config routeConfig = loadConfiguration(routeId);
 3
 4    // How many requests per second do you want a user to be allowed to do?
 5    int replenishRate = routeConfig.getReplenishRate();
 6
 7    // How many seconds for a token refresh?
 8    int refreshPeriod = routeConfig.getRefreshPeriod();
 9
10    // How many tokens are requested per request?
11    int requestedTokens = routeConfig.getRequestedTokens();
12
13    final io.github.resilience4j.ratelimiter.RateLimiter rateLimiter = RateLimiterRegistry
14            .ofDefaults()
15            .rateLimiter(id, createRateLimiterConfig(refreshPeriod, replenishRate));
16
17    final boolean allowed = rateLimiter.acquirePermission(requestedTokens);
18    final Long tokensLeft = (long) rateLimiter.getMetrics().getAvailablePermissions();
19
20    Response response = new Response(allowed, getHeaders(routeConfig, tokensLeft));
21    return Mono.just(response);
22}

有意思的是,這個類 還有一個早期版本,是基于 Bucket4j 實作的:

 1public Mono<Response> isAllowed(String routeId, String id) {
 2
 3    Config routeConfig = loadConfiguration(routeId);
 4
 5    // How many requests per second do you want a user to be allowed to do?
 6    int replenishRate = routeConfig.getReplenishRate();
 7
 8    // How much bursting do you want to allow?
 9    int burstCapacity = routeConfig.getBurstCapacity();
10
11    // How many tokens are requested per request?
12    int requestedTokens = routeConfig.getRequestedTokens();
13
14    final Bucket bucket = bucketMap.computeIfAbsent(id,
15            (key) -> createBucket(replenishRate, burstCapacity));
16
17    final boolean allowed = bucket.tryConsume(requestedTokens);
18
19    Response response = new Response(allowed,
20            getHeaders(routeConfig, bucket.getAvailableTokens()));
21    return Mono.just(response);
22}

實作方式都是類似的,在上面對 Bucket4j 和 Resilience4j 已經作了比較詳細的介紹,這里不再贅述,不過從這里也可以看出 Spring 生態圈對 Resilience4j 是比較看好的,我們也可以將其引入到我們的專案中,

4.2 實作分布式請求頻率限流

上面介紹了如何實作單機請求頻率限流,接下來再看下分布式請求頻率限流,這個就比較簡單了,因為上面說了,Spring Cloud Gateway 自帶了一個限流實作,就是 RedisRateLimiter,可以用于分布式限流,它的實作原理依然是基于令牌桶演算法的,不過實作邏輯是放在一段 lua 腳本中的,我們可以在 src/main/resources/META-INF/scripts 目錄下找到該腳本檔案 request_rate_limiter.lua:

 1local tokens_key = KEYS[1]
 2local timestamp_key = KEYS[2]
 3
 4local rate = tonumber(ARGV[1])
 5local capacity = tonumber(ARGV[2])
 6local now = tonumber(ARGV[3])
 7local requested = tonumber(ARGV[4])
 8
 9local fill_time = capacity/rate
10local ttl = math.floor(fill_time*2)
11
12local last_tokens = tonumber(redis.call("get", tokens_key))
13if last_tokens == nil then
14  last_tokens = capacity
15end
16
17local last_refreshed = tonumber(redis.call("get", timestamp_key))
18if last_refreshed == nil then
19  last_refreshed = 0
20end
21
22local delta = math.max(0, now-last_refreshed)
23local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
24local allowed = filled_tokens >= requested
25local new_tokens = filled_tokens
26local allowed_num = 0
27if allowed then
28  new_tokens = filled_tokens - requested
29  allowed_num = 1
30end
31
32if ttl > 0 then
33  redis.call("setex", tokens_key, ttl, new_tokens)
34  redis.call("setex", timestamp_key, ttl, now)
35end
36
37return { allowed_num, new_tokens }

這段代碼和上面介紹令牌桶演算法時用 Java 實作的那段經典代碼幾乎是一樣的,這里使用 lua 腳本,主要是利用了 Redis 的單執行緒特性,以及執行 lua 腳本的原子性,避免了并發訪問時可能出現請求量超出上限的現象,想象目前令牌桶中還剩 1 個令牌,此時有兩個請求同時到來,判斷令牌是否足夠也是同時的,兩個請求都認為還剩 1 個令牌,于是兩個請求都被允許了,

有兩種方式來配置 Spring Cloud Gateway 自帶的限流,第一種方式是通過組態檔,比如下面所示的代碼,可以對某個 route 進行限流:

 1spring:
 2  cloud:
 3    gateway:
 4      routes:
 5      - id: test
 6        uri: http://httpbin.org:80/get
 7        filters:
 8        - name: RequestRateLimiter
 9          args:
10            key-resolver: '#{@hostAddrKeyResolver}'
11            redis-rate-limiter.replenishRate: 1
12            redis-rate-limiter.burstCapacity: 3

其中,key-resolver 使用 SpEL 運算式 #{@beanName} 從 Spring 容器中獲取 hostAddrKeyResolver 物件,burstCapacity 表示令牌桶的大小,replenishRate 表示每秒往桶中填充多少個令牌,也就是填充速度,

第二種方式是通過下面的代碼來配置:

 1@Bean
 2public RouteLocator myRoutes(RouteLocatorBuilder builder) {
 3  return builder.routes()
 4    .route(p -> p
 5      .path("/get")
 6      .filters(filter -> filter.requestRateLimiter()
 7        .rateLimiter(RedisRateLimiter.class, rl -> rl.setBurstCapacity(3).setReplenishRate(1)).and())
 8      .uri("http://httpbin.org:80"))
 9    .build();
10}

這樣就可以對某個 route 進行限流了,但是這里有一點要注意,Spring Cloud Gateway 自帶的限流器有一個很大的坑,replenishRate 不支持設定小數,也就是說往桶中填充的 token 的速度最少為每秒 1 個,所以,如果我的限流規則是每分鐘 10 個請求(按理說應該每 6 秒填充一次,或每秒填充 1/6 個 token),這種情況 Spring Cloud Gateway 就沒法正確的限流,網上也有人提了 issue,support greater than a second resolution for the rate limiter,但還沒有得到解決,

4.3 實作單機并發量限流

上面學習 Resilience4j 的時候,我們提到了 Resilience4j 的一個功能特性,叫 隔離(Bulkhead),Bulkhead 這個單詞的意思是船的艙壁,利用艙壁可以將不同的船艙隔離起來,這樣如果一個船艙破損進水,那么只損失這一個船艙,其它船艙可以不受影響,借鑒造船行業的經驗,這種模式也被引入到軟體行業,我們把它叫做 艙壁模式(Bulkhead pattern),艙壁模式一般用于服務隔離,對于一些比較重要的系統資源,如 CPU、記憶體、連接數等,可以為每個服務設定各自的資源限制,防止某個例外的服務把系統的所有資源都消耗掉,這種服務隔離的思想同樣可以用來做并發量限流,

正如前文所述,Resilience4j 提供了兩種 Bulkhead 的實作:SemaphoreBulkhead 和 ThreadPoolBulkhead,這也正是艙壁模式常見的兩種實作方案:一種是帶計數的信號量,一種是固定大小的執行緒池,考慮到多執行緒場景下的執行緒切換成本,默認推薦使用信號量,

在作業系統基礎課程中,我們學習過兩個名詞:互斥量(Mutex)信號量(Semaphores),互斥量用于執行緒的互斥,它和臨界區有點相似,只有擁有互斥物件的執行緒才有訪問資源的權限,由于互斥物件只有一個,因此任何情況下只會有一個執行緒在訪問此共享資源,從而保證了多執行緒可以安全的訪問和操作共享資源,而信號量是用于執行緒的同步,這是由荷蘭科學家 E.W.Dijkstra 提出的概念,它和互斥量不同,信號允許多個執行緒同時使用共享資源,但是它同時設定了訪問共享資源的執行緒最大數目,從而可以進行并發量控制,

下面是使用信號量限制并發訪問的一個簡單例子:

 1public class SemaphoreTest {
 2
 3    private static ExecutorService threadPool = Executors.newFixedThreadPool(100);
 4    private static Semaphore semaphore = new Semaphore(10);
 5
 6    public static void main(String[] args) {
 7        for (int i = 0; i < 100; i++) {
 8            threadPool.execute(new Runnable() {
 9                @Override
10                public void run() {
11                    try {
12                        semaphore.acquire();
13                        System.out.println("Request processing ...");
14                        semaphore.release();
15                    } catch (InterruptedException e) {
16                        e.printStack();
17                    }
18                }
19            });
20        }
21        threadPool.shutdown();
22    }
23}

這里我們創建了 100 個執行緒同時執行,但是由于信號量計數為 10,所以同時只能有 10 個執行緒在處理請求,說到計數,實際上,在 Java 里除了 Semaphore 還有很多類也可以用作計數,比如 AtomicLong 或 LongAdder,這在并發量限流中非常常見,只是無法提供像信號量那樣的阻塞能力:

 1public class AtomicLongTest {
 2
 3    private static ExecutorService threadPool = Executors.newFixedThreadPool(100);
 4    private static AtomicLong atomic = new AtomicLong();
 5
 6    public static void main(String[] args) {
 7        for (int i = 0; i < 100; i++) {
 8            threadPool.execute(new Runnable() {
 9                @Override
10                public void run() {
11                    try {
12                        if(atomic.incrementAndGet() > 10) {
13                            System.out.println("Request rejected ...");
14                            return;
15                        }
16                        System.out.println("Request processing ...");
17                        atomic.decrementAndGet();
18                    } catch (InterruptedException e) {
19                        e.printStack();
20                    }
21                }
22            });
23        }
24        threadPool.shutdown();
25    }
26}

4.4 實作分布式并發量限流

通過在單機實作并發量限流,我們掌握了幾種常用的手段:信號量、執行緒池、計數器,這些都是單機上的概念,那么稍微拓展下,如果能實作分布式信號量、分布式執行緒池、分布式計數器,那么實作分布式并發量限流不就易如反掌了嗎?

關于分布式執行緒池,是我自己杜撰的詞,在網上并沒有找到類似的概念,比較接近的概念是資源調度和分發,但是又感覺不像,這里直接忽略吧,

關于分布式信號量,還真有這樣的東西,比如 Apache Ignite 就提供了 IgniteSemaphore 用于創建分布式信號量,它的使用方式和 Semaphore 非常類似,使用 Redis 的 ZSet 也可以實作分布式信號量,比如 這篇博客介紹的方法,還有《Redis in Action》這本電子書中也提到了這樣的例子,教你如何實作 Counting semaphores,另外,Redisson 也實作了基于 Redis 的分布式信號量 RSemaphore,用法也和 Semaphore 類似,使用分布式信號量可以很容易實作分布式并發量限流,實作方式和上面的單機并發量限流幾乎是一樣的,

最后,關于分布式計數器,實作方案也是多種多樣,比如使用 Redis 的 INCR 就很容易實作,更有甚者,使用 MySQL 資料庫也可以實作,只不過使用計數器要注意操作的原子性,每次請求時都要經過這三步操作:取計數器當前的值、判斷是否超過閾值,超過則拒絕、將計數器的值自增,這其實和信號量的 P 操作是一樣的,而釋放就對應 V 操作,

所以,利用分布式信號量和計數器就可以實作并發量限流了嗎?問題當然沒有這么簡單,實際上,上面通過信號量和計數器實作單機并發量限流的代碼片段有一個嚴重 BUG:

1semaphore.acquire();
2System.out.println("Request processing ...");
3semaphore.release();

想象一下如果在處理請求時出現例外了會怎么樣?很顯然,信號量被該執行緒獲取了,但是卻永遠不會釋放,如果請求例外多了,這將導致信號量被占滿,最后一個請求也進不來,在單機場景下,這個問題可以很容易解決,加一個 finally 就行了:

1try {
2    semaphore.acquire();
3    System.out.println("Request processing ...");
4} catch (InterruptedException e) {
5    e.printStack();
6} finally {
7    semaphore.release();
8}

由于無論出現何種例外,finally 中的代碼一定會執行,這樣就保證了信號量一定會被釋放,但是在分布式系統中,就不是加一個 finally 這么簡單了,這是因為在分布式系統中可能存在的例外不一定是可被捕獲的代碼例外,還有可能是服務崩潰或者不可預知的系統宕機,就算是正常的服務重啟也可能導致分布式信號量無法釋放,

對于這個問題,我和幾個同事連續討論了幾個晚上,想出了兩種解決方法:第一種方法是使用帶 TTL 的計數器,第二種方法是基于雙視窗滑動的一種比較 tricky 的演算法,

第一種方法比較容易理解,我們為每個請求賦予一個唯一 ID,并在 Redis 里寫入一個鍵值對,key 為 requests_xxx(xxx 為請求 ID),value 為 1,并給這個 key 設定一個 TTL(如果你的應用中存在耗時非常長的請求,譬如對于一些 WebSockket 請求可能會持續幾個小時,還需要開一個執行緒定期去重繪這個 key 的 TTL),然后在判斷并發量時,使用 KEYS 命令查詢 requests_* 開頭的 key 的個數,就可以知道當前一共有多少個請求,如果超過并發量上限則拒絕請求,這種方法可以很好的應對服務崩潰或重啟的問題,由于每個 key 都設定了 TTL,所以經過一段時間后,這些 key 就會自動消失,就不會出現信號量占滿不釋放的情況了,但是這里使用 KEYS 命令查詢請求個數是一個非常低效的做法,在請求量比較多的情況下,網關的性能會受到嚴重影響,我們可以把 KEYS 命令換成 SCAN,性能會得到些許提升,但總體來說效果還是很不理想的,

針對第一種方法,我們可以進一步優化,不用為每個請求寫一個鍵值對,而是為每個分布式系統中的每個實體賦予一個唯一 ID,并在 Redis 里寫一個鍵值對,key 為 instances_xxx(xxx 為實體 ID),value 為這個實體當前的并發量,同樣的,我們為這個 key 設定一個 TTL,并且開啟一個執行緒定期去重繪這個 TTL,每接受一個請求后,計數器加一,請求結束,計數器減一,這和單機場景下的處理方式一樣,只不過在判斷并發量時,還是需要使用 KEYS 或 SCAN 獲取所有的實體,并計算出并發量的總和,不過由于實體個數是有限的,性能比之前的做法有了明顯的提升,

第二種方法我稱之為 雙視窗滑動演算法,結合了 TTL 計數器和滑動視窗演算法,我們按分鐘來設定一個時間視窗,在 Redis 里對應 202009051130 這樣的一個 key,value 為計數器,表示請求的數量,當接受一個請求后,在當前的時間視窗中加一,當請求結束,在當前的時間視窗中減一,注意,接受請求和請求結束的時間視窗可能不是同一個,另外,我們還需要一個本地串列來記錄當前實體正在處理的所有請求和請求對應的時間視窗,并通過一個小于時間視窗的定時執行緒(如 30 秒)來遷移過期的請求,所謂過期,指的是請求的時間視窗和當前時間視窗不一致,那么具體如何遷移呢?我們首先需要統計串列中一共有多少請求過期了,然后將串列中的過期請求時間更新為當前時間視窗,并從 Redis 中上一個時間視窗移動相應數量到當前時間視窗,也就是上一個時間視窗減 X,當前時間視窗加 X,由于遷移執行緒定期執行,所以過期的請求總是會被移動到當前視窗,最終 Redis 中只有當前時間視窗和上個時間視窗這兩個時間視窗中有資料,再早一點的視窗時間中的資料會被往后遷移,所以可以給這個 key 設定一個 3 分鐘或 5 分鐘的 TTL,判斷并發量時,由于只有兩個 key,只需要使用 MGET 獲取兩個值相加即可,下面的流程圖詳細描述了演算法的運行程序:

其中有幾個需要注意的細節:

  1. 請求結束時,直接在 Redis 中當前時間視窗減一即可,就算是負數也沒關系,請求串列中的該請求不用急著洗掉,可以打上結束標記,在遷移執行緒中統一洗掉(當然,如果請求的開始時間和結束時間在同一個視窗,可以直接洗掉);
  2. 遷移的時間間隔要小于時間視窗,一般設定為 30s;
  3. Redis 中的 key 一定要設定 TTL,時間至少為 2 個時間視窗,一般設定為 3 分鐘;
  4. 遷移程序涉及到“從上一個時間視窗減”和“在當前時間視窗加”兩個操作,要注意操作的原子性;
  5. 獲取當前并發量可以通過 MGET 一次性讀取兩個時間視窗的值,不用 GET 兩次;
  6. 獲取并發量和判斷并發量是否超限,這個程序也要注意操作的原子性,

總結

網關作為微服務架構中的重要一環,充當著一夫當關萬夫莫開的角色,所以對網關服務的穩定性要求和性能要求都非常高,為保證網關服務的穩定性,一代又一代的程式員們前仆后繼,想出了十八般武藝:限流、熔斷、隔離、快取、降級、等等等等,這篇文章從限流入手,詳細介紹了限流的場景和演算法,以及原始碼實作和可能踩到的坑,盡管限流只是網關的一個非常小的功能,但卻影響到網關的方方面面,在系統架構的設計中至關重要,

雖然我試著從不同的角度希望把限流介紹的更完全,但終究是管中窺豹,只見一斑,還有很多的內容沒有介紹到,比如阿里開源的 Sentinel 組件也可以用于限流,因為篇幅有限未能展開,另外前文提到的 Netflix 不再維護 Hystrix 專案,這是因為他們把精力放到另一個限流專案 concurrency-limits 上了,這個專案的目標是打造一款自適應的,極具彈性的限流組件,它借鑒了 TCP 擁塞控制的演算法(TCP congestion control algorithm),實作系統的自動限流,感興趣的同學可以去它的專案主頁了解更多內容,

本文篇幅較長,難免疏漏,如有問題,還望不吝賜教,

來源:https://www.aneasystone.com/archives/2020/08/spring-cloud-gateway-current-limiting.html

近期熱文推薦:

1.1,000+ 道 Java面試題及答案整理(2021最新版)

2.別在再滿屏的 if/ else 了,試試策略模式,真香!!

3.臥槽!Java 中的 xx ≠ null 是什么新語法?

4.Spring Boot 2.5 重磅發布,黑暗模式太炸了!

5.《Java開發手冊(嵩山版)》最新發布,速速下載!

覺得不錯,別忘了隨手點贊+轉發哦!

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/303172.html

標籤:其他

上一篇:C語言 extern - C語言零基礎入門教程

下一篇:Maven-基本配置

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 【C++】Microsoft C++、C 和匯編程式檔案

    ......

    uj5u.com 2020-09-10 00:57:23 more
  • 例外宣告

    相比于斷言適用于排除邏輯上不可能存在的狀態,例外通常是用于邏輯上可能發生的錯誤。 例外宣告 Item 1:當函式不可能拋出例外或不能接受拋出例外時,使用noexcept 理由 如果不打算拋出例外的話,程式就會認為無法處理這種錯誤,并且應當盡早終止,如此可以有效地阻止例外的傳播與擴散。 示例 //不可 ......

    uj5u.com 2020-09-10 00:57:27 more
  • Codeforces 1400E Clear the Multiset(貪心 + 分治)

    鏈接:https://codeforces.com/problemset/problem/1400/E 來源:Codeforces 思路:給你一個陣列,現在你可以進行兩種操作,操作1:將一段沒有 0 的區間進行減一的操作,操作2:將 i 位置上的元素歸零。最終問:將這個陣列的全部元素歸零后操作的最少 ......

    uj5u.com 2020-09-10 00:57:30 more
  • UVA11610 【Reverse Prime】

    本人看到此題沒有翻譯,就附帶了一個自己的翻譯版本 思考 這一題,它的第一個要求是找出所有 $7$ 位反向質數及其質因數的個數。 我們應該需要質數篩篩選1~$10^{7}$的所有數,這里就不慢慢介紹了。但是,重讀題,我們突然發現反向質數都是 $7$ 位,而將它反過來后的數字卻是 $6$ 位數,這就說明 ......

    uj5u.com 2020-09-10 00:57:36 more
  • 統計區間素數數量

    1 #pragma GCC optimize(2) 2 #include <bits/stdc++.h> 3 using namespace std; 4 bool isprime[1000000010]; 5 vector<int> prime; 6 inline int getlist(int ......

    uj5u.com 2020-09-10 00:57:47 more
  • C/C++編程筆記:C++中的 const 變數詳解,教你正確認識const用法

    1、C中的const 1、區域const變數存放在堆疊區中,會分配記憶體(也就是說可以通過地址間接修改變數的值)。測驗代碼如下: 運行結果: 2、全域const變數存放在只讀資料段(不能通過地址修改,會發生寫入錯誤), 默認為外部聯編,可以給其他源檔案使用(需要用extern關鍵字修飾) 運行結果: ......

    uj5u.com 2020-09-10 00:58:04 more
  • 【C++犯錯記錄】VS2019 MFC添加資源不懂如何修改資源宏ID

    1. 首先在資源視圖中,添加資源 2. 點擊新添加的資源,復制自動生成的ID 3. 在解決方案資源管理器中找到Resource.h檔案,編輯,使用整個專案搜索和替換的方式快速替換 宏宣告 4. Ctrl+Shift+F 全域搜索,點擊查找全部,然后逐個替換 5. 為什么使用搜索替換而不使用屬性視窗直 ......

    uj5u.com 2020-09-10 00:59:11 more
  • 【C++犯錯記錄】VS2019 MFC不懂的批量添加資源

    1. 打開資源頭檔案Resource.h,在其中預先定義好宏 ID(不清楚其實ID值應該設定多少,可以先新建一個相同的資源項,再在這個資源的ID值的基礎上遞增即可) 2. 在資源視圖中選中專案資源,按F7編輯資源檔案,按 ID 型別 相對路徑的形式添加 資源。(別忘了先把檔案拷貝到專案中的res檔案 ......

    uj5u.com 2020-09-10 01:00:19 more
  • C/C++編程筆記:關于C++的參考型別,專供新手入門使用

    今天要講的是C++中我最喜歡的一個用法——參考,也叫別名。 參考就是給一個變數名取一個變數名,方便我們間接地使用這個變數。我們可以給一個變數創建N個參考,這N + 1個變數共享了同一塊記憶體區域。(參考型別的變數會占用記憶體空間,占用的記憶體空間的大小和指標型別的大小是相同的。雖然參考是一個物件的別名,但 ......

    uj5u.com 2020-09-10 01:00:22 more
  • 【C/C++編程筆記】從頭開始學習C ++:初學者完整指南

    眾所周知,C ++的學習曲線陡峭,但是花時間學習這種語言將為您的職業帶來奇跡,并使您與其他開發人員區分開。您會更輕松地學習新語言,形成真正的解決問題的技能,并在編程的基礎上打下堅實的基礎。 C ++將幫助您養成良好的編程習慣(即清晰一致的編碼風格,在撰寫代碼時注釋代碼,并限制類內部的可見性),并且由 ......

    uj5u.com 2020-09-10 01:00:41 more
最新发布
  • Rust中的智能指標:Box<T> Rc<T> Arc<T> Cell<T> RefCell<T> Weak

    Rust中的智能指標是什么 智能指標(smart pointers)是一類資料結構,是擁有資料所有權和額外功能的指標。是指標的進一步發展 指標(pointer)是一個包含記憶體地址的變數的通用概念。這個地址參考,或 ” 指向”(points at)一些其 他資料 。參考以 & 符號為標志并借用了他們所 ......

    uj5u.com 2023-04-20 07:24:10 more
  • Java的值傳遞和參考傳遞

    值傳遞不會改變本身,參考傳遞(如果傳遞的值需要實體化到堆里)如果發生修改了會改變本身。 1.基本資料型別都是值傳遞 package com.example.basic; public class Test { public static void main(String[] args) { int ......

    uj5u.com 2023-04-20 07:24:04 more
  • [2]SpinalHDL教程——Scala簡單入門

    第一個 Scala 程式 shell里面輸入 $ scala scala> 1 + 1 res0: Int = 2 scala> println("Hello World!") Hello World! 檔案形式 object HelloWorld { /* 這是我的第一個 Scala 程式 * 以 ......

    uj5u.com 2023-04-20 07:23:58 more
  • 理解函式指標和回呼函式

    理解 函式指標 指向函式的指標。比如: 理解函式指標的偽代碼 void (*p)(int type, char *data); // 定義一個函式指標p void func(int type, char *data); // 宣告一個函式func p = func; // 將指標p指向函式func ......

    uj5u.com 2023-04-20 07:23:52 more
  • Django筆記二十五之資料庫函式之日期函式

    本文首發于公眾號:Hunter后端 原文鏈接:Django筆記二十五之資料庫函式之日期函式 日期函式主要介紹兩個大類,Extract() 和 Trunc() Extract() 函式作用是提取日期,比如我們可以提取一個日期欄位的年份,月份,日等資料 Trunc() 的作用則是截取,比如 2022-0 ......

    uj5u.com 2023-04-20 07:23:45 more
  • 一天吃透JVM面試八股文

    什么是JVM? JVM,全稱Java Virtual Machine(Java虛擬機),是通過在實際的計算機上仿真模擬各種計算機功能來實作的。由一套位元組碼指令集、一組暫存器、一個堆疊、一個垃圾回收堆和一個存盤方法域等組成。JVM屏蔽了與作業系統平臺相關的資訊,使得Java程式只需要生成在Java虛擬機 ......

    uj5u.com 2023-04-20 07:23:31 more
  • 使用Java接入小程式訂閱訊息!

    更新完微信服務號的模板訊息之后,我又趕緊把微信小程式的訂閱訊息給實作了!之前我一直以為微信小程式也是要企業才能申請,沒想到小程式個人就能申請。 訊息推送平臺🔥推送下發【郵件】【短信】【微信服務號】【微信小程式】【企業微信】【釘釘】等訊息型別。 https://gitee.com/zhongfuch ......

    uj5u.com 2023-04-20 07:22:59 more
  • java -- 緩沖流、轉換流、序列化流

    緩沖流 緩沖流, 也叫高效流, 按照資料型別分類: 位元組緩沖流:BufferedInputStream,BufferedOutputStream 字符緩沖流:BufferedReader,BufferedWriter 緩沖流的基本原理,是在創建流物件時,會創建一個內置的默認大小的緩沖區陣列,通過緩沖 ......

    uj5u.com 2023-04-20 07:22:49 more
  • Java-SpringBoot-Range請求頭設定實作視頻分段傳輸

    老實說,人太懶了,現在基本都不喜歡寫筆記了,但是網上有關Range請求頭的文章都太水了 下面是抄的一段StackOverflow的代碼...自己大修改過的,寫的注釋挺全的,應該直接看得懂,就不解釋了 寫的不好...只是希望能給視頻網站開發的新手一點點幫助吧. 業務場景:視頻分段傳輸、視頻多段傳輸(理 ......

    uj5u.com 2023-04-20 07:22:42 more
  • Windows 10開發教程_編程入門自學教程_菜鳥教程-免費教程分享

    教程簡介 Windows 10開發入門教程 - 從簡單的步驟了解Windows 10開發,從基本到高級概念,包括簡介,UWP,第一個應用程式,商店,XAML控制元件,資料系結,XAML性能,自適應設計,自適應UI,自適應代碼,檔案管理,SQLite資料庫,應用程式到應用程式通信,應用程式本地化,應用程式 ......

    uj5u.com 2023-04-20 07:22:35 more