高可用服務設計之二:Rate limiting 限流與降級


高可用服務設計之二:Rate limiting 限流與降級

nginx限制請求之一:(ngx_http_limit_conn_module)模塊

nginx限制請求之二:(ngx_http_limit_req_module)模塊

nginx限制請求之三:Nginx+Lua+Redis 對請求進行限制

nginx限制請求之四:目錄進行IP限制

分布式限流之一:redis+lua 實現分布式令牌桶,高並發限流

服務容災

為了避免出現服務的雪崩,我們需要對服務做容災處理。

常規的服務容災處理思路有:

  • 資源隔離
  • 超時設定
  • 服務降級
  • 服務限流

其中每種思路又可以有不同的解決方案。

比如資源隔離可以通過將不同的服務發布在獨立的docker容器或服務器中,這樣即使一個服務出現問題,也不會殃及池魚。

服務降級和服務限流可以通過前端nginx+lua來實現,當服務處理延遲或宕機時,nginx可以直接返回固定的降級/失敗響應,已快速跳過問題服務。

一、限流

在開發高並發系統時,有很多手段保護系統,比如緩存降級限流。緩存的目的是提升系統訪問速度和增大系統處理能力,可謂是抗高並發的銀彈。而降級是當服務出問題或者影響到核心流程的性能,需要暫時屏蔽掉,待高峰過去或者問題解決后再打開的場景。而有些場景並不能用緩存和降級來解決,比如稀缺資源(秒殺、搶購)、寫服務(如評論、下單)、頻繁的復雜查詢(評論的最后幾頁)等。因此,需要有一種手段來限制這些場景下的並發/請求量,這種手段就是限流。

限流的目的是通過對並發訪問/請求進行限速或者一個時間窗口內的請求進行限速來保護系統,一旦達到限速速率則可以拒絕服務(定向到錯誤頁或告知資源沒有了)、排隊或等待(比如秒殺、評論、下單)、降級(返回兜底數據或者默認數據,如商品詳情頁庫存默認有貨)。在壓測時,我們能找出每個系統的處理峰值,然后通過設定峰值閾值,當系統過載時,通過拒絕過載的請求來保障系統可用。另外,也可以根據系統的吞吐量、響應時間、可用率來動態調整限流閾值。

一般開發高並發系統場景的限流有:限制總並發數(比如數據庫連接池、線程池)、限制瞬時並發數(如Nginx的limit_conn模塊,用來限制瞬間並發連接數)、限制時間窗口內的平均速率(如Guava的RateLimiter、Nginx的limit_req模塊,用來限制每秒的平均速率),以及限制遠程接口調用速率、限制MQ的消費速率等。另外還可以根據網絡連接數、網絡流量、CPU或內存負載等來限流。

Rate limiting 在 Web 架構中非常重要,是互聯網架構可靠性保證重要的一個方面。

從最終用戶訪問安全的角度看,設想有人想暴力碰撞網站的用戶密碼;或者有人攻擊某個很耗費資源的接口;或者有人想從某個接口大量抓取數據。大部分人 都知道應該增加 Rate limiting,做請求頻率限制。從安全角度,這個可能也是大部分能想到,但不一定去做的薄弱環節。

從整個架構的穩定性角度看,一般 SOA 架構的每個接口的有限資源的情況下,所能提供的單位時間服務能力是有限的。假如超過服務能力,一般會造成整個接口服務停頓,或者應用 Crash,或者帶來連鎖反應,將延遲傳遞給服務調用方造成整個系統的服務能力喪失。有必要在服務能力超限的情況下 Fail Fast。

另外,根據排隊論,由於 API 接口服務具有延遲隨着請求量提升迅速提升的特點,為了保證 SLA 的低延遲,需要控制單位時間的請求量。這也是 Little’s law 所說的。

1.1、限流算法

常見的限流算法有:令牌桶、漏桶。計數器也可以用來進行粗暴限流實現。

限流算法之漏桶算法、令牌桶算法

令牌桶算法

令牌桶算法,是一個存放固定容量令牌的桶,按照固定速率往桶里添加令牌。令牌桶算法的描述如下:

  • 假設限制2r/s,則按照500毫秒的固定速率往桶內添加令牌。
  • 桶中最多存放b個令牌,當桶滿時,新添加的令牌會被丟棄或拒絕。
  • 當一個n個字節大小的數據包到達,將從桶中刪除n個令牌,接着數據包被發送到網絡上。
  • 如果桶中的令牌不足n個,則不會刪除令牌,且該數據包被限流(要么丟棄,要么在緩沖區等待)。

漏桶算法

漏桶作為計量工具時,可以用於流量整形和流量控制,漏桶算法的描述如下:

  • 一個固定容量的漏桶,按照常量固定速率流出水滴。
  • 如果桶是空的,則不需流出水滴。
  • 可以以任意速率流入水滴到漏桶。
  • 如果流入水滴超過了桶的容量,則流入的水滴溢出了(被丟棄),而漏桶容量是不變的。

令牌桶和漏桶對比

  • 令牌桶是按照固定速率往桶中添加令牌,請求是否被處理需要看桶中令牌是否足夠,當令牌數減為零時,則拒絕新的請求。
  • 漏桶則是按照常量固定速率流出請求,請求流入速率任意,當流入的請求數累積到漏桶容量時,則新流入的請求被拒絕。
  • 令牌桶限制的是平均流入速率(允許突發請求,只要有令牌就可以處理,支持一次拿多個令牌),並允許一定程序的突發流量。
  • 漏桶限制的是常量流出速率(即流出速率是一個固定常量值,比如都是1的速率流出,而不能一次是1,下次又是2),從而平滑突發流入速率。
  • 令牌桶允許一定程序的突發,而漏桶主要目的是平滑流入速率。
  • 兩個算法實現可以一樣,但是方向是相反的,對於相同的參數得到的限流效果是一樣的。

常見的限流方式有:限制總並發數(數據庫連接池、線程池)、限制瞬時並發數(如Nginx的limit_conn模塊)、限制時間窗口的平均速率(如Guava的RateLimiter、Nginx的limit_req模塊)、限制遠程接口的調用速率限制MQ的消費速率等。從應用的層面上來講,又可以分為:接入層限流應用層限流分布式限流等。

1.2、應用級限流

1.2.1、限制總並發/連接/請求數

對於一個應用系統來說,一定會有極限並發/請求數,即總有一個TPS/QPS閾值,如果超了閾值,則系統就會不影響用戶請求或響應得非常慢。因此,我們最好進行過載保護,以防止大量請求涌入擊垮系統。

如Tomcat的Connector中的以下幾個參數:

  • acceptCount:如果Tomcat的線程都忙於響應,新來的連接將會進入隊列,如果超出隊列大小,則會拒絕連接。
  • maxConnections:瞬時最大連接數,超出的會排隊等待。
  • maxThreads:Tomcat能啟動用來處理請求的最大線程數,如果請求處理量一直遠遠大於線程數,則會引起響應變慢甚至會僵死。

類似於Tomcat配置最大連接數等參數,Redis和MySQL也有相關的配置。

如MQ(max_connections)、Redis(tcp-backlog)都會有類似的限制連接數的配置。

1.2.2、限制總資源數

如果有的資源是稀缺資源(如數據庫連接、線程),而且可能有多個系統都會去使用它,那么需要加以限制。可以使用池化技術來限制總資源數,如連接池、線程池。假設分配給每個應用的數據庫連接是100,那么本應用最多可以使用100個資源,超出則可以等待或者拋異常。

1.2.3、限制某個接口的總並發/請求數

如果接口可能會有並發流量,但又擔心訪問量太大造成奔潰,那么久需要限制這個接口的總並發/請求數了。因為粒度比較細,可以為每個接口設置相應的閾值。可以使用Java中的AtomicLong或者Semaphore進行限流。Hystrix在信號量模式下也使用Semaphore限制每個接口的總請求數。

一種實現方式如下:

try {
    if (atomic.incrementAndGet() > 限流數) {
        //拒絕請求
    }
    //處理請求
} finally {
    atomic.decrementAndGet();
}

1.2.4、限流接口每秒的請求數

限制每秒的請求數,可以使用Guava的Cache來存儲計數器,設置過期時間為2S(保證能記錄1S內的計數)。下面代碼使用當前時間戳的秒數作為key進行統計,這種限流的方式也比較簡單。

LoadingCache<Long, AtomicLong> counter =
        CacheBuilder.newBuilder()
        .expireAfterWrite(2, TimeUnit.SECONDS)
        .build(new CacheLoader<Long, AtomicLong>() {
            @Override
            public AtomicLong load(Long aLong) throws Exception {
                return new AtomicLong(0);
            }
        });

long limit = 1000;
while (true) {
    long currentSeconds = System.currentTimeMillis() / 1000;
    if (counter.get(currentSeconds).incrementAndGet() > limit) {
        logger.info("被限流了:{}", currentSeconds);
        continue;
    }
    //業務處理
}

上面介紹的“限制某個接口的總並發/請求數”和"限流接口每秒的請求數"限流方案都是對於單機接口的限流,當系統進行多機部署時,就無法實現整體對外功能的限流了。當然這也看具體的應用場景,如果平行的應用服務器需要共享限流閥值指標,可以使用Redis作為共享的計數器。

1.2.5、平滑限流某個接口的請求數

Guava RateLimiter提供的令牌桶算法可用於平滑突發限流(SmoothBursty)和平滑預熱限流(SmoothWarmingUp)實現。

1.2.5.1、平滑突發限流(SmoothBursty)

平滑突發限流顧名思義,就是允許突發的流量進入,后面再慢慢的平穩限流。下面給出幾個Demo

# 創建了容量為5的桶,並且每秒新增5個令牌,即每200ms新增一個令牌
RateLimiter limiter = RateLimiter.create(5); 
while (true) {
    // 獲取令牌(可以指定一次獲取的個數),獲取后可以執行后續的業務邏輯
    System.out.println(limiter.acquire());
}

上面代碼執行結果如下所示:

0.0
0.188216
0.191938
0.199089
0.19724
0.19997

上面while循環中執行的limiter.acquire(),當沒有令牌時,此方法會阻塞。實際應用當中應當使用tryAcquire()方法,如果獲取不到就直接執行拒絕服務。

下面在介紹一下中途休眠的場景:

RateLimiter limiter = RateLimiter.create(2);
System.out.println(limiter.acquire());
Thread.sleep(1500L);
while (true) {
    System.out.println(limiter.acquire());
}

上面代碼執行結果如下:

0.0
0.0
0.0
0.0
0.499794
0.492334

從上面結果可以看出,當線程休眠時,會囤積令牌,以給后續的acquire()使用。但是上面的代碼只能囤積1S的令牌(也就是2個),當睡眠時間超過1.5S時,執行結果還是相同的。

1.2.5.2、平滑預熱限流(SmoothWarmingUp)

平滑突發限流有可能瞬間帶來了很大的流量,如果系統扛不住的話,很容易造成系統掛掉。這時候,平滑預熱限流便可以解決這個問題。創建方式:

// permitsPerSecond表示每秒鍾新增的令牌數,warmupPeriod表示從冷啟動速率過渡到平均速率所需要的時間間隔
RateLimiter.create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)

例如:

RateLimiter limiter = RateLimiter.create(5, 1000, TimeUnit.MILLISECONDS);
for (int i = 1; i < 5; i++) {
    System.out.println(limiter.acquire());
}
Thread.sleep(1000L);
for (int i = 1; i < 50; i++) {
    System.out.println(limiter.acquire());
}

執行結果如下:

0.0
0.513566
0.353789
0.215167
0.0
0.519854
0.359071
0.219118
0.197874
0.197322
0.197083
0.196838

上面結果可以看出來,平滑預熱限流的耗時是慢慢趨近平均值的。

節流

有時候我們想在特定時間窗口內對重復的相同事件最多只處理一次,或者想限制多個連續相同事件最小執行時間間隔,那么可使用節流(Throttle)實現,其防止多個相同事件連續重復執行。節流主要有如下幾種用法:throttleFirst、throttleLast、throttleWithTimeout。

1.3、限流方法總結

常見的 Rate limiting 的實現方式

1.3.1、在Proxy層限流

Proxy 層的實現,針對部分 URL 或者 API 接口進行訪問頻率限制

1.3.1.1、Nginx 中限流

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

server {
    location /search/ {
        limit_req zone=one burst=5;
    }

詳細參見:《Nginx模塊 ngx_http_limit_req_module 限制請求速率

1.3.1.2、Haproxy 提供限流

詳細參見:Haproxy Rate limit 模塊

1.3.2、Java、Scala JVM 系應用層實現

例如:在提供給業務方的Controller層進行控制。 

1.3.2.1使用guava提供工具庫里的RateLimiter類(內部采用令牌捅算法實現)進行限流

<!--核心代碼片段-->
private RateLimiter rateLimiter = RateLimiter.create(400);//400表示每秒允許處理的量是400
 if(rateLimiter.tryAcquire()) {
   //短信發送邏輯可以在此處

 }

Google Guava 提供了一個 RateLimiter 實現。使用方式簡單明了,在自己的應用中簡單封裝即可,放到 HTTP 服務或者其他邏輯接口調用的前端。

詳細參見:Google Guava RateLimiter

1.3.2.2使用Java自帶delayqueue的延遲隊列實現(編碼過程相對麻煩,此處省略代碼)

1.3.2.3使用Redis實現,存儲兩個key,一個用於計時,一個用於計數。請求每調用一次,計數器增加1,若在計時器時間內計數器未超過閾值,則可以處理任務

if(!cacheDao.hasKey(API_WEB_TIME_KEY)) {
      cacheDao.putToValue(API_WEB_TIME_KEY,0,(long)1, TimeUnit.SECONDS);
}       

if(cacheDao.hasKey(API_WEB_TIME_KEY)&&cacheDao.incrBy(API_WEB_COUNTER_KEY,(long)1) > (long)400) { LOGGER.info("調用頻率過快"); } //短信發送邏輯

這個在 Redis 官方文檔有非常詳細的實現。一般適用於所有類型的應用,比如 PHP、Python 等等。Redis 的實現方式可以支持分布式服務的訪問頻率的集中控制。Redis 的頻率限制實現方式還適用於在應用中無法狀態保存狀態的場景。

具體實現

A、在Controller層設置兩個全局key,一個用於計數,另一個用於計時

//用於計時
private static final String API_WEB_TIME_KEY = "time_key";
//用於計數
private static final String API_WEB_COUNTER_KEY = "counter_key";

2、對時間key的存在與否進行判斷,並對計數器是否超過閾值進行判斷

//1秒的時間已經過了,重新對time_key賦值,同時初始化計數變量
if(!cacheDao.hasKey(API_WEB_TIME_KEY)) {
     cacheDao.putToValue(API_WEB_TIME_KEY,0,(long)1, TimeUnit.SECONDS);
     cacheDao.putToValue(API_WEB_COUNTER_KEY,0,(long)2, TimeUnit.SECONDS);//時間到就重新初始化為0
}

//如果大於最大請求數量,直接打logger,返回
if(cacheDao.hasKey(API_WEB_TIME_KEY)&&cacheDao.incrBy(API_WEB_COUNTER_KEY,(long)1) > (long)400) {
       LOGGER.info("調用頻率過快");
       return;
}

       //短信發送邏輯

1.3.2.4通過Hytrix限流

見《服務容錯保護斷路器Hystrix之一:入門示例介紹(springcloud引入Hystrix的兩種方式)

1.3.3、數據庫限流

        數據庫連接數的限制

1.4、分布式限流

分布式限流最關鍵的是要將限流服務做成原子化,而解決方案可以使用Redis+Lua或者Nginx+Lua技術進行實現,通過這兩種技術可以實現高並發和高性能。

 見《分布式限流之一:redis+lua 實現分布式令牌桶,高並發限流

 

2 降級

 

當訪問量劇增、服務出現問題(如響應時間長或者不響應)或非核心服務影響到核心服務的性能時,仍然需要保證服務還是可用的,即使是有損服務。系統可以根據一些關鍵參數進行自動降級,也可以配合開關實現人工降級。

降級的最終目的是保證核心服務可用,即使是有損的。而且有些服務是無法降級的(如加入購物車、結算)。降級也需要根據系統的吞吐量、響應時間、可用率等條件進行手工降級或自動降級。

降級預案

在進行降級之前要對系統進行梳理,看看系統是不是可以丟卒保車,從而梳理出哪些必須誓死保護,哪些可以降級。比如,可以參考日志級別設置預案:

  • 一般:比如,有些服務偶爾因為網絡抖動或者服務正在上線而超時,可以自動降級。
  • 警告:有些服務在一段時間內成功率有波動(如在95%~100%之間),可以自動降級或人工降級並發送告警。
  • 錯誤:比如,可用率低於90%,或者數據庫連接池用完了,或者訪問量突然猛增到系統能承受的最大閾值,此時可以根據情況自動降級或人工降級。
  • 嚴重錯誤:比如,因為特殊原因數據出現錯誤,此時需要緊急人工降級。

降級分類

  • 降級按照是否自動化可分為:自動開關降級和人工開關降級。
  • 降級按照功能可分為:讀服務降級和寫服務降級。
  • 降級按照處於的系統層次可分為:多級降級。

降級的功能點主要從服務器端鏈路考慮,即根據用戶訪問的服務調用鏈路來梳理哪里需要降級。

  • 頁面降級。
  • 頁面片段降級。
  • 頁面異步請求降級。
  • 服務功能降級。
  • 讀降級。比如多級緩存模式,如果后端服務有問題,則可以降級為只讀緩存,這種方式是用於對讀一致性要求不高的場景。
  • 寫降級。比如秒殺搶購我們可以只進行Cache的更新,然后異步扣減庫存到DB,保證最終一致性即可,此時可以將DB降級為Cache。
  • 爬蟲降級。
  • 風控降級。

自動開關降級

自動降級是根據系統負載、資源使用情況、SLA等指標進行降級。

  • 超時降級
  • 統計失敗次數降級
  • 故障降級
  • 限流降級

人工開關降級

比如,上線新功能時進行灰度測試,當新服務有問題時通過開關切換回老服務。

自動降級

我們這邊將系統遇到“危險”時采取的整套應急方案和措施統一稱為降級或服務降級。想要幫助服務做到自動降級,需要先做到如下幾個步驟:

  1. 可配置的降級策略:降級策略=達到降級的條件+降級后的處理方案,策略一定得可配置,因為不同的服務對服務的質量定義不一樣,降級的方案也將不一樣。
  2. 可識別的降級邊界:一定要精確的知道需要對誰進行降級,可以是一個對外服務、對下游的一個依賴或者是內部一段處理邏輯。降級邊界主要用來植入降級邏輯。
  3. 數據采集:是否達到降級條件依賴於采集的數據,這些數據可以是當前某段時間的數據,也可以是很長一段時間的歷史數據。
  4. 行為干預:進入降級狀態后將會對正常的業務流程產生干預,可能是限流、熔斷,也可能是同步流程變為異步流程等(比如發送MQ的變成oneway的形式)等。
  5. 結果干預:是返回null,還是默認值,還是流程上的同步改異步等。
  6. 快速恢復:即如何從降級狀態變回正常狀態,這也需要達到某些條件。

3 超時與重試機制

如果應用不設置超時,可能會導致請求響應慢,慢請求累積導致連鎖效應,甚至造成應用雪崩。而有些中間件或框架在超時后會進行重試(如設置超時自動重試兩次),讀服務天然適合重試,但寫服務大多不能重試(如寫訂單,如果寫服務是冪等的,則重試是允許的),重試次數太多會導致多倍請求流量,即模擬了DDoS攻擊,后果可能是災難。因此,務必設置合理的重試機制,並且應該和熔斷、快速失敗機制配合。在進行代碼Review時,一定記得Review超時與重試機制。

對於非冪等寫服務應避免重試,或者考慮提前生成唯一流水號來保證寫服務操作通過判斷流水號來實現冪等操作。

在進行數據庫/緩存服務器操作時,要經常檢查慢查詢,慢查詢通常是引起服務出問題的罪魁禍首。也要考慮在超時嚴重時,直接將該服務降級,待該服務修復后再取消降級。

4 回滾機制

回滾是指當程序或數據出錯時,將程序或數據恢復到最近的一個正確版本的行為。通過回滾機制可保證系統在某些場景下的高可用。常見的回滾如下:

  • 事務回滾
  • 代碼庫回滾
  • 部署版本回滾
  • 數據版本回滾
  • 靜態資源版本回滾。

5 壓測與預案

在大促來臨之前,研發人員需要對現有系統進行梳理,發現系統瓶頸和問題,然后進行系統調優來提升系統的健壯性和處理能力。一般通過系統壓測來發現系統瓶頸和問題,然后進行系統優化和容災(系統參數調整、單機房容災、多機房容災等)。

系統壓測

壓測一般是指性能壓力測試,用來評估系統的穩定性和性能,通過壓測數據進行系統容量評估,從而決定是否需要進行擴容或縮容。

壓測之前要有壓測方案(如壓測接口、並發量、壓測策略[突發、逐步加壓、並發量]、壓測指標[機器負載、QPS/TPS]、響應時間[平均、最小、最大]、成功率、相關參數[JVM參數、壓縮參數]等),最后根據壓測報告分析的結果進行系統優化和容災。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM