熔斷、降級、限流


熔斷(circuit break

  股票交易:股票市場的交易時間中,當價格波動的幅度達到一個限定的目標(熔斷點)時,對其暫停交易一段時間的機制。

  保險絲:當電路發生故障或異常時,伴隨着電流不斷升高,並且升高的電流有可能損壞電路中的某些重要器件,也有可能燒毀電路甚至造成火災。若電路中正確地安置了保險絲,那么保險絲就會在電流異常升高到一定的高度和熱度的時候,自身熔斷切斷電流,從而起到保護電路安全運行的作用

  服務熔斷的作用類似於保險絲,當下游某服務出現不可用或響應超時的情況時,上游服務為了保護系統整體的可用性,暫時停止對該服務的調用。

    這種局部犧牲,保全整體的措施就叫做熔斷。

 

  熔斷在互聯網中的理解:

    當異常幅度達到設定的閥值后觸發的系統保護機制

    保護機制會將某部分能力關閉,以保證大部分能力的正常

    這種機制是有損的,但是利大於弊

 

  如:前系統中有A,B,C三個服務,服務A是上游,服務B是中游,服務C是下游。它們的調用鏈如下 A——>B——>C

  當下游服務C因為某些原因變得不可用,積壓了大量請求,服務B的請求線程也隨之阻塞。線程資源耗盡,使得B服務也變得不可用。緊接着服務A也變得不可用,整個調用鏈路被拖垮。

  這種調用鏈路的連鎖故障,叫做雪崩。

  這種情況下,就需要使用熔斷機制來挽救整個系統。熔斷分為熔斷開啟和熔斷恢復:

    1、熔斷開啟

    在固定時間窗口內,接口調用超時比率達到一個閾值,會開啟熔斷。進入熔斷狀態后,后續對該服務接口的調用不再經過網絡,直接執行本地的默認方法,達到服務降級的效果。

    2、熔斷恢復

    熔斷不可能是永久的。當經過了規定時間之后,服務將從熔斷狀態恢復過來,再次接受調用方的遠程調用。

 

   熔斷實現

    Spring Cloud Hystrix是基於Netflix的開源框架Hystrix實現,該框架實現了服務熔斷、線程隔離等一系列服務保護功能。

對於熔斷機制的實現,Hystrix設計了三種狀態:

    1.熔斷關閉狀態(Closed)

      服務沒有故障時,熔斷器所處的狀態,對調用方的調用不做任何限制。

       2.熔斷開啟狀態(Open)

      在固定時間窗口內(Hystrix默認是10秒),接口調用出錯比率達到一個閾值(Hystrix默認為50%),會進入熔斷開啟狀態。進入熔斷狀態后,后續對該服務接口的調用不再經過網絡,直接執行本地的fallback方法。

    3.半熔斷狀態(Half-Open)

      在進入熔斷開啟狀態一段時間之后(Hystrix默認是5秒),熔斷器會進入半熔斷狀態。所謂半熔斷就是嘗試恢復服務調用,允許有限的流量調用該服務,並監控調用成功率。如果成功率達到預期,則說明服務已恢復,進入熔斷關閉狀態;如果成功率仍舊很低,則重新進入熔斷關閉狀態。

 

 

 

降級

  為了預防某些功能出現負荷過載或者響應慢的情況,在其內部暫時舍棄一些非核心接口和數據的請求(如評論、積分),而直接返回一個提前准備好的 fallback(退路) 錯誤處理信息。釋放CPU和內存資源,以保證整個系統的穩定性和可用性。

 

  服務降級分容錯降級和屏蔽降級兩種模式

  1、屏蔽降級:

    在一個實例中,服務之間盡管可以通過線程池隔離方式實現資源隔離,但是100%的隔離是不現實的。特別是對緩存、網絡IO,磁盤IO、內存、CPU、數據庫連接資源等公共依賴無法隔離,在業務高峰期或者大促時,服務之間往往存在激烈的競爭,導致核心服務(如雙十一下單)運行質量下降,影響系統的穩定運行和客戶體驗。

    屏蔽降級需要手動操作,當外界的觸發條件達到某個臨界值時,由運維/開發人員決策,通過服務治理控制台,對某個服務進行人工降級操作。當系統壓力恢復正常時,可以對已經屏蔽降級的服務恢復正常。

    降級策略:返回null、返回指定異常、執行本地mock接口實現

  2、容錯降級:

    當非核心服務不可用時,可以對故障服務做業務邏輯放通。當然,容錯降級不僅僅用於業務放通,它也常用於服務提供方在執行容錯邏輯,包括RPC異常、service異常等。

    容錯降級策略:

      1)將異常轉義

      2)將異常屏蔽掉,直接執行本地模擬接口實現類,返回mock接口的執行結果

 

熔斷與降級比較:

  相同點 不同點
熔斷

目標一致 都是從可用性和可靠性出發,為了防止系統崩潰;

用戶體驗類似,最終都讓用戶體驗到的是某些功能暫時不可用;

觸發原因不同 服務熔斷一般是某個服務(下游服務)故障引起
降級 服務降級一般是從整體負荷考慮;

 

 

 

 

限流(flow limiting)

  什么是限流?

    即,流量控制,指的是限制到達系統的並發請求數,使得系統能夠正常的處理部分用戶的請求,來保障系統的穩定性。

  為什么要限流?

    即保證系統的穩定性。

    比如秒殺等重大活動,流量激增,后端服務的處理能力是有限的,如果不能處理好突發流量,后端服務很容易被打垮。

    另外一些爬蟲或惡意請求,因此我們對外暴露的服務都要以最大的惡意去防備調用者

 

  常見限流算法:

    計數限流:

      最簡單的限流算法,邏輯是維護一個計數器(根據用戶id/ip作為限流對象),后台配置閥值(如1000次/秒),處理一個請求,計數器+1,請求處理結束,計數器-1,每次請求進來的時候看看計數器的值,如果超過閥值就拒絕請求。

      優點:實現簡單,計數器單機可以使用Java中的Atomic原子類,分布式可使用Redis的incr。

      缺點:頂不住超過閥值的瞬時流量沖擊,且一般的限流都是為了限制在指定時間間隔內的訪問量,因此出現了固定窗口限流算法。」

      使用場景:適用於做API限流,比如對外提供ip定位查詢服務api,天氣查詢api等,可以根據ip做粒度控制,防止惡意刷接口造成異常,也適用於提供API查詢服務做配額限制,一般限流后會對請求做丟棄處理。

      

    固定窗口限流:

      它相比於計數限流主要是多了個時間窗口的概念,計數器每過一個時間窗口就重置。規則如下:

        ①:請求次數小於閾值,允許訪問並且計數器 +1;

        ②:請求次數大於閾值,拒絕訪問;

        ③:這個時間窗口過了之后,計數器清零;      


      固定窗口臨界問題:

        假設每秒鍾允許100個請求,時間窗口間隔為1秒鍾。在第1s的的0.6秒涌入100個請求,然后計數器清零,此時在1.1秒又涌入100個請求。雖然在窗口內的計數沒超過閥值,但是全局來看在0.6-1.1秒內涌入了200個請求,超過了允許的閥值。

        為解決這個問題,引入了滑動時間窗口限流算法。

                    

    滑動窗口限流:

      滑動窗口限流解決固定窗口臨界值的問題,可以保證在任意時間窗口內都不會超過閾值。

      相對於固定窗口,滑動窗口除了需要引入計數器之外還需要記錄時間窗口內每個請求到達的時間點,因此對內存的占用會比較多

      規則如下,假設時間窗口為 1 秒:

        ①:記錄每次請求的時間

        ②:統計每次請求的時間 至 往前推1秒這個時間窗口內請求數,並且 1 秒前的數據可以刪除

        ③:統計的請求數小於閾值就記錄這個請求的時間,並允許通過,反之拒絕

 

      問題:和固定窗口一樣都無法解決短時間內集中流量的沖擊。

      滑動窗口可以使用Redis的zset存儲,key使用用戶id/ip等,value和score都使用毫秒時間戳,利用zremrangebyscore 刪除時間窗口之外的數據,再用 ZCARD計數

      具體思路:每一個行為到來時,都維護一次時間窗口,將時間窗口之外的記錄全部清理掉,只保留窗口內的記錄。zset中只有score值非常重要,value沒有特別的意義,只要保證它是唯一的就行了。

 

    漏桶算法:

      即,模擬漏桶,水滴持續滴入漏桶中,底部定速流出。如果水滴滴入的速率大於流出的速率,當存水超過桶的大小的時候就會溢出。

      水滴對應的就是請求,桶內容量就是我們允許的流量閥值。流出對應的是處理請求。

      規則如下:

        ①:請求來了放入桶中

        ②:桶內請求量滿了拒絕請求

        ③:服務定速從桶內拿請求處理      

      和消息隊列類似。一般而言,漏桶也是由隊列來實現的,處理不過來的請求就排隊,隊列滿了就開始拒絕請求。和線程池類似。

      缺點:出口處理是勻速的,面對短時間大量的突發請求,即使負載壓力不大,請求仍需要在隊列等待處理。

      Redis 4.0 提供了限流模塊:Redis-Cell,該模塊使用了漏桶算法,並提供了原子的限流指令:cl.throttle。

        CL.THROTTLE user123 15 30 60 1
                ▲     ▲  ▲  ▲ ▲
                 |        |    |     | └───── apply 1 operation (default if omitted)
                 |        |   └─┴─────── 30 operations / 60 seconds
                 |       └───────────── 15 max_burst
                └─────────────────── key “user123”

        返回:

        127.0.0.1:6379> CL.THROTTLE user123 15 30 60
          1) (integer) 0   # 0 means allowed; 1 means denied   0表示允許,1表示拒絕
          2) (integer) 16  # total quota (`X-RateLimit-Limit` header)  漏斗容量 capacity
          3) (integer) 15  # remaining quota (`X-RateLimit-Remaining`)  漏斗剩余空間left_quota
          4) (integer) -1  # if denied, time until user should retry (`Retry-After`)  如果被拒絕了,需要多長時間后再試(表示多久后漏桶有空間)
          5) (integer) 2   # time until limit resets to maximum capacity (`X-RateLimit-Reset`)  多長時間后,漏桶完全空出來,單位秒

 

    令牌桶算法:

      令牌桶其實和漏桶的原理類似,只不過漏桶是定速地流出,而令牌桶是定速地往桶里塞入令牌,然后請求只有拿到了令牌才能通過,之后再被服務器處理 

      當然令牌桶的大小也是有限制的,假設桶里的令牌滿了之后,定速生成的令牌會丟棄

      規則:

        ①:定速的往桶內放入令牌

        ②:令牌數量超過桶的限制,丟棄

        ③:請求來了先向桶內索要令牌,索要成功則通過被處理,反之拒絕 

 

      和Java的Semaphore信號量類似。

      優點:在應對突發流量時,可以一次全部拿走所有令牌。

      注意:上線時令牌桶內需要先預熱放入令牌,否則請求過來會直接被拒絕。令牌可以放到Redis中(為了原子性,使用redis+lua腳本)。

       

 

  限流組件:

    1、阿里的Sentinel限流工具:https://github.com/alibaba/Sentinel ,勻速排隊限流策略,采用漏桶算法。

    2、Google Guava 提供的限流工具類 RateLimiter,是基於令牌桶實現的,並且擴展了算法,支持預熱功能

    3、Nginx 中的限流模塊 limit_req_zone,采用了漏桶算法

    4、OpenResty 中的 resty.limit.req庫

  

 

附錄:

  Java令牌桶限流代碼:

import com.yang.custom.redis.common.utils.RedisUtil;
import org.springframework.stereotype.Service;

import java.time.Instant;
import java.util.Arrays;
import java.util.List;

/**
 * soul網關中的令牌桶限流
 * KEY[1]:tokenKey=upgrade:tokens
 * KEY[2]: timestampKey=upgrade:timestamp
 * <p>
 * ARGV[1]:每秒往桶內投放token的頻率
 * ARGV[2]:桶容量
 * ARGV[3]:當前時間
 * ARGV[4]:請求獲取token數量
 */
@Service
public class TokenBucketRateLimiter2 {

    public void isAllow() throws InterruptedException {
        // 限流關鍵lua腳本
        String script = "local tokens_key = KEYS[1]\n" +
                "local timestamp_key = KEYS[2]\n" +
                "\n" +
                "local rate = tonumber(ARGV[1])\n" +
                "local capacity = tonumber(ARGV[2])\n" +
                "local now = tonumber(ARGV[3])\n" +
                "local requested = tonumber(ARGV[4])\n" +
                "\n" +
                "local fill_time = capacity/rate\n" +
                "local ttl = math.floor(fill_time*2)\n" +
                "\n" +
                "local last_tokens = tonumber(redis.call(\"get\", tokens_key))\n" +
                "if last_tokens == nil then\n" +
                "  last_tokens = capacity\n" +
                "end\n" +
                "\n" +
                "local last_refreshed = tonumber(redis.call(\"get\", timestamp_key))\n" +
                "if last_refreshed == nil then\n" +
                "  last_refreshed = 0\n" +
                "end\n" +
                "\n" +
                "local delta = math.max(0, now-last_refreshed)\n" +
                "local filled_tokens = math.min(capacity, last_tokens+(delta*rate))\n" +
                "local allowed = filled_tokens >= requested\n" +
                "local new_tokens = filled_tokens\n" +
                "local allowed_num = 0\n" +
                "if allowed then\n" +
                "  new_tokens = filled_tokens - requested\n" +
                "  allowed_num = 1\n" +
                "end\n" +
                "\n" +
                "redis.call(\"setex\", tokens_key, ttl, new_tokens)\n" +
                "redis.call(\"setex\", timestamp_key, ttl, now)\n" +
                "\n" +
                "return allowed_num";

        List<String> keys = Arrays.asList("upgrade:tokens", "upgrade:timestamp");

        // 1允許通過,0被限流
        Long result = RedisUtil.eval(script, Long.class, keys, "5", "50", String.valueOf(Instant.now().getEpochSecond()), "1");

    }

}

   令牌桶限流lua腳本:

local tokens_key = KEYS[1]
local timestamp_key = KEYS[2]
--redis.log(redis.LOG_WARNING, "tokens_key " .. tokens_key)

local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])

local fill_time = capacity/rate  --放滿需要時間
local ttl = math.floor(fill_time*2) --令牌生存時間

local last_tokens = tonumber(redis.call("get", tokens_key))
if last_tokens == nil then
  last_tokens = capacity
end

--上次刷新時間
local last_refreshed = tonumber(redis.call("get", timestamp_key))
if last_refreshed == nil then
  last_refreshed = 0
end

local delta = math.max(0, now-last_refreshed)
local filled_tokens = math.min(capacity, last_tokens+(delta*rate)) --需要補多少token
local allowed = filled_tokens >= requested --要填充的大於獲取的,則通過
local new_tokens = filled_tokens
local allowed_num = 0
if allowed then
  new_tokens = filled_tokens - requested
  allowed_num = 1
end

redis.call("setex", tokens_key, ttl, new_tokens)
redis.call("setex", timestamp_key, ttl, now)

return { allowed_num, new_tokens }
View Code

 

 

END.


免責聲明!

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



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