如何使用 redis 實現限流
背景
在工作中時常會遇到需要對介面或者某個呼叫進行限流的情況。也會遇到在限流的同時對 redis
資料進行一些處理,在涉及到分散式的情景下,就需要操作的原子性。
限流演算法
主流的限流演算法為以下四種:
- 計數器(固定視窗)
- 滑動視窗(分割計數器)
- 漏桶演算法
- 令牌桶演算法
對於演算法的解釋,網上有很多好文章,在這裡貼上常用四種限流演算法。
在本文中,討論前兩種也就是 計數器 以及 滑動視窗。
業務解釋
限流,是在業務中經常遇到的場景。例如:對介面的限流、對呼叫的限流,etc。
以對介面限流為例,流程如下:
請求打到伺服器之後,需要先判斷當前介面是否達到閾值,若:
- 達到閾值,則結束本次請求。
- 未達到閾值,則 計數++,繼續下一步呼叫。
限流可以有很多中辦法,如果只是小型的單機部署應用,則可以考慮在記憶體中進行計數與操作。若是複雜的專案且分散式部署的專案,可以考慮使用redis
進行計數。且限流的邏輯不一定要限於Java
程式碼中,也可以使用lua
在nginx
進行操作,例如大名鼎鼎的openresty
,同理其他閘道器服務也可實現。
分散式業務中的限流
首先分析業務場景,在分散式部署的api場景中需要注意以下幾點:
- 使用閘道器對api進行負載均衡,部署在不同伺服器上的進行之間記憶體很難做到共享。
- 基於限流的業務,是對整個系統的某一個或者某一些介面進行限流,所以計數必須做到不同的程式都可以讀取。
- 對於計數的觸發,是請求達到伺服器上之後發生的,所以需要考慮原子性。即:同一時刻,只有一個請求可以觸發計數。這就對計數服務的要求提出了很高的併發要求。
分析 nginx + lua 的可行性
nginx
常用於請求的入口,在使用它的負載均衡之後,可以實現將請求分發到不同的服務上。使用 lua
對記憶體進行操作,似乎可以實現上述要求(可行性待驗證)。
但是,在實際情況中,一個系統並一定只會部署一個 nginx
作為入口。一方面是單機風險,另一方面是地理位置的不同,網路的不同對同一臺機器的訪問速度可能會有天差地別。所以,大家更喜歡使用 DNS 或者其他將請求達到多型 nginx
先做一層負載均衡。所以,單是 nginx
+ lua
並不能達到我們的需求。
分析 redis 的可行性
redis
是基於記憶體的一種非關係型資料庫,它的併發是經得住考驗的,同時它也可以滿足不同程式對相同資料讀取、修改的需求。
對於原子性,redis
操作天生支援原子性,而且 string 型別的 INCR(原子累加) 操作與 限流 業務又十分的契合。
redis 實現限流
讓我們再回到一開始的流程,計數限流的操作有:
- 查詢當前計數
- 累加當前計數
在分散式系統中,必須要時刻注意 原子性。在單一程式中,我們保持資料執行緒安全的辦法是加鎖,無論是可重入鎖還是synchronized
,其語義都是告訴其他執行緒,這個資料(程式碼塊)我現在徵用了,你們等會再來。那在分散式系統中,我們自然而然的可以想到分散式鎖。
虛擬碼如下:
Lock lock = getDistributedLock();
try{
lock.lock();
// 從 redis 中獲取計數
Integer count = getCountFromRedis();
if(count >= limit){
// 超過閾值,不予呼叫
return false;
}
// 未超過閾值,允許呼叫
incrRedisCount();
return true;
}catch{
...
}finally{
lock.unlock();
}
乍一看,這種邏輯沒有問題,但其實問題很大:
- 使用分散式鎖明顯會拖慢整個系統,浪費很多資源。
- redis incr 操作會返回累加之後的值,所以查詢操作是不必須的。
虛擬碼如下:
Integer count = incrRedisCount();
if (count >= limit){
return false;
}
return true;
是不是變的簡單了很多。但是隨之而來的有其他的問題,大部分的業務都不是要求我們只對次數進行限制,更多的是要求我們限制介面在一段時間內的請求次數----滑動視窗。
滑動視窗的實現
顧名思義,滑動視窗就是將一個固定的視窗滑動起來。用於限流上來說就是,一段時間內進行計數,時間一過,立馬開始新的計數。
如何實現 一段時間 這個邏輯?
其實很簡單,我們完全可以使用 時間戳 來實現這一功能。
// 秒級時間戳
long timestamp = System.currentTimeMillis() / 1000;
Long aLong = redisTemplate.opsForValue().increment(RedisKeyEnum.SYSTEM_FLOW_LIMIT.getKey() + timestamp);
return aLong;
此時會有一個問題,如果按以上程式碼來看,每秒建立一個鍵,那redis 記憶體遲早會被撐爆。我們需要一個策略來刪除這個鍵。
笨的方法,可以記錄這些鍵,然後非同步去刪除這些鍵。但是更好的方法是,在鍵第一次建立的時候設定一個稍大於視窗的過期值。所以,程式碼如下:
/**
* 按秒統計傳送訊息數量
*
* @return
*/
public Long getSystemMessageCountAtomic() {
// 秒級時間戳
long timestamp = System.currentTimeMillis() / 1000;
Long aLong = redisTemplate.opsForValue().increment(RedisKeyEnum.SYSTEM_FLOW_LIMIT.getKey() + timestamp);
if (aLong != null && aLong == 1) {
redisTemplate.expire(RedisKeyEnum.SYSTEM_FLOW_LIMIT.getKey() + timestamp, 2, TimeUnit.SECONDS);
}
return aLong;
}
只有在第一次計數的時候才會執行 expire
命令。為什麼需要設定稍大於視窗的時間呢?
想象一下,如果設定和視窗一樣的時間,在 a 時刻的時候生成的鍵 keyA,然後過期時間是一秒。然後在 b 時刻,生成的鍵也是 keyA(在同一秒內),但是由於網路或者其他原因,b 時刻的命令在一秒之後才傳送到 redis server。由於過期時間是一秒,此時舊的 keyA 已經過期,那麼 b 時刻就會建立一個新的鍵。
此時,需要考慮另外一個問題,如果超過限制,以上程式碼會如何表現。
假設,一秒鐘內只允許請求100次。那麼第101次,也會去 redis 中執行 incr 命令,往後的請求都會執行。其實這些命令的執行時沒有意義的,因為第 101 次時,這一秒的請求已經到限制了,所以我們需要另外一個儲存來記錄以上資料。
我選用 AtomicLong 來記錄已經到限的視窗。分析一下是否可行。
- AtomicLong 屬於 java.util.concurrent.atomic 包,採用 CAS 與 volatile 來保證資料的執行緒安全。
- 上述需求,我們只需要在單機上記錄 flag 即可,不需要考慮分散式情況。
論述可行,以下展示程式碼。
private final AtomicLong flag = new AtomicLong();
/**
* 系統全域性流量限制
*/
public void systemFlowLimit() {
// 判斷 flag 是否與當前秒相同
if (flag.get() != System.currentTimeMillis() / 1000) {
// 由於 flag.get 到 flag.set 之間的所有操作組合之後 不具備原子性,所以會有 小於 執行緒數 的執行緒會進入到這裡面。
// 意思是,當 第一個 執行緒將 flag 設定為 當前秒級 時間戳之後, 會有一部分執行緒已經執行完 flag.get 的判斷邏輯
// 此時,部分執行緒會繼續 redis 操作與 日誌操作
Long count = systemLimitService.getSystemMessageCountAtomic();
if (count >= systemProperties.getFlowLimit()) {
// 超過之後會將flag 設定為當前秒
flag.set(System.currentTimeMillis() / 1000);
LOGGER.warn("system flow now is out of system flow limit,at:{}", System.currentTimeMillis() / 1000);
throw new BusinessException(...);
}
} else {
throw new BusinessException(...);
}
}
總結
以上整理了使用 redis 做限流的一些方法,經常使用的演算法便是滑動視窗,所以花了較大筆墨解釋滑動視窗的實現。
當然,我們還可以使用 lua 指令碼來操作 redis 以實現限流與其他 redis 操作的配合。
我經常遇到的一個場景就是,往 redis 佇列中寫資料需要進行限流,當流量達到之後需要刪除部分 redis 佇列中的內容。此時,使用 lua 指令碼來做可以很優雅的保持多個 redis 操作的原子性,也可以減少網路情況的開銷。