網關限流算法及實現總結

在高併發的系統中,往往需要在系統中做限流,一方面是爲了防止大量的請求使服務器過載,導致服務不可用,另一方面是爲了防止網絡攻擊。

常見的限流場景有:

  • 限制總併發數(比如數據庫連接池、線程池)
  • 限制瞬時併發數(如 nginx 的 limit_conn 模塊,用來限制瞬時併發連接數)
  • 限制時間窗口內的平均速率(如 Guava 的 RateLimiter、nginx 的 limit_req 模塊,限制每秒的平均速率)
  • 其他還有如限制遠程接口調用速率、限制 MQ 的消費速率
  • 另外還可以根據網絡連接數、網絡流量、CPU 或內存負載等來限流。

1.算法篇

常用的限流算法有:計數器,漏桶,令牌桶。

1.1.計數器算法

請求限流簡單的做法是維護一個單位時間內的計數器,每次請求計數器加1,當單位時間內計數器累加到大於設定的閾值,則之後的請求都被拒絕,直到單位時間已經過去,再將計數器 重置爲零。
這種方式有個缺點:如果在單位時間1s內允許100個請求,在10ms已經通過了100個請求,那後面的990ms,只能眼巴巴的把請求拒絕,我們把這種現象稱爲“突刺現象”。

計數器算法在生產實踐中使用較少,在工業界常用的是更平滑的限流算法:漏桶算法令牌桶算法

1.2.漏桶算法

漏桶算法很簡單,水(請求)先進入到漏桶裏,漏桶以一定的速度出水(接口有響應速率),當水流入速度過大會直接溢出(訪問頻率超過接口響應速率),然後就拒絕請求,可以看出漏桶算法能強行限制請求的處理速率。

漏桶算法

算法思想是:

  1. 水(請求)從上方倒入水桶,從水桶下方流出(被處理);
  2. 來不及流出的水存在水桶中(緩衝),以固定速率流出;
  3. 水桶滿後水溢出(丟棄)。
    這個算法的核心是:緩存請求、勻速處理、多餘的請求丟棄。

漏桶算法中主要有兩個變量:

  • 一個是桶大小(burst),即支持流量突發增多時可以存多少的水;
  • 另一個是漏斗大小(rate),即漏桶漏出速率的固定參數;

所以,即使網絡中不存在資源衝突(沒有發生擁塞),漏桶算法也不能使流突發(burst)到端口速率。因此,漏桶算法對突發流量不做額外處理,無法應對存在突發特性的流量。

1.3.令牌桶算法

令牌桶算法 和漏桶算法 效果一樣但方向相反的算法,更加容易理解。隨着時間流逝,系統會按恆定 1/QPS 時間間隔(如果 QPS=100,則間隔是 10ms)往桶裏加入 Token(想象和漏洞漏水相反,有個水龍頭在不斷的加水),如果桶已經滿了就不再加了。新請求來臨時,會各自拿走一個 Token,如果沒有 Token 可拿了就阻塞或者拒絕服務。
令牌桶算法

算法思想是:

  1. 令牌以固定速率產生,並緩存到令牌桶中;
  2. 令牌桶放滿時,多餘的令牌被丟棄;
  3. 請求要消耗等比例的令牌才能被處理;
  4. 令牌不夠時,請求被丟棄。

漏桶和令牌桶算法最明顯的區別就是是否允許突發流量(burst)的處理,漏桶算法能夠強行限制數據的實時傳輸(處理)速率,對突發流量不做額外處理;而令牌桶算法能夠在限制數據的平均傳輸速率的同時允許某種程度的突發流量處理(桶內可累計緩存最大令牌數量)。

2.實踐篇

在生產實踐中,請求限流通常依賴於api網關來實現,常用網關基本都支持限流功能,例如常用的nginx和springcloud-gateway。

2.1.nginx限流

Nginx按請求速率限速模塊使用的是優化漏桶算法(支持突發量請求處理,詳見nodelay參數),即能夠強行保證請求的實時處理速度不會超過設置的閾值。Nginx官方版本限制IP的連接和併發分別有兩個模塊:

  • ngx_http_limit_req_module 用來限制單位時間內的請求數,即速率限制,採用的漏桶算法 “leaky bucket”。
  • ngx_http_limit_conn_module 用來限制同一時間連接數,即併發限制。

2.1.1.Module ngx_http_limit_req_module

參數配置

Syntax: limit_req_zone key zone=name:size rate=req/time; 
Default: — 
Context: http
# 語法
Syntax: limit_req zone=name [burst=number] [nodelay];
# 默認值(無)
Default: -
# 作用域
Context: http, server, location

示例

http{

 # 聲明請求限流存儲區
 limit_req_zone $binary_remote_addr zone=req_one:10m rate=1r/s
 server{
  ...
  limit_req zone=req_one burst=5 nodelay;
  ...
 }
}

配置說明:

limit_req_zone $binary_remote_addr zone=req_one:10m rate=1r/s

  • 第一個參數:$binary_remote_addr 表示通過remote_addr這個標識來做限制,“binary_”目的是縮寫內存佔用量,是限制同一客戶端ip地址。
  • 第二個參數:zone=one:10m 表示生成一個大小爲10M,名字爲one的內存區域,用來存儲訪問的頻次信息。
  • 第三個參數:rate=1r/s表示允許相同標識的客戶端的訪問頻次,這裏限制的是每秒1次,還可以有比如30r/m的。

limit_req zone=one burst=5 nodelay;

  • 第一個參數:zone=one 設置使用哪個配置區域來做限制,與上面limit_req_zone 裏的name對應。
  • 第二個參數:burst=5,重點說明一下這個配置,burst爆發的意思,這個配置的意思是設置一個大小爲5的緩衝區當有大量請求(爆發)過來時,超過了訪問頻次限制的請求可以先放到這個緩衝區內,緩衝區容量爲5。
  • 第三個參數:nodelay 該參數允許請求在排隊的時候就立即被處理,也就是說只要請求能夠進入burst隊列,就會立即被後臺worker處理,請注意,這意味着burst設置了nodelay時,系統瞬間的QPS可能會超過rate設置的閾值。nodelay參數要跟burst一起使用纔有作用。否則所有請求會等待排隊按漏桶固定速度處理。

其他參數:

  • limit_req_log_level 當服務器由於限速或緩存,設置寫入日誌的級別。
Syntax: limit_req_log_level info | notice | warn | error;
Default: limit_req_log_level error; 
Context: http, server, location
  • limit_req_status 設置拒絕請求的返回值,只能設置400-599之間
Syntax: limit_req_status code; 
Default: limit_req_status 503;
Context: http, server, location

2.1.2.Module ngx_http_limit_conn_module

這個模塊用來限制單個IP的連接數,並非所有的連接都被計數,只有在服務器處理了請求並已經讀取了完整的請求頭時,才被計數。
參數配置:

Syntax: limit_conn_zone key zone=name:size; 
Default: — 
Context: http
Syntax: limit_conn zone number; 
Default: — 
Context: http, server, location

示例:
只允許每個IP保持一個連接

limit_conn_zone $binary_remote_addr zone=addr:10m; 
server {
    location /download/ { 
        limit_conn addr 1; 
    }

可以配置多個limit_coon指令,例如配置客戶端每個IP連接數,同時限制服務端最大保持連接數

limit_conn_zone $binary_remote_addr zone=perip:10m; 
limit_conn_zone $server_name zone=perserver:10m; 
server {
    ... 
    limit_conn perip 10; 
    limit_conn perserver 100; 
    ...
}

在這裏,客戶端IP地址作爲鍵,不是$remote_addr,而是使用$binary_remote_addr變量。
$remote_addr 變量的大小可以從7到15個字節不等。存儲的狀態在32位平臺上佔用32或64字節的內存,在64位平臺上總是佔用64字節。對於IPv4地址,$binary_remote_addr變量的大小始終爲4個字節,對於IPv6地址則爲16個字節。存儲狀態在32位平臺上始終佔用32或64個字節,在64位平臺上佔用64個字節。一個兆字節的區域可以保持大約32000個32字節的狀態或大約16000個64字節的狀態。如果區域存儲耗盡,服務器會將錯誤返回給所有其他請求。

其他參數

  • limit_conn_log_level 當超出連接數時,設置日誌記錄級別
Syntax: limit_conn_log_level info | notice | warn | error;
Default: limit_conn_log_level error;
Context: http, server, location
  • limit_conn_status 設置拒絕請求時返回碼
Syntax: limit_conn_status code; 
Default: limit_conn_status 503; 
Context: http, server, location

實例一 限制訪問速率

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s; 
server {
    location / { 
        limit_req zone=mylimit; 
        }
    }

上述規則限制了每個IP訪問的速度爲2r/s,並將該規則作用於根目錄。如果單個IP在非常短的時間內併發發送多個請求,結果會怎樣呢?
在這裏插入圖片描述

我們使用單個IP在10ms內發併發送了6個請求,只有1個成功,剩下的5個都被拒絕。我們設置的速度是2r/s,爲什麼只有1個成功呢,是不是Nginx限制錯了?當然不是,是因爲Nginx的限流統計是基於毫秒的,我們設置的速度是2r/s,轉換一下就是500ms內單個IP只允許通過1個請求,從501ms開始才允許通過第二個請求。

實例二 burst緩存處理

短時間內發送大量請求,Nginx按照毫秒級精度統計,超出限制的請求直接拒絕。這在實際場景中未免過於苛刻,真實網絡環境中請求到來不是勻速的,很可能有請求“突發”的情況,也就是“一股子一股子”的。Nginx考慮到了這種情況,可以通過burst關鍵字開啓對突發請求的緩存處理,而不是直接拒絕。

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s; 
server { 
    location / { 
        limit_req zone=mylimit burst=4; 
    } 
}

我們加入了burst=4,意思是每個key(此處是每個IP)最多允許4個突發請求的到來。如果單個IP在10ms內發送6個請求,結果會怎樣呢?
在這裏插入圖片描述

相比實例一成功數增加了4個,這個我們設置的burst數目是一致的。具體處理流程是:1個請求被立即處理,4個請求被放到burst隊列裏,另外一個請求被拒絕。通過burst參數,我們使得Nginx限流具備了緩存處理突發流量的能力。
但是請注意:burst的作用是讓多餘的請求可以先放到隊列裏,慢慢處理。如果不加nodelay參數,隊列裏的請求不會立即處理,而是按照rate設置的速度,以毫秒級精確的速度慢慢處理。

實例三 nodelay降低排隊時間

實例二中我們看到,通過設置burst參數,我們可以允許Nginx緩存處理一定程度的突發,多餘的請求可以先放到隊列裏,慢慢處理,這起到了平滑流量的作用。但是如果隊列設置的比較大,請求排隊的時間就會比較長,用戶角度看來就是響應變長了,這對用戶很不友好。有什麼解決辦法呢?nodelay參數允許請求在排隊的時候就立即被處理,也就是說只要請求能夠進入burst隊列,就會立即被後臺worker處理,請注意,這意味着burst設置了nodelay時,系統瞬間的QPS可能會超過rate設置的閾值。nodelay參數要跟burst一起使用纔有作用。

limit_req_zone $binary_remote_addr zone=mylimit:10m rate=2r/s;
server { 
    location / { 
        limit_req zone=mylimit burst=4 nodelay;
    } 
}

單個IP 10ms內併發發送6個請求,結果如下:
在這裏插入圖片描述

跟實例二相比,請求成功率沒變化,但是總體耗時變短了。這怎麼解釋呢?實例二中,有4個請求被放到burst隊列當中,工作進程每隔500ms(rate=2r/s)取一個請求進行處理,最後一個請求要排隊2s纔會被處理;實例三中,請求放入隊列跟實例二是一樣的,但不同的是,隊列中的請求同時具有了被處理的資格,所以實例三中的5個請求可以說是同時開始被處理的,花費時間自然變短了。

但是請注意,雖然設置burst和nodelay能夠降低突發請求的處理時間,但是長期來看並不會提高吞吐量的上限,長期吞吐量的上限是由rate決定的,因爲nodelay只能保證burst的請求被立即處理,但Nginx會限制隊列元素釋放的速度,就像是限制了令牌桶中令牌產生的速度。看到這裏你可能會問,加入了nodelay參數之後的限速算法,到底算是哪一個“桶”,是漏桶算法還是令牌桶算法?當然還算是漏桶算法。

在令牌桶算法中,令牌桶算法的token爲耗盡時會怎麼做呢?由於它有一個請求隊列,所以會把接下來的請求緩存下來,緩存多少受限於隊列大小。但此時緩存這些請求還有意義嗎?如果server已經過載,緩存隊列越來越長,響應時間越來越長,即使過了很久請求被處理了,對用戶來說也沒什麼價值。所以當token不夠用時,最明智的做法就是直接拒絕用戶的請求,這就成了漏桶算法。

2.2.springcloud-gateway限流

Spring Cloud Gateway是Spring官方基於Spring 5.0,Spring Boot 2.0和Project Reactor等技術開發的網關,Spring Cloud Gateway旨在爲微服務架構提供一種簡單而有效的統一的API路由管理方式。Spring Cloud Gateway作爲Spring Cloud生態系統中的網關,目標是替代Netflix ZUUL,其不僅提供統一的路由方式,並且基於Filter鏈的方式提供了網關基本的功能,例如:安全,監控/埋點,和限流等。

Spring Cloud Gateway 已經內置了一個基於令牌桶算法實現的限流器RequestRateLimiterGatewayFilterFactory,我們可以直接使用。RequestRateLimiterGatewayFilterFactory的實現依賴於 Redis,所以我們還要引入spring-boot-starter-data-redis-reactive。

pom.xml

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

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

application.yml

server:
  port: 8080
spring:
  cloud:
    gateway:
      routes:
        - id: limit_route
          uri: http://www.baidu.com/
          predicates:
          - After=2019-02-26T00:00:00+08:00[Asia/Shanghai]
          filters:
          - name: RequestRateLimiter
            args:
              key-resolver: '#{@hostAddrKeyResolver}'
              redis-rate-limiter.replenishRate: 1
              redis-rate-limiter.burstCapacity: 3
  application:
    name: gateway-limiter
  redis:
    host: localhost
    port: 6379
    database: 0

在上面的配置文件,配置了 redis的信息,並配置了RequestRateLimiter的限流過濾器,該過濾器需要配置三個參數:

  • burstCapacity:令牌桶總容量。
  • replenishRate:令牌桶每秒填充平均速率。
  • key-resolver:用於限流的鍵的解析器的 Bean 對象的名字。它使用 SpEL 表達式根據#{@beanName}從 Spring 容器中獲取 Bean 對象。

實例一 IP限流

獲取請求用戶ip作爲限流key,KeyResolver是獲取限流鍵的接口。

    @Bean
    public KeyResolver hostAddrKeyResolver() {
        return new IpKeyResolver();  
    }

實例二 用戶限流

獲取請求用戶id作爲限流key

@Bean
public KeyResolver userKeyResolver() {
    return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst("userId"));
}

實例三 接口限流

獲取請求地址的uri作爲限流key

@Bean
public KeyResolver apiKeyResolver() {
    return exchange -> Mono.just(exchange.getRequest().getPath().value());
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章