【分佈式限流】你被12306的驗證碼坑過麼?

Stay Hungry,Stay Foolish——
求知若飢,虛心若愚java

目錄node

  • 前言
  • 基本概念
  • 解決方案
    • 基於guava實現限流
    • 網關層面實現限流
    • 中間件實現限流
  • 經常使用限流算法
    • 令牌桶算法
    • 漏桶算法
  • 實戰
    • 基於guava的限流實戰
    • 基於Nginx限流實戰
    • 基於Redis+Lua的限流組件(略)
  • 寫在最後

前言

相信不少在中小型企業或者TO B企業的小夥伴們都不曾接觸過限流。舉個例子,小夥伴們就會發現,原來軟件限流就在身邊。相信不少小夥伴們都有12306買票回家的體驗吧。以下圖你們應該很是熟悉。
12306登陸nginx

沒錯,這就是坑死人不償命的驗證碼,尤爲在搶票的時候(固然今年疫情期間搶票容易不少),無論怎麼選,老是被告知選錯了。要麼就給你一堆鬼都看不出什麼東西的圖,有時候真的讓人懷疑人生。其實這就是一種限流措施,這種故意刁難用戶的手段,光明正大地限制了流量的訪問,大大下降了系統的訪問壓力。程序員

基本概念

經過上述例子,老貓以爲小夥伴們至少內心對限流有了個定數,那麼咱們再來細看一下限流的維度。web

其實對於通常限流場景來講,會有兩個維度的信息:redis

  1. 時間:限流基於某個時間段或者時間點,即「時間窗口」,對每分鐘甚至每秒作限定。
  2. 資源:基於現有可用資源的限制,比方說最大訪問次數或者最高可用鏈接數。

基於上述的兩個維度,咱們基本能夠給限流下個簡單的定義,限流就是某個時間窗口對資源訪問作限制。打個比方,每秒最多100個請求。可是在真正的場景中,咱們不會僅僅只設置一種限流規則,而是多種規則共同做用。
限流手段算法

QPS以及鏈接數控制spring

針對上圖中的鏈接數以及訪問頻次(QPS)限流來講,咱們能夠設定IP維度的限流。也能夠基於當個服務器的限流。在實戰的時候一般會設置多個維度限流規則,舉個例子,訪問同一個IP每秒訪問頻率小於10鏈接小於5,在設定每臺機器QPS最高1000,鏈接數最大保持200。更進一步的,咱們能夠把整個服務器組以及機房當作一個總體,而後設置更高級別的限流規則。關於這個場景,老貓會在本文的後面篇幅給出具體的實現的demo代碼。數據庫

傳輸速率編程

對於傳輸速率,你們應該不陌生,例如某盤若是不是會員給你幾KB的下載,充完會員給你幾十M的下載速率。這就是基於會員用戶的限流邏輯。其實在Nginx中咱們就能夠限制傳輸速率,demo看本文後面篇幅。

黑白名單

黑白名單是不少企業的常見限流以及放行手段,並且黑白名單每每是動態變化的。舉個例子,若是某個IP在一段時間中訪問次數過於頻繁,別系統識別爲機器人或者流量攻擊,那麼IP就會被加入黑名單,從而限制了對系統資源的訪問,這就是封IP,仍是說到搶火車票,你們會用到第三方的軟件,在進行刷票的時候,不曉得你們有沒有留意有的時候會關進小黑屋,而後果斷時間又被釋放了。其實這就是基於黑名單的動態變化。

那關於白名單的話就更加不用解釋了,就至關於通行證同樣能夠自由穿行在各個限流規則中。

分佈式限流

如今不少系統都是分佈式系統,老貓在以前和你們分享了分佈式鎖機制。那麼什麼叫作分佈式限流呢?其實也很簡單,就是區別於單機限流場景,把整個分佈式環境中全部的服務器當作一個總體去考量。舉個例子,比方說針對IP的限流,咱們限制一個IP每秒最多100個訪問,無論落到哪臺機器上,只要是訪問了集羣中的服務節點,就會受到限流約束。

所以分佈式的限流方案通常有這兩種:

  • 網關層限流:流量規則放在流量入口。
  • 中間件限流:利用中間件,例如Redis緩存,每一個組件均可以從這裏獲取當前時刻的流量統計,從而決定放行仍是拒絕。

解決方案

老貓本篇文章中主要給你們介紹一下三種解決方案。

方案一:基於GUAVA實現限流

相信不少鐵子比較熟悉guava,它實際上是谷歌出品的工具包,咱們常常用它作一些集合操做或者作一些內存緩存操做。可是除了這些基本的用法以外,其實Guava在其餘的領域涉及也很廣,其中包括反射工具、函數式編程、數學運算等等。固然在限流的領域guava也作了貢獻。主要的是在多線程的模塊中提供了RateLimiter爲首的幾個限流支持類。Guava是一個客戶端組件,就是說它做用範圍僅限於當前的服務器,不能對集羣之內的其餘服務器加以流量控制。簡單示例圖以下。
guava使用簡單架構

方案二:網關層面實現限流

我們直接看個圖,準確地來講應該是個漏斗模型,具體以下:
簡單漏斗模型

從上圖中咱們能夠發現這基本是咱們平常請求的一個正常的請求流程:

  1. 用戶流量從網關層到後臺服務
  2. 後臺服務承接流量,調用緩存獲取數據
  3. 緩存中無數據的狀況則回源查詢數據庫

那麼咱們爲何稱呼它爲漏斗模型?其實很簡單,由於流量自上而下是遞減的,在網關層彙集了最爲密集的用戶訪問請求,其次纔是後臺服務,通過服務驗證以後,刷掉一部分錯誤請求,剩下的請求落到緩存中,若是沒有緩存的狀況下才是最終的數據庫層,因此數據庫請求頻次是最低的。以後老貓會將網關層Nginx的限流演示給你們看。

方案三:中間件限流

對於開發人員來講,網關層的限流須要尋找運維團隊的配合才能實現,可是如今的年輕人控制慾都挺強的,因而大部分開發會決定在代碼層面進行限流控制,那麼此時,中間件Redis就成了不二之選。咱們能夠利用Redis的過時時間特性,請求設置限流的時間跨度(好比每秒是個請求,或者10秒10個請求)。同時Redis還有一個特殊的技能叫作腳本編程,咱們能夠將限流邏輯編寫完成一段腳本植入到Redis中,這樣就將限流的重任從服務層徹底剝離出來,同時Redis強大的併發量特性以及高可用的集羣架構也能夠很好支持龐大集羣的限流訪問。

經常使用限流算法

算法一:令牌桶算法(Token bucket)

Token Bucket令牌桶算法是目前應用最爲普遍的限流算法,顧名思義,它由如下兩個角色構成:

  • 令牌——獲取到令牌的請求才會被處理,其餘請求要麼排隊要麼被丟棄。
  • 桶——用來裝令牌的地方,全部請求都是從桶中獲取相關令牌。

簡單令牌處理以下圖:
令牌桶算法模型

令牌生成

該流程涉及令牌生成器以及令牌桶,對於令牌生成器來講,它會根據一個預約的速率向桶中添加令牌,例如每秒100個請求的速率發放令牌。這裏的發放是勻速發放。令牌生成器類比水龍頭,令牌桶類比水桶,當水盛滿以後,接下來再往桶中灌水的話就會溢出,令牌發放性質是同樣的,令牌桶的容量是有限的,當前若已經放滿,那麼此時新的令牌就會被丟棄掉。(你們能夠嘗試先思考一下令牌發放速率快慢有無坑點)。

令牌獲取

每一個請求到達以後,必須獲取到一個令牌才能執行後面的邏輯。假如令牌的數量少,而訪問請求較多的狀況下,一部分請求天然沒法獲取到令牌,這個時候咱們就能夠設置一個緩衝隊列來存儲多餘的令牌。緩衝隊列也是一個可選項,並非全部的令牌桶算法程序都會實現隊列。當緩存隊列存在的狀況下,那些暫時沒有獲取到令牌的請求將被放到這個隊列中排隊,直到新的令牌產生後,再從隊列頭部拿出一個請求來匹配令牌。當隊列滿的狀況下,這部分訪問請求就被拋棄。其實在實際的應用中也能夠設置隊裏的相關屬性,例如設置隊列中請求的存活時間,或者根據優先級排序等等。

算法二:漏桶算法(Leaky Bucket)

示意圖以下:
漏桶算法模型

漏桶算法判斷邏輯和令牌桶有所相似,可是操做對象不一樣,令牌桶是將令牌放入桶內,而漏桶是將請求放到桶中。同樣的是若是桶滿了,那麼後來的數據會被丟棄。漏桶算法只會以一個恆定的速度將數據包從桶內流出。

兩種算法的聯繫和區別:

  • 共同點:這兩種算法都有一個「恆定」的速率和「不定」的速率。令牌桶是以恆定的速率建立令牌,可是訪問請求獲取令牌的速率是不定的,有多少令牌就發多少,令牌沒了只能等着。漏桶算法是以很定的速率處理請求,可是流入桶內的請求的速度是不定的
  • 不一樣點:漏桶自然決定它不會發生突發流量,就算每秒1000個請求,它後來服務輸出的訪問速率永遠都是恆定的。然而令牌桶不一樣,其特性能夠「預存」必定量的令牌,所以在應對突發流量的時候能夠在短期裏消耗全部的令牌。突發流量的出率效率會比漏桶來的高,固然對應後臺系統的壓力也會大一些。

限流實戰

基於guava的限流實戰

pom依賴:

<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>18.0</version>
  </dependency>

demo代碼:

// 限流組件每秒容許發放兩個令牌
    RateLimiter limiter = RateLimiter.create(2.0);
    //非阻塞限流
    @GetMapping("/tryAcquire")
    public String tryAcquire(Integer count){
        // 每次請求須要獲取的令牌數量
        if (limiter.tryAcquire(count)){
            log.info("success, rate is {}",limiter.getRate());
            return "success";
        }else {
            log.info("fail ,rate is {}",limiter.getRate());
            return "fail";
        }
    }
    //限定時間的阻塞限流
    @GetMapping("tryAcquireWithTimeout")
    public String tryAcquireWithTimeout(Integer count, Integer timeout){
        if (limiter.tryAcquire(count,timeout, TimeUnit.SECONDS)){
            log.info("success, rate is {}",limiter.getRate());
            return "success";
        }else {
            log.info("fail ,rate is {}",limiter.getRate());
            return "fail";
        }
    }
    //同步阻塞限流
    @GetMapping("acquire")
    public String acquire(Integer count) {
        limiter.acquire(count);
        log.info("success, rate is {}",limiter.getRate());
        return "success";
    }

關於guava單機限流的演示,老貓簡單地寫了幾個Demo。guava單機限流主要分爲阻塞限流以及非阻塞限流。你們啓動項目以後,能夠調整相關的入參來觀察一下日誌的變動狀況。

老貓舉例分析一下其中一種請求的結果 localhost:10088/tryAcquire?count=2;請求以後輸出的日誌以下:

2021-02-18 23:41:48.615  INFO 5004 --- [io-10088-exec-9] com.ktdaddy.KTController:success,rate is2.0
2021-02-18 23:41:49.164  INFO 5004 --- [io-10088-exec-2] com.ktdaddy.KTController:success, rate is2.0
2021-02-18 23:41:49.815  INFO 5004 --- [o-10088-exec-10] com.ktdaddy.KTController:success, rate is2.0
2021-02-18 23:41:50.205  INFO 5004 --- [io-10088-exec-1] com.ktdaddy.KTController:fail ,rate is 2.0
2021-02-18 23:41:50.769  INFO 5004 --- [io-10088-exec-3] com.ktdaddy.KTController:success,rate is 2.0
2021-02-18 23:41:51.470  INFO 5004 --- [io-10088-exec-4] com.ktdaddy.KTController:fail ,rate is 2.0

從請求日誌中咱們神奇地發現前兩次請求中間間隔不到一秒,可是消耗了令牌確都是成功的。這個是什麼緣由呢?這裏面賣個關子,後面再和你們同步一下guava的流量預熱模型。

基於Nginx限流實戰

nginx.conf限流配置以下:

# 根據 IP地址限制速度
# (1)第一個參數 $binary_remote_addr:能夠理解成nginx內部系統的變量
#                binary_目的是縮寫內存佔用,remote_addr表示經過IP地址來限流
#
# (2)第二個參數 zone=iplimit:20m
#                 iplimit是一塊內存區域,20m是指這塊內存區域的大小(專門記錄訪問頻率信息)  
# (3)第三個參數 rate=1r/s,標識訪問的限流頻率
#                 配置形式不止一種,還例如:100r/m
limit_req_zone $binary_remote_addr zone=iplimit:20m rate=10r/s;

# 根據服務器級別作限流
limit_req_zone $server_name zone=serverlimit:10m rate=1r/s;

# 基於IP鏈接數的配置
limit_conn_zone $binary_remote_addr zone=perip:20m;

# 根據服務器級別作限流
limit_conn_zone $server_name zone=perserver:20m;

# 普通的服務映射域名limit-rate.ktdaddy.com映射到http://127.0.0.1:10088/服務。
# 你們本地能夠經過配置host映射去實現,很簡單,很少贅述。
server {
        server_name  limit-rate.ktdaddy.com;
        location /access-limit/ {
            proxy_pass http://127.0.0.1:10088/;
            
            # 基於IP地址的限制
            # 1)第一個參數  zone=iplimit => 引用limit_req_zone的zone變量信息
            # 2)第二個參數  burst=2,設置一個大小爲2的緩衝區域,當大量請求到來,請求數量超過限流頻率的時候
            #             將其放入緩衝區域
            # 3)第三個參數  nodelay=> 緩衝區滿了之後,直接返回503異常
            limit_req zone=iplimit burst=2 nodelay;
        
            # 基於服務器級別的限制
            # 一般狀況下,server級別的限流速率大於IP限流的速率(你們能夠思考一下,其實比較簡單)
            limit_req zone=serverlimit burst=1 nodelay;
       
            # 每一個server最多保持100個連接
            limit_conn perserver 100;
            # 每一個Ip地址保持1個連接地址
            limit_conn perip 1;
            # 異常狀況返回指定返回504而不是默認的503
            limit_req_status 504;
            limit_conn_status 504;
        }
        # 簡單下載限速的配置,表示下載文件達到100m以後限制速度爲256k的下載速度
        location /download/ {
              limit_rate_after 100m;
              limit_rate 256k;
        }
    }

上面配置裏面其實結合了nginx限流四種配置方式,分別是基於ip的限流方式,基於每臺服務的限流,基於IP鏈接數的限流,基於每臺服務鏈接數的限流。固然最後還給你們提到了下載限速的配置。你們有興趣能夠模擬配置一下,體驗一下基於nginx的限流。

基於Redis+Lua的限流組件

此處仍是比較重要的,老貓決定單獨做爲一個知識點和你們分享,此處暫時省略。

寫在最後

本文和你們分享了限流的相關概念以及實際中咱們的應用還有相關的算法以及實現。關於redis+lua腳本的限流實現方式暫時沒有作分享。我的以爲仍是比較實用的,後面單獨拉出來和你們分享,敬請期待,更多技術乾貨也歡迎你們關注公衆號「程序員老貓」。

相關文章
相關標籤/搜索