熔斷與降級
離小眼睛家不遠的地方,開了一個熟食店。店內有兩個窗口總能排起長龍,一個窗口是選好的涼菜讓師傅調味,一個窗口是買到的扒雞讓胖師傅現場脫骨。顧客的正常的流程,大致是這個樣子滴:
炎炎夏日,邀三五好友,喝杯啤酒吹吹牛皮,豈不美哉。可能大家跟小眼睛想法一致,小店的生意日漸火爆。這天,小眼睛選好了菜,付了錢,正準備排隊讓師傅調口味、脫骨。目測兩個窗口排隊時間不會少於 20 分鐘,加之幾個朋友輪番催促,果斷放棄,拎着菜直接回家。於是我到流程就變成了:
當下遊的服務(調料、脫骨)因爲某種原因突然變得不可用或響應過慢(買菜3分鐘排隊半小時),上游服務爲了保證自己整體服務的可用性(等不及了),不再繼續調用目標服務,直接返回,快速釋放資源。如果目標服務情況好轉則恢復調用。這就叫做服務熔斷。
小眼睛因爲排隊時間過長,果斷放棄後續流程,提供了「降低品質」的菜品。這叫做服務降級。
熔斷有多種方式
服務降級的方式有很多種,比如限流、開關、熔斷,熔斷是降級的一種。
熔斷,在 Spring Cloud 中有熔斷降級庫 Hystrix ,在分佈式項目中也可以使用阿里開源的 Sentinel 達到熔斷降級目的。無論是 Hystrix 還是 Sentinel 都需要引入第三方組件,搞明白實現原理,不適合簡單場景下的使用。
手寫熔斷器的使用
本文介紹一種適合簡單應用的熔斷方法,核心代碼不超過 100 行。使用方法大致如下:
// 初始化一個熔斷器
private CircuitBreaker breaker = new CircuitBreaker(0.1, 10, true, "serviceDemo");
public void doSomething() {
// 每次調用都檢查服務狀態
breaker.checkStatus();
// 如果熔斷器返回 true 認爲服務可用,繼續執行邏輯
if (breaker.isWorked()) {
try {
service.doSomething();
} catch (Exception e) {
e.printStackTrace();
// 出現調用失敗,記錄失敗次數
breaker.addFailTimes();
} finally {
// 每一次調用,增加調用次數
breaker.addInvokeTimes();
}
}
// 服務不可用,執行降級邏輯
}
這段僞代碼中,熔斷器做了三件事兒:
-
檢查服務狀態,並且輸出統計日誌
-
返回服務狀態 breaker.isWorked()
-
記錄調用次數和失敗次數,作爲熔斷依據
熔斷器的實現
熔斷器具體實現如下:
public class CircuitBreaker {
/**
* 記錄失敗次數
*/
private AtomicLong failTimes =
new AtomicLong(0);
/**
* 記錄調用次數
*/
private AtomicLong invokeTimes =
new AtomicLong(0);
/**
* 降級閾值,比如 0.1
* 請求失敗次數/請求總次數的比例
*/
private double failedRate = 0.1;
/**
* 降級最小條件,請求總次數大於該值
* 纔會執行閾值判斷
* 比如 設置爲 10 ,
* 當請求次數大於10次時纔會執行判斷
*/
private double minTimes;
/**
* 熔斷開關,默認關閉
*/
private boolean enabled;
/**
* 熔斷後是否發送郵件告警
*/
private boolean mail;
/**
* 熔斷後是否發送短信告警
*/
private boolean sms;
/**
* 熔斷器名字
*/
private String name;
/**
* 保存上一次統計的時間戳,記錄單位是分鐘
*/
private AtomicLong currentTime =
new AtomicLong(
System.currentTimeMillis() / 60000);
/**
* 記錄服務是否是不可用狀態
*/
private AtomicBoolean isFailed =
new AtomicBoolean(false);
/**
* 服務宕掉的狀態放到線程容器中
*/
private ThreadLocal<Boolean> fail =
new ThreadLocal<Boolean>();
private Logger log =
LoggerFactory.getLogger(getClass());
/**
* 構造熔斷器
*
* @param failedRate 熔斷的閾值,
* 請求失敗次數/請求總次數
* @param minTimes 熔斷的最小條件,
* 請求總次數大於該值纔會根據閾值判斷,
* 執行降級操作
* @param enabled 是否需開啓熔斷操作
*/
public CircuitBreaker(double failedRate,
double minTimes,
boolean enabled,
String name) {
fail.set(false);
this.failedRate = failedRate;
this.minTimes = minTimes;
this.enabled = enabled;
this.name = name;
}
/**
* 判斷服務是否是失敗狀態
*
* @return
*/
public boolean isFailed() {
return isFailed.get();
}
/**
* 增加錯誤次數
*/
public void addFailTimes() {
fail.set(true);
if (enabled) {
failTimes.incrementAndGet();
}
}
/**
* 增加一次調用次數
*/
public void addInvokeTimes() {
if (enabled) {
invokeTimes.incrementAndGet();
}
}
/**
* 判斷服務是否可用
*
* @return
*/
public boolean isWorked() {
if (!enabled) {
return true;
}
// 當服務不可用時,犧牲掉 1% 的流量做探活請求
if (isFailed.get() &&
System.currentTimeMillis() % 100 == 0) {
return true;
}
if (isFailed.get()) {
fail.set(true);
return false;
}
return true;
}
public void checkStatus() {
if (!enabled) {
return;
}
long newTime =
System.currentTimeMillis() / 60000;
if ((newTime > currentTime.get())
&& (invokeTimes.get() > minTimes)) {
double percent =
failTimes.get() * 1.0 /
invokeTimes.get();
if (percent > failedRate) {
if (isFailed.get()) {
// 日誌輸出
if (mail) {
// 發送郵件通知
}
} else {
// 日誌輸出
isFailed.set(true);
if (sms) {
// 發送短信通知
}
if (mail) {
// 發送郵件通知
}
}
} else { // 服務恢復
if (isFailed.get()) {
// 日誌輸出
if (sms) {
// 發送短信通知
}
if (mail) {
// 發送郵件通知
}
}
isFailed.set(false);
}
if (log.isInfoEnabled()) {
// 日誌輸出
}
currentTime.set(newTime);
failTimes.set(0);
invokeTimes.set(0);
}
}
}
總體思路:
-
基於統計信息做熔斷,錯誤請求佔比超過閾值做熔斷
-
統計週期在分鐘級別內(1 分鐘內的統計達到閾值)
-
如果分鐘內,總請求次數未達到 minTimes 次數不做熔斷(請求頻次太低,統計信息無意義)
-
即便是到達熔斷條件,仍然犧牲 1% (可修改)的請求做探活
優缺點
-
Hystrix 提供了服務熔斷、線程隔離等一系列服務保護功能。我們手寫的熔斷器只能提供基於調用方的手工熔斷方法。
-
Hystrix 提供了線程池、信號量兩種方式。手寫熔斷器功能相對單一隻基於統計信息,且以分鐘爲維度的顆粒度較爲粗糙。
-
Hystrix 命令式編程和註冊回調的方式,代碼複雜度高。手寫熔斷器在侵入代碼過程中,偏面向過程,理解成本低。
去掉註釋和無效空行後實際有效代碼不足 100 行,我們用了不到一百行代碼實現了熔斷功能。雖然應用到大型服務場景下會有諸多缺陷,也希望至少能爲大家提供了一個思路。
關注我
歡迎關注,隨時與我交流溝通~