我們項目中使用了Sentinel作為限流器,Sentinel可配置按最大工作線程數以及按QPS限流,而要實作這兩個限流規則,就必須要統計到目前工作線程數以及QPS,那麼Sentinel是怎麼統計線程數以及每秒的請求數QPS的呢。本篇将為大家解答疑惑。
同時,我也将Sentinel實作統計QPS的部分代碼抄了出來,封裝為一個工具包。沒錯,是抄的,隻是做了些許改動,去掉限流的統計,讓原本複雜的代碼變得稍簡單些。該工具包我已經上傳到Github,如果你項目中也需要統計每個接口的QPS或者整個web應用的QPS,那麼這個工具包應該能幫到你。
Github傳送門:https://github.com/wujiuye/qps-helper
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiInVGcq5SOmZjY4UGOzE2N5UjMiRDOwY2Y1kTZlRzYlljM5EWNk9CX3AzLcZDMxIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjL0M3Lc9CX6MHc0RHaiojIsJye.jpeg)
Sentinel是如何支援Dubbo的
Sentinel的實作并不簡單,并不是簡單的統計一個接口的QPS實作限流就完事了,如果不了解Sentinel,可以看下官方的文檔,這裡給個傳送門:https://github.com/alibaba/Sentinel/wiki。
要了解Sentinel實作限流的源碼,首先我們要了解其核心,比如如何統計QPS,隻要了解核心功能的實作,我們也可以自己實作一個簡單的限流器。
根據前面對Dubbo源碼分析的了解,我們已經知道,Dubbo服務提供端的過濾器是通過包裝Invoker以攔截請求處理過濾邏輯的,而在處理一個請求過程中,都是在一個線程上完成的,包括我們熟悉的Spring MVC。是以,Sentinel支援Dubbo就是通過Dubbo提供的過濾器功能實作的。
按最大工作線程數限流規則的實作相比QPS限流規則簡單太多。Sentinel實際上并不需要關心Dubbo的實際工作線程數,實作按最大線程數限流可以在接收到請求且不攔截請求時,将目前工作線程數加1,完成一個請求之後将工作線程數減1,當目前工作線程數等于限流規則配置的最大線程數時,就可以攔截請求,抛出請求被限流的異常。
限流是在服務端實作的,是以需要在服務端通過Dubbo SPI注冊一個過濾器SentinelDubboProviderFilter。
@Activate(group = "provider")public class SentinelDubboProviderFilter extends AbstractDubboFilter implements Filter { @Override public Result invoke(Invoker<?> invoker, Invocation invocation) throws RpcException { // 隻要不攔截,就調用invoker.invoke(invocation); }}
Sentinel如何統計每秒請求數(QPS)
為了簡單,我不直接分析Sentinel的源碼,而是分析我從Sentinel中抄的,且經過改造後的代碼。總體上是一樣的,我就去掉一些不需要的統計資料,以及将Sentinel一些自定義的類替換成JDK提供的類,這樣便于了解。
01
Sentinel是以Bucket(桶)為機關記錄一段時間内的請求總數、異常總數、總耗時的,而一個Bucket可以是記錄一秒内的資料,也可以是10毫秒内的資料,我們稱這個時間區間為Bucket的統計機關,是由使用者自定義的。
public class MetricBucket { /** * 存儲各事件的計數,比如異常總數、請求總數等 */ private final LongAdder[] counters; /** * 這段事件内的最小耗時 */ private volatile long minRt;}
Bucket存儲一段時間内的請求數、異常數等這些資料用的是一個LongAdder數組,LongAdder保證了資料修改的原子性,數組的每個元素分别代表一段時間内的請求總數、異常數、總耗時。
用枚舉類型MetricEvent的ordinal作為下标。LongAdder被我替換為j.u.c包下的atomic類了。
// 事件類型public enum MetricEvent { EXCEPTION,// 異常 對應數組下标為0 SUCCESS, // 成功 對應數組下标為1 RT // 耗時 對應數組下标為2}
當需要擷取Bucket記錄的總的成功請求數、或者異常總數、或者總的請求處理耗時時,可以通過MetricEvent從LongAdder數組中擷取對應的LongAdder,調用sum方法。
// 假設事件為MetricEvent.SUCCESSpublic long get(MetricEvent event) { // MetricEvent.SUCCESS.ordinal()為1 return counters[event.ordinal()].sum();}
當需要往Bucket添加1個請求、或者一個異常,或者處理請求的耗時時,可以通過MetricEvent從LongAdder數組中擷取對應的LongAdder,調用add方法。
// 假設事件為MetricEvent.RTpublic void add(MetricEvent event, long n) { // MetricEvent.RT.ordinal()為2 counters[event.ordinal()].add(n);}
02
有了Bucket之後,假設我們需要讓Bucket存儲一秒鐘的資料,這樣我們就能夠知道每秒處理成功的請求數(成功QPS)、每秒處理失敗的請求數(失敗QPS),以及處理每個成功請求的平均耗時(avg RT)。但是我們如何才能確定Bucket存儲的就是精确到1秒的資料呢?最low的做法就是啟一個定時任務每秒建立一個Bucket,但統計出來的資料誤差絕對很大。
而Sentinel是這樣實作的。它定義一個Bucket數組,根據時間戳來定位到數組下标。假設我們需要統計每1秒處理的請求數等資料,且隻需要儲存最近一分鐘的資料。那麼Bucket數組的大小就可以設定為60,每個Bucket的windowLengthInMs視窗大小就是1000毫秒(1秒)。
由于每個Bucket存儲的是1秒的資料,那麼就可以将目前時間戳去掉毫秒部分,就能得到目前的秒數,假設Bucket數組的大小是無限大的,那麼得到的秒數就是目前要擷取的Bucket所在的數組下标。
但我們不能無限的存儲Bucket,一秒一個Bucket得要多大的記憶體才能存一天的資料。是以,當我們隻需要保留一分鐘的資料時,Bucket數組的大小就是60,将得到的秒數與數組長度取餘數,就得到目前Bucket所在的數組下标。這個數組是循環使用的,永遠隻儲存最近1分鐘的資料。
/** * 計算索引,将時間戳映射到Bucket數組。 * * @param timeMillis 時間戳(毫秒) * @return */ private int calculateTimeIdx(long timeMillis) { /** * 假設目前時間戳為1577017699235 * windowLengthInMs為1000毫秒(1秒) * 則 * 将毫秒轉為秒 => 1577017699 * 映射到數組的索引為 => 19 */ long timeId = timeMillis / windowLengthInMs; return (int) (timeId % array.length()); }
取餘數就是循環利用數組。如果想要擷取連續的一分鐘的Bucket資料,就不能簡單的從頭開始周遊數組,而是指定一個開始時間和結束時間, 從開始時間戳開始計算Bucket存放的數組下标,然後循環每次将開始時間戳加上1秒,直到開始時間等于結束時間。
但由于循環使用的問題,目前時間戳與一分鐘之前的時間戳和一分鐘之後的時間戳都會映射到數組的同一個下标,是以,必須要能夠判斷數組下标的資料是否是目前時間的,這便要數組每個元素存儲一個Bucket統計的時間區間的開始時間戳。
比如目前時間戳是1577017699235,Bucket統計一秒的資料,是以将時間戳去掉毫秒數後補0,就是Bucket統計的時間區間的開始時間戳1577017699000。
/** * 擷取bucket開始時間戳 * * @param timeMillis * @return */ protected long calculateWindowStart(long timeMillis) { /** * 假設視窗大小為1000毫秒,即數組每個元素存儲1秒鐘的統計資料 * timeMillis % windowLengthInMs 就是取得毫秒部分 * timeMillis - 毫秒數 = 秒部分 * 這就得到每秒的開始時間戳 */ return timeMillis - timeMillis % windowLengthInMs; }
03
因為Bucket自身并不儲存統計資料的時間區間,是以Sentinel給Bucket加了一個包裝類叫WindowWrap<Bucket>。用于給Bucket記錄統計的時間區間。
public class WindowWrap<T> { /** * 單個bucket存儲桶的時間長度(毫秒) */ private final long windowLengthInMs; /** * bucket的開始時間戳(毫秒) */ private long windowStart; /** * 統計資料 */ private T value; public WindowWrap(long windowLengthInMs, long windowStart, T value) { this.windowLengthInMs = windowLengthInMs; this.windowStart = windowStart; this.value = value; }}
如前面所說,假設Bucket以秒為機關統計請求數資訊,那麼它記錄的就是一秒内的請求總數等這些資訊。換算毫秒為機關,比如1577017699000 ~ 1577017699999,是以1577017699000就被稱為視窗的開始時間windowStart,一秒轉為毫秒是1000,是以1000就稱為視窗的長度windowLengthInMs。windowStart+windowLengthInMs等于結束時間。
是以,隻要給定一個時間戳,就能知道該時間戳是否在該Bucket統計的時間區間内。
/** * 檢查給定的時間戳是否在目前bucket中。 * * @param timeMillis 時間戳,毫秒 * @return */ public boolean isTimeInWindow(long timeMillis) { return windowStart <= timeMillis && timeMillis < windowStart + windowLengthInMs; }
04
有了Bucket,也有了Bucket數組,也能通過給Bucket包裝一個WindowWrap判斷一個Bucket所統計的時間區間。最後就是要能夠通過目前時間定位到一個Bucket,當接收到一個請求時,根據目前時間戳計算出一個數組下标,從數組中擷取一個Bucket,調用Bucket的add方法添加事件數,如給成功請求數+1。
前面也分析了根據時間戳計算Bucket數組下标的方法,以及根據時間戳計算出Bucket所統計的時間區間的開始時間。現在要做的就是能夠根據目前時間戳找到對應的Bucket。
通過目前時間戳,計算出目前Bucket(New Buket)所在的數組下标(cidx),以及Bucket統計時間區間的開始時間。通過下标拿到目前數組存儲的Bucket(Old Bucket)。
- 當數組下标cidx不存在Bucket時,建立一個新Bucket,并且確定線程安全寫入到數組cidx處,将此Bucket傳回;
- 當Old Bucket不為空時,且Old Bucket的開始時間與目前計算得到的New Buket的開始時間相等時,就是目前要找的Bucket,直接傳回;
- 當計算出New Bucket的開始時間大于目前數組下标cidx位置存儲的Old Bucket的開始時間時,複用舊的Bucket,確定線程安全重置Bucket,并傳回。
- 當計算出New Bucket的開始時間小于目前數組下标cidx位置存儲的Old Bucket的開始時間時,直接傳回一個空的Bucket。
一個QPS的統計功能,實作起來還是挺複雜的,可能看代碼更容易了解,github位址已在文章開頭給出。當然,你也可以直接去看Sentinel的源碼,在sentinel-core的slots包下。