計算QPS-Sentinel限流算法
一. Sentinel架構大致流程
Sentinel其實就是一個AOP,通過AspectJ切入要進行限流的接口,爲其添加@Around環繞通知,並使用try-catch包裹起來,源碼在SentinelAutoConfiguration中
每一個對該限流接口的請求,都要經過AOP的增強,先執行過一系列流控、熔斷規則組成的責任鏈,然後才執行真正的接口邏輯。責任鏈的組裝使用了原生的spi機制,流控規則可以在sentinel控制檯去配置,配置完畢後會填入sentinel服務端,也就是我們的某一個服務,請求流控接口時,就會觸發流控邏輯!
@Aspect //使用的AOP
public class SentinelResourceAspect extends AbstractSentinelAspectSupport {
//切入點
@Pointcut("@annotation(com.alibaba.csp.sentinel.annotation.SentinelResource)")
public void sentinelResourceAnnotationPointcut() {
}
//環繞通知
@Around("sentinelResourceAnnotationPointcut()")
public Object invokeResourceWithSentinel(ProceedingJoinPoint pjp) throws Throwable {
Method originMethod = resolveMethod(pjp);
//1.獲取 @SentinelResource 註解
SentinelResource annotation = originMethod.getAnnotation(SentinelResource.class);
if (annotation == null) {
// Should not go through here.
throw new IllegalStateException("Wrong state for SentinelResource annotation");
}
String resourceName = getResourceName(annotation.value(), originMethod);
EntryType entryType = annotation.entryType();
int resourceType = annotation.resourceType();
Entry entry = null;
try {
// 2.執行流控組成的責任鏈
entry = SphU.entry(resourceName, resourceType, entryType, pjp.getArgs());
// 3.執行業務方法
Object result = pjp.proceed();
return result;
} catch (BlockException ex) {
//4. 拋出流控異常
return handleBlockException(pjp, annotation, ex);
} catch (Throwable ex) {
// 5. 拋出業務異常
Class<? extends Throwable>[] exceptionsToIgnore = annotation.exceptionsToIgnore();
// The ignore list will be checked first.
if (exceptionsToIgnore.length > 0 && exceptionBelongsTo(ex, exceptionsToIgnore)) {
throw ex;
}
if (exceptionBelongsTo(ex, annotation.exceptionsToTrace())) {
traceException(ex);
return handleFallback(pjp, annotation, ex);
}
// No fallback function can handle the exception, so throw it out.
throw ex;
} finally {
if (entry != null) {
//6.所有流控的收尾邏輯,比如:斷路器半開狀態重試
entry.exit(1, pjp.getArgs());
}
}
}
}
3. 如果執行中拋出異常,異常分爲流控異常BlockException
和 業務異常Throwable
,流控異常可以使用blockHandler
進行處理,業務異常使用fallback
處理!
@RequestMapping(value = "/findOrderByUserId/{id}")
@SentinelResource(value = "findOrderByUserId",
//業務異常,ExceptionUtil類中的fallback方法來處理
fallback = "fallback",fallbackClass = ExceptionUtil.class,
//流控異常,ExceptionUtil類中的handleException方法來處理
blockHandler = "handleException",blockHandlerClass = ExceptionUtil.class
)
public R findOrderByUserId(@PathVariable("id") Integer id) {
//ribbon實現
String url = "http://xx/order/findOrderByUserId/"+id;
R result = restTemplate.getForObject(url,R.class);
if(id==4){
throw new IllegalArgumentException("非法參數異常");
}
return result;
}
public class ExceptionUtil {
//業務異常
public static R fallback(Integer id,Throwable e){
return R.error(-2,"===被異常降級啦===");
}
//流控異常
public static R handleException(Integer id, BlockException e){
return R.error(-2,"===被限流啦===");
}
}
4 Sentinel中統計單位時間QPS進行流控時,採用的是滑動時間窗算法!流控效果有三種
快速失敗,直接拋出流控異常,底層使用的是滑動時間窗口算法
預熱Warm up,把突然爆發的大流量變爲緩慢增加
勻速排隊,使用的漏桶算法
5 Sentinel中服務熔斷降級有三個指標
慢調用比例
異常比例
異常個數
二. Sentinel斷路器的三種狀態
Sentinel中服務熔斷降級的斷路器有三個狀態,分別是關閉(close)、打開(open)和半開(halfOpen)狀態。
1 如果在單位時間內達到斷路條件,則把斷路器置爲打開(open)狀態,拋出流控異常,進行服務熔斷
2 下一次請求過來時,如果斷路器是關閉(close)狀態,直接通行;如果是打開(open)狀態,則會查看當前時間是否大於斷路後的最小等待時間,如果大於則把斷路器置爲半開(halfOpen)狀態;如果小於,繼續阻塞
3 最後會在try-catch-finally的finally中判斷斷路器的狀態是否是半開(halfOpen)狀態,如果是,則請求一次接口,如果請求正常,則把斷路器置爲打開(open)狀態,如果不正常把斷路器置爲關閉(close)狀態
三. 計算QPS的限流算法
①:計數器限流
計數器法是限流算法裏最簡單也是最容易實現的一種算法。對於A接口來說,1分鐘的訪問次數不能超過100個。
那麼可以這麼做:
- 在一開始的時候,我們可以設置一個計 數器counter,每當一個請求過來的時候,counter就加1,
- 如果counter的值大於100並且該請求 與第一個 請求的間隔時間還在1分鐘之內,那麼說明請求數過多;
- 如果該請求與第一個請求的間 隔時間大於1分鐘,且counter的值還在限流範圍內,那麼就重置 counter
計數器實現限流的缺點就是:精度低,如果在 0.5分鐘 和 1.5 分鐘之間有超過100個請求,這種限流算法就不起作用了。此外,計數器限流算法的實現還可以使用redis,設置一個key,一分鐘過期,進來一個請求就使用incr命令自增一,代碼中拿key的值與limt進行比較!
public class _10_限流算法_計數器 {
//開始統計時間
private long beginTime = System.currentTimeMillis();
//請求數
private int reqCount = 0;
//請求限制數
private int limit = 100;
//單位時間:1分鐘
private long window = 1000 * 60;
public boolean limitReq() {
//當前時間
long currTime = System.currentTimeMillis();
if (currTime < beginTime + window) {
//如果當前時間在統計期內,則遞增請求數,並於限制數100比較
reqCount++;
return reqCount <= limit;
} else {
//如果當前時間不在統計期內,則重置請求數,並設置當前時間爲統計開始時間
reqCount = 1;
beginTime = currTime;
return true;
}
}
}
②:滑動時間窗算法限流
爲了解決計數器法統計精度太低的問題,引入了滑動窗口算法。滑動時間窗其實就是把計數器限流算法的時間窗口再做進一步劃分,當滑動窗口的格子劃分的越多,那麼滑動窗口的滾動就越平滑,限流的統計就會越精確。Sentinel底層在做統計QPS做快速失敗時用的也是滑動時間窗算法
滑動時間窗口限流實現:
- 假設某個服務最多隻能每秒鐘處理100個請求,可以設置一個1秒鐘的滑動時間窗口,用LinkedList表示,該窗口分爲10個格子。
- 每個格子100毫秒,每100毫秒移動一次,每次移動都需要記錄當前服務100ms內請求的次數counter到格子中,counter的值是累計請求的值。不會被重置
- 如果格子數大於10個,刪除最前邊的各自,格子數始終保留10個
- 用最後一個格子的counter值減去最前邊格子的counter值,如果大於限流請求數,則會被限流。否則不做限流
public class _11_限流算法_滑動時間窗 {
//服務訪問次數,可以放在Redis中,實現分佈式系統的訪問計數
Long counter = 0L;
//使用LinkedList來記錄滑動窗口的10個格子。
LinkedList<Long> slots = new LinkedList<Long>();
public static void main(String[] args) throws InterruptedException {
_11_限流算法_滑動時間窗 timeWindow = new _11_限流算法_滑動時間窗();
//開啓一個子線程執行滑動時間窗檢測請求個數
new Thread(new Runnable() {
@Override
public void run() {
try {
timeWindow.doCheck();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
//主線程死循環模擬一直有請求過來
while (true) {
//TODO 判斷限流標記
timeWindow.counter++;
Thread.sleep(new Random().nextInt(15));
}
}
private void doCheck() throws InterruptedException {
while (true) {
//每過100ms,就把總請求數加入linkedList末尾節點,該linkedList長度也自增一
slots.addLast(counter);
//如果linkedList長度大於10個,則刪除最前面的一個,體現滑動時間窗
if (slots.size() > 10) {
slots.removeFirst();
}
//比較最後一個節點和第一個節點,兩者相差100以上就限流
if ((slots.peekLast() - slots.peekFirst()) > 100) {
System.out.println("限流了。。");
//TODO 修改限流標記爲true
} else {
//TODO 修改限流標記爲false
}
//每100毫秒執行一次
Thread.sleep(100);
}
}
}
Sentinel底層在做統計QPS做快速失敗時用的也是滑動時間窗算法,只不過與上面的稍有不同
Sentinel在做QPS統計時,滑動時間窗有兩個維度- 毫秒級維度:初始化一個跨度爲1000ms,包含兩個500ms的時間窗口
- 秒級維度:還有一個跨度爲60s的,包含60個1s的時間窗口
在毫秒級維度中,僅僅使用兩個時間窗口就完成了QPS的計算,並沒有做刪除時間窗口節點的操作,而是清空原本節點的內容。兩個時間窗口代表數組的兩個下標。
通過 (當前時間 / 500ms) % 數組長度2的取模結果,得到當前時間的請求數落在那個時間窗口內
然後拿 (當前時間 / 500ms) * 500ms比較時間窗口的起始位置,
如果與之前 (比如:500ms) 一致,就把當次請求數加入到該窗口內用作統計
如果與之前不一致,就清空之前時間窗口內統計的數據,並放入當前時間的請求數,完成滑動的操作
③:漏桶算法限流
首先,需要有一個固定容量的桶,有水流進來,也有水流出去。對於流進來的水來說,我們無法預計一共有多少水會流進來,也無法預計水流的速度。但是對於流出去的水來說,這個桶可以固定水流出的速率。而且,當桶滿了之後,多餘的水將會溢出。
將算法中的水換成實際應用中的請求,就可以看到漏桶算法天生就限制了請求的速度。 當使用了漏桶算法,可以保證接口會以一個常速速率來處理請求。所以漏桶算法天生不會出現臨界問題。
public class _12_限流算法_漏桶算法 {
//初始時間
private long initTime = System.currentTimeMillis();
//漏桶算法一般都有三個指標 桶的容量 流出速度 當前水位
private long capacity; //容量,代表最大接受請求個數
private long rate; //水流速度
private long water; //當前水位 ,桶內剩餘請求數
public boolean limit() {
//當有請求進入時的時間
long now = System.currentTimeMillis();
//計算一下當時水位:請求進來之間一直在勻速滴水,當前水位要減去這部分滴出去的水!
water = Math.max(0, water - ((now - initTime) / 1000) * rate);
if (water + 1 <= capacity) {
//如果當前水位沒滿,返回true,並把當前水位+1
water += 1;
return true;
} else {
//否則返回false,水滿了 不讓進!
return false;
}
}
}
④:令牌桶限流
令牌桶算法,又稱token bucket
。同樣爲了理解該算法,我們來看一下該算法的示意圖:
從圖中我們可以看到,令牌桶算法比漏桶算法稍顯複雜。首先,我們有一個固定容量的桶,桶裏存放着令牌(token)。桶一開始是空的,token以 一個固定的速率r往桶裏填充,直到達到桶的容量,多餘的令牌將會被丟棄。每當一個請求過來時,就會嘗試從桶裏移除一個令牌,如果沒有令牌的話,請求無法通過。
漏桶算法和令牌桶算法最明顯的區別是令牌桶算法允許流量一定程度的突發。 因爲默認的令牌桶算法,取走token是不需要耗費時間的,也就是說,假設桶內有100個token時,那麼可以瞬間允許100個請求通過。
僞代碼:
/**
* 令牌桶限流算法
*/
public class TokenBucket {
public long timeStamp = System.currentTimeMillis(); // 當前時間
public long capacity; // 桶的容量
public long rate; // 令牌放入速度
public long tokens; // 當前令牌數量
public boolean grant() {
long now = System.currentTimeMillis();
// 先添加令牌
tokens = Math.min(capacity, tokens + (now - timeStamp) * rate);
timeStamp = now;
if (tokens < 1) {
// 若不到1個令牌,則拒絕
return false;
} else {
// 還有令牌,領取令牌
tokens -= 1;
return true;
}
}
}