熔斷和降級都是系統自我保護的一種機制,但二者又有所不同,它們的區別主要體現在以下幾點:
- 概念不同
- 觸發條件不同
- 歸屬關係不同
1.概念不同
1.1 熔斷概念
「熔斷」一詞早期來自股票市場。熔斷(Circuit Breaker)也叫自動停盤機制,是指當股指波幅達到規定的熔斷點時,交易所為控制風險採取的暫停交易措施。比如 2020 年 3 月 9 日,紐約股市開盤出現暴跌,隨後跌幅達到 7% 上限,觸發熔斷機制,停止交易 15 分中,恢復交易後跌幅有所減緩。
而熔斷在程序中,表示「斷開」的意思。如發生了某事件,程序為了整體的穩定性,所以暫時(斷開)停止服務一段時間,以保證程序可用時再被使用。
如果沒有熔斷機制的話,會導致聯機故障和服務雪崩等問題,如下圖所示:
1.2 降級概念
降級(Degradation)降低級別的意思,它是指程序在出現問題時,仍能保證有限功能可用的一種機制。
比如電商交易系統在雙 11 時,使用的人比較多,此時如果開放所有功能,可能會導致系統不可用,所以此時可以開啟降級功能,優先保證支付功能可用,而其他非核心功能,如評論、物流、商品介紹等功能可以暫時關閉。
所以,從上述信息可以看出:降級是一種退而求其次的選擇,而熔斷卻是整體不可用。
2.觸發條件不同
不同框架的熔斷和降級的觸發條件是不同的,本文咱們以經典的 Spring Cloud 組件 Hystrix 為例,來說明觸發條件的問題。
2.1 Hystrix 熔斷觸發條件
默認情況 hystrix 如果檢測到 10 秒內請求的失敗率超過 50%,就觸發熔斷機制。之後每隔 5 秒重新嘗試請求微服務,如果微服務不能響應,繼續走熔斷機制。如果微服務可達,則關閉熔斷機制,恢復正常請求。
2.2 Hystrix 降級觸發條件
默認情況下,hystrix 在以下 4 種條件下都會觸發降級機制:
- 方法拋出 HystrixBadRequestException
- 方法調用超時
- 熔斷器開啟攔截調用
- 線程池或隊列或信號量已滿
雖然 hystrix 組件的觸發機制,不能代表所有的熔斷和降級機制,但足矣說明此問題。
3.歸屬關係不同
**熔斷時可能會調用降級機制,而降級時通常不會調用熔斷機制。**因為熔斷是從全局出發,為了保證系統穩定性而停用服務,而降級是退而求其次,提供一種保底的解決方案,所以它們的歸屬關係是不同(熔斷 > 降級)。
題外話
當然,某些框架如 Sentinel,它早期在 Dashboard 控制台中可能叫「降級」,但在新版中新版本又叫「熔斷」,如下圖所示:
但在兩個版本中都是通過同一個異常類型 DegradeException 來監聽的,如下代碼所示:
所以,在 Sentinel 中,熔斷和降級功能指的都是同一件事,也側面證明了「熔斷」和「降級」概念的相似性。但我們要知道它們本質上是不同的,就像兩個雙胞胎,不能因為他們長得像,就說他們是同一個人。
當用戶請求 A、P、H、I 四個服務獲取數據時,在正常流量下系統穩定運行,如果某天系統進來大量流量,其中服務 I 出現 CPU、內存佔用過高等問題,結果導致服務 I 出現延遲、響應過慢,隨著請求的持續增加,服務 I 承受不住壓力導致內部錯誤或資源耗盡,一直不響應,此時更糟糕的是其他服務對 I 有依賴,那麼這些依賴 I 的服務一直等待 I 的響應,也會出現請求堆積、資源佔用,慢慢擴散到所有微服務,引發雪崩效應。
基本的容錯模式
常見的容錯模式主要包含以下幾種方式:
1.主動超時:Http請求主動設置一個超時時間,超時就直接返回,不會造成服務堆積
2.限流:限制最大並發數
3.熔斷:當錯誤數超過閾值時快速失敗,不調用後端服務,同時隔一定時間放幾個請求去重試後端服務是否能正常調用,如果成功則關閉熔斷狀態,失敗則繼續快速失敗,直接返回。(此處有個重試,重試就是彈性恢復的能力)
4.隔離:把每個依賴或調用的服務都隔離開來,防止級聯失敗引起整體服務不可用
5.降級:服務失敗或異常後,返回指定的默認信息
服務降級
由於爆炸性的流量衝擊,對一些服務進行有策略的放棄,以此緩解系統壓力,保證目前主要業務的正常運行。它主要是針對非正常情況下的應急服務措施:當此時一些業務服務無法執行時,給出一個統一的返回結果。
服務熔斷
熔斷這一概念來源於電子工程中的斷路器(Circuit Breaker)。在互聯網系統中,當下游服務因訪問壓力過大而響應變慢或失敗,上游服務為了保護系統整體的可用性,可以暫時切斷對下游服務的調用。
服務熔斷與服務降級比較
服務熔斷對服務提供了proxy,防止服務不可能時,出現串聯故障(cascading failure),導致雪崩效應。
服務熔斷一般是某個服務(下游服務)故障引起,而服務降級一般是從整體負荷考慮。
1.共性:
目的 -> 都是從可用性、可靠性出發,提高系統的容錯能力。
最終表現->使某一些應用不可達或不可用,來保證整體系統穩定。
粒度 -> 一般都是服務級別,但也有細粒度的層面:如做到數據持久層、只許查詢不許增刪改等。
自治 -> 對其自治性要求很高。都要求具有較高的自動處理機制。
2.區別:
觸發原因 -> 服務熔斷通常是下級服務故障引起;服務降級通常為整體系統而考慮。
管理目標 -> 熔斷是每個微服務都需要的,是一個框架級的處理;而服務降級一般是關注業務,對業務進行考慮,抓住業務的層級,從而決定在哪一層上進行處理:比如在IO層,業務邏輯層,還是在外圍進行處理。
實現方式 -> 代碼實現中的差異。
服務熔斷恢復需注意的問題
如果服務是冪等性的,則恢復重試不會有問題;而如果服務是非冪等性的,則重試會導致數據出現問題。
Java 實現限流
1 滑動窗口
public class WindowLimiterComponent implements LimiterComponent {
/**
* 隊列id和隊列的映射關係,隊列裡面存儲的是每一次通過時候的時間戳,這樣可以使得程序里有多個限流隊列
*/
private final Map<String, List<Long>> MAP = new ConcurrentHashMap<>();
/**
* 限制次數
*/
private final int count;
/**
* 時間窗口大小
*/
private final long timePeriod;
public WindowLimiterComponent(int count, long timePeriod) {
this.count = count;
this.timePeriod = timePeriod;
}
/**
* 滑動時間窗口限流演算法
* 在指定時間窗口,指定限制次數內,是否允許通過
*
* @param id 隊列id
* @return 是否被限流
*/
@Override
public synchronized boolean isLimited(String id) {
// 獲取當前時間
long nowTime = System.currentTimeMillis();
// 根據隊列id,取出對應的限流隊列,若沒有則創建
List<Long> list = MAP.computeIfAbsent(id, k -> new LinkedList<>());
// 如果隊列還沒滿,則允許通過,並添加當前時間戳到隊列開始位置
if (list.size() < count) {
list.add(0, nowTime);
return false;
}
// 隊列已滿(達到限制次數),則獲取隊列中最早添加的時間戳
Long lastTime = list.get(count - 1);
// 用當前時間戳 減去 最早添加的時間戳
if (nowTime - lastTime <= timePeriod) {
// 若結果小於等於timePeriod,則說明在timePeriod內,通過的次數大於count
// 不允許通過
return true;
} else {
// 若結果大於timePeriod,則說明在timePeriod內,通過的次數小於等於count
// 允許通過,並刪除最早添加的時間戳,將當前時間添加到隊列開始位置
list.remove(count - 1);
list.add(0, nowTime);
return false;
}
}
}
複製代碼
@Test
public void test() throws InterruptedException {
// 任意10秒內,只允許2次通過
LimiterComponent component = new WindowLimiterComponent(2, 10000L);
while (true) {
System.out.println(LocalTime.now().toString() + component.isLimited("1"));
// 睡眠0-10秒
Thread.sleep(1000 * new Random().nextInt(10));
}
}
複製代碼
2 Redis zset
public class RedisZSetLimiterComponent implements LimiterComponent{
private final redissonComponent redissonComponent;
/**
* 限制次數
*/
private final int count;
/**
* 時間窗口大小,單位毫秒
*/
private final long timePeriod;
public RedisZSetLimiterComponent(RedissonComponent component) {
this.redissonComponent = component;
this.count = 5;
this.timePeriod = 1000;
}
public RedisZSetLimiterComponent(RedissonComponent component, int count, long timePeriod) {
this.redissonComponent = component;
this.count = count;
this.timePeriod = timePeriod;
}
/**
* 基於 zset 的滑動時間窗口限流演算法
* 在指定時間窗口,指定限制次數內,是否允許通過
*
* @param key 隊列key
* @return 是否允許通過
*/
@Override
public synchronized boolean isLimited(String key) {
// 獲取當前時間
long nowTime = System.currentTimeMillis();
RScoredSortedSet<String> set = redissonComponent.getRScoredSortedSet(key);
// 移除一個時間段以前的
set.removeRangeByScore(0, true, (double) (nowTime - timePeriod), true);
// 獲取集合內元素總數
int size = set.count((double) (nowTime - timePeriod), true, nowTime, true);
// 如果隊列沒滿
if (size < count) {
// 當前時間加入集合
set.add((double) nowTime, String.valueOf(nowTime));
return false;
}
return true;
}
}
複製代碼
@Test
public void test() throws InterruptedException {
// 任意10秒內,只允許2次通過
LimiterComponent component = new RedisZSetLimiterComponent(redissonComponent, 2, 10000L);
while (true) {
System.out.println(LocalTime.now().toString() + component.isLimited("1"));
// 睡眠0-10秒
Thread.sleep(1000 * new Random().nextInt(10));
}
}
複製代碼
3 guava RateLimiter
@SuppressWarnings("UnstableApiUsage")
public class GuavaLimiterComponent implements LimiterComponent {
private final int count;
private final long timePeriod;
private final Map<String, RateLimiter> MAP = new ConcurrentHashMap<>();
public GuavaLimiterComponent(int count, long timePeriod) {
this.count = count;
this.timePeriod = timePeriod;
}
/**
* 令牌桶演算法
*
* @param key 鍵值
* @return 是否被限流
*/
@Override
public synchronized boolean isLimited(String key) {
RateLimiter rateLimiter = MAP.computeIfAbsent(key, k -> RateLimiter.create(count, timePeriod, TimeUnit.MILLISECONDS));
return !rateLimiter.tryAcquire();
}
}
複製代碼
@Test
public void test() throws InterruptedException {
// 任意10秒內,只允許2次通過
LimiterComponent component = new GuavaLimiterComponent(2, 10000L);
while (true) {
System.out.println(LocalTime.now().toString() + component.isLimited("1"));
// 睡眠0-10秒
Thread.sleep(1000 * new Random().nextInt(10));
}
}
複製代碼
4 redisson RRateLimiter
public class RedisRateLimiterComponent implements LimiterComponent {
private final RedissonComponent redissonComponent;
/**
* 限制次數
*/
private final int count;
/**
* 時間窗口大小,單位毫秒
*/
private final long timePeriod;
public RedisRateLimiterComponent(RedissonComponent component) {
this.redissonComponent = component;
this.count = 5;
this.timePeriod = 1000;
}
public RedisRateLimiterComponent(RedissonComponent component, int count, long timePeriod) {
this.redissonComponent = component;
this.count = count;
this.timePeriod = timePeriod;
}
/**
* 基於 rateLimiter 的滑動時間窗口限流演算法
* 在指定時間窗口,指定限制次數內,是否允許通過
*
* @param key 隊列key
* @return 是否允許通過
*/
@Override
public synchronized boolean isLimited(String key) {
RRateLimiter rateLimiter = redissonComponent.getRateLimiter(key);
rateLimiter.trySetRate(RateType.PER_CLIENT, count, timePeriod, RateIntervalUnit.MILLISECONDS);
return !rateLimiter.tryAcquire();
}
}
複製代碼
@Test
public void test() throws InterruptedException {
// 任意10秒內,只允許2次通過
LimiterComponent component = new RedisRateLimiterComponent(redissonComponent, 2, 10000L);
while (true) {
System.out.println(LocalTime.now().toString() + component.isLimited("1"));
// 睡眠0-10秒
Thread.sleep(1000 * new Random().nextInt(10));
}
}
總結
熔斷和降級都是程序在我保護的一種機制,但二者在概念、觸發條件、歸屬關係上都是不同的。熔斷更偏向於全局視角的自我保護(機制),而降級則偏向於具體模塊「退而請其次」的解決方案。