[TOC]java
在開發高併發系統時,有三把利器用來保護系統:緩存、降級和限流:redis
本文僅針對限流作一些簡單的說明,那麼何爲限流呢?顧名思義,限流就是限制流量,就像你寬帶包了1個G的流量,用完了就沒了。經過限流,咱們能夠很好地控制系統的qps,從而達到保護系統的目的。本篇文章將會介紹一下經常使用的限流算法以及他們各自的特色。算法
限流本質上是控制某段代碼在必定時間內執行的次數,例如咱們系統天天五點事後都有130w~140w的數據須要插入數據庫,如果直接一次性插入這些數據,必將致使數據庫鏈接被佔滿沒法接收其餘處理的請求,數據庫的負載壓力會瞬間飆升,甚至是壓垮數據庫形成雪崩現象。因此咱們須要對此操做進行限流,以一個恆定的速率去插入數據,假設每秒插入400條數據,固然這個數值須要根據實際狀況去設定,如此一來就能夠有效控制同一時間往數據庫插入的數據流不會很大,這樣就不會出現上述問題了。以下圖:數據庫
應用限流的經常使用算法:編程
計數器法是限流算法裏最簡單也是最容易實現的一種算法。好比咱們規定,對於A接口來講,咱們1分鐘的訪問次數不能超過100個。那麼咱們能夠這麼作:在一開始的時候,咱們能夠設置一個計數器counter,每當一個請求過來的時候,counter就加1,若是counter的值大於100而且該請求與第一個 請求的間隔時間還在1分鐘以內,那麼說明請求數過多;若是該請求與第一個請求的間隔時間大於1分鐘,且counter的值還在限流範圍內,那麼就重置 counter,具體算法的示意圖以下:緩存
具體的僞代碼以下:服務器
public class CounterDemo { public long timeStamp = getNowTime(); // 當前時間 public int reqCount = 0; // 初始化計數器 public final int limit = 100; // 時間窗口內最大請求數 public final long interval = 1000; // 時間窗口ms public boolean grant() { long now = getNowTime(); if (now < timeStamp + interval) { // 在時間窗口內 reqCount++; // 判斷當前時間窗口內是否超過最大請求控制數 return reqCount <= limit; } else { timeStamp = now; // 超時後重置 reqCount = 1; return true; } } }
這個算法雖然簡單,可是有一個十分致命的問題,那就是臨界問題,以下圖:網絡
從上圖中咱們能夠看到,假設有一個惡意用戶,他在0:59時,瞬間發送了100個請求,而且1:00又瞬間發送了100個請求,那麼其實這個用戶在 1秒裏面,瞬間發送了200個請求。咱們剛纔規定的是1分鐘最多100個請求,也就是每秒鐘最多1.7個請求,用戶經過在時間窗口的重置節點處突發請求, 能夠瞬間超過咱們的速率限制。用戶有可能經過算法的這個漏洞,瞬間壓垮咱們的應用。併發
聰明的朋友可能已經看出來了,剛纔的問題實際上是由於咱們統計的精度過低。那麼如何很好地處理這個問題呢?或者說,如何將臨界問題的影響下降呢?咱們能夠看下面的滑動窗口算法。ide
滑動窗口,又稱rolling window。爲了解決計數器法統計精度過低的問題,引入了滑動窗口算法。若是學過TCP網絡協議的話,那麼必定對滑動窗口這個名詞不會陌生。下面這張圖,很好地解釋了滑動窗口算法:
在上圖中,整個紅色的矩形框表示一個時間窗口,在咱們的例子中,一個時間窗口就是一分鐘。而後咱們將時間窗口進行劃分,好比圖中,咱們就將滑動窗口劃成了6格,因此每格表明的是10秒鐘。每過10秒鐘,咱們的時間窗口就會往右滑動一格。每個格子都有本身獨立的計數器counter,好比當一個請求 在0:35秒的時候到達,那麼0:30~0:39對應的counter就會加1。
那麼滑動窗口怎麼解決剛纔的臨界問題的呢?在上圖中,0:59到達的100個請求會落在灰色的格子中,而1:00到達的請求會落在橘×××的格子中。當時間到達1:00時,咱們的窗口會往右移動一格,那麼此時時間窗口內的總請求數量一共是200個,超過了限定的100個,因此此時可以檢測出來觸發了限流。
我再來回顧一下剛纔的計數器算法,咱們能夠發現,計數器算法其實就是滑動窗口算法。只是它沒有對時間窗口作進一步地劃分,因此只有1格。
因而可知,當滑動窗口的格子劃分的越多,那麼滑動窗口的滾動就越平滑,限流的統計就會越精確。
漏桶算法,又稱leaky bucket。爲了理解漏桶算法,咱們看一下對於該算法的示意圖:
從圖中咱們能夠看到,整個算法其實十分簡單。首先,咱們有一個固定容量的桶,有水流進來,也有水流出去。對於流進來的水來講,咱們沒法預計一共有多少水會流進來,也沒法預計水流的速度。可是對於流出去的水來講,這個桶能夠固定水流出的速率。並且,當桶滿了以後,多餘的水將會溢出。
咱們將算法中的水換成實際應用中的請求,咱們能夠看到漏桶算法天生就限制了請求的速度。當使用了漏桶算法,咱們能夠保證接口會以一個常速速率來處理請求。因此漏桶算法天生不會出現臨界問題。
具體的僞代碼以下:
public class LeakyDemo { public long timeStamp = getNowTime(); // 當前時間 public int capacity; // 桶的容量 public int rate; // 水漏出的速度 public int water; // 當前水量(當前累積請求數) public boolean grant() { long now = getNowTime(); water = max(0, water - (now - timeStamp) * rate); // 先執行漏水,計算剩餘水量 timeStamp = now; if ((water + 1) < capacity) { // 嘗試加水,而且水還未滿 water += 1; return true; } else { // 水滿,拒絕加水 return false; } } }
令牌桶算法,又稱token bucket。一樣爲了理解該算法,咱們來看一下該算法的示意圖:
從圖中咱們能夠看到,令牌桶算法比漏桶算法稍顯複雜。首先,咱們有一個固定容量的桶,桶裏存放着令牌(token)。桶一開始是空的,token以 一個固定的速率r往桶裏填充,直到達到桶的容量,多餘的令牌將會被丟棄。每當一個請求過來時,就會嘗試從桶裏移除一個令牌,若是沒有令牌的話,請求沒法經過。
具體的僞代碼以下:
public class TokenBucketDemo { public long timeStamp = getNowTime(); // 當前時間 public int capacity; // 桶的容量 public int rate; // 令牌放入速度 public int tokens; // 當前令牌數量 public boolean grant() { long now = getNowTime(); // 先添加令牌 tokens = min(capacity, tokens + (now - timeStamp) * rate); timeStamp = now; if (tokens < 1) { // 若不到1個令牌,則拒絕 return false; } else { // 還有令牌,領取令牌 tokens -= 1; return true; } } }
若仔細研究算法,咱們會發現咱們默認從桶裏移除令牌是不須要耗費時間的。若是給移除令牌設置一個延時時間,那麼實際上又採用了漏桶算法的思路。Google的Guava庫下的SmoothWarmingUp類就採用了這個思路。
咱們再來考慮一下臨界問題的場景。在0:59秒的時候,因爲桶內積滿了100個token,因此這100個請求能夠瞬間經過。可是因爲token是以較低的速率填充的,因此在1:00的時候,桶內的token數量不可能達到100個,那麼此時不可能再有100個請求經過。因此令牌桶算法能夠很好地解決臨界問題。下圖比較了計數器(左)和令牌桶算法(右)在臨界點的速率變化。咱們能夠看到雖然令牌桶算法容許突發速率,可是下一個突發速率必需要等桶內有足夠的 token後才能發生:
計數器 VS 滑動窗口:
計數器算法是最簡單的算法,能夠當作是滑動窗口的低精度實現。滑動窗口因爲須要存儲多份的計數器(每個格子存一份),因此滑動窗口在實現上須要更多的存儲空間。也就是說,若是滑動窗口的精度越高,須要的存儲空間就越大。
漏桶算法 VS 令牌桶算法:
漏桶算法和令牌桶算法最明顯的區別是令牌桶算法容許流量必定程度的突發。由於默認的令牌桶算法,取走token是不須要耗費時間的,也就是說,假設桶內有100個token時,那麼能夠瞬間容許100個請求經過。
令牌桶算法因爲實現簡單,且容許某些流量的突發,對用戶友好,因此被業界採用地較多。固然咱們須要具體狀況具體分析,只有最合適的算法,沒有最優的算法。
Google開源工具包Guava提供了限流工具類RateLimiter,該類基於令牌桶算法(Token Bucket)來完成限流,很是易於使用。RateLimiter常常用於限制對一些物理資源或者邏輯資源的訪問速率,它支持兩種獲取permits接口,一種是若是拿不到馬上返回false(tryAcquire()
),一種會阻塞等待一段時間看能不能拿到(tryAcquire(long timeout, TimeUnit unit)
)。
使用tryAcquire方法獲取令牌的示例代碼:
@Slf4j public class RateLimiterExample1 { /** * 每秒鐘放入5個令牌,至關於每秒只容許執行5個請求 */ private static final RateLimiter RATE_LIMITER = RateLimiter.create(5); public static void main(String[] args) { // 模擬有100個請求 for (int i = 0; i < 100; i++) { // 嘗試從令牌桶中獲取令牌,若獲取不到則等待300毫秒看能不能獲取到 if (RATE_LIMITER.tryAcquire(300, TimeUnit.MILLISECONDS)) { // 獲取成功,執行相應邏輯 handle(i); } } } private static void handle(int i) { log.info("{}", i); } }
若想保證全部的請求都被執行,而不會被拋棄的話,能夠選擇使用acquire方法:
@Slf4j public class RateLimiterExample2 { /** * 每秒鐘放入5個令牌,至關於每秒只容許執行5個請求 */ private static final RateLimiter RATE_LIMITER = RateLimiter.create(5); public static void main(String[] args) { for (int i = 0; i < 100; i++) { // 從令牌桶中獲取一個令牌,若沒有獲取到會阻塞直到獲取到爲止,因此全部的請求都會被執行 RATE_LIMITER.acquire(); // 獲取成功,執行相應邏輯 handle(i); } } private static void handle(int i) { log.info("{}", i); } }
前面討論的幾種算法都屬於單機限流的範疇,可是業務需求五花八門,簡單的單機限流,根本沒法知足他們。
好比爲了限制某個資源被每一個用戶或者商戶的訪問次數,5s只能訪問2次,或者一天只能調用1000次,這種需求,單機限流是沒法實現的,這時就須要經過集羣限流進行實現。
如何實現?爲了控制訪問次數,確定須要一個計數器,並且這個計數器只能保存在第三方服務,好比redis。
大概思路:每次有相關操做的時候,就向redis服務器發送一個incr命令,好比須要限制某個用戶訪問/index接口的次數,只須要拼接用戶id和接口名生成redis的key,每次該用戶訪問此接口時,只須要對這個key執行incr命令,在這個key帶上過時時間,就能夠實現指定時間的訪問頻率。