在實際業務中,可能會遇到瞬時流量劇增的情況,大量的請求可能會導致服務器過載和宕機。為了保護系統自身和上下游服務,需要采用限流的方式,拒絕部分請求。
限流就是對請求的頻率進行控制,迅速拒絕超過請求閾值的請求。
比如:Redis的QPS為10w,但是此時20w個請求同一時刻請求到Redis,造成Redis性能降低。
1. 什么是限流
限流就是在高并發場景下,通過限制系統處理請求的速率,迅速拒絕超過設定上限的請求(最大的QPS),從而保證系統正常運行。
在限流技術中,主要是有兩個注意點:當前系統請求的閾值和拒絕策略。
請求的閾值:閾值就是單位時間內允許請求的最大請求數。例如,將QPS(Queries Per Second)設置為1000,表明1s內最多接受1000個請求。通過設置適當的閾值,可以有效控制系統負載,避免系統請求過多而崩潰。(閾值是上線前通過壓測或者評估得出的指標)
拒絕策略: 處理超過閾值的請求的方法,常見的拒絕策略包括直接拒絕和等待。
- 直接拒絕策略:直接拒絕超過閾值的請求,直接返回給客戶端。(提示:當前搶購人數太多)
- 排隊等待策略:將請求放入隊列中,按照一定規則處理,避免瞬間拒絕大量請求。
2. 常見的限流算法
常見的限流算法分為:固定窗口(計數器限流)、滑動窗口、漏桶、令牌桶 四個方案
2.1. 固定窗口限流算法
在一段時間間隔內,處理請求的最大數量。
固定窗口其實就是時間窗口,原理是將時間劃分成固定大小的窗口,并且在每個窗口內限制請求的數量和速率,如果某個窗口內的請求超過了閾值,就直接執行拒絕策略。
即:固定窗口限流算法是規定了單位時間處理的請求數量。
算法步驟:
- 將時間劃分為固定大小的窗口,例如每秒一個。
- 在每一個時間窗口內,記錄請求的數量。
- 當新的請求到達時,當前窗口的請求計數器的值加一。
- 如果窗口內請求計數器超過閾值,那么執行拒絕策略。
- 當窗口結束后,重置計數器。
例如,每個時間窗口為1s,限流的閾值為3,窗口內超過閾值的請求都會觸發拒絕策略。
優點:實現簡單,易于理解。
缺點:
- 請求不夠均勻:在固定窗口算法中,請求在窗口內的分布可能會不均勻。假如限制某個接口每分鐘只能訪問30次,那么前30秒就會有30個請求到達的話,那么后30秒就無法處理請求,導致請求不均勻。
- 無法保證限流速率,因而無法因對突增的流量。比如一個接口一分鐘只能訪問1000次,但是接口的QPS為500,前55秒這個接口沒有收到請求,但是后一秒突然接收到1000個請求。然后,在當前場景下,這1000個請求在1s內是無法被處理的,系統可能就直接被大量的請求給擊垮了。
- 存在明顯的臨界問題(請求突刺問題):前一個窗口和后一個窗口之間的可能會存在大量的請求,無法應對無法流量。
比如:一個窗口內請求的閾值為3,以下存在臨界問題:
public class FixedWindowLimiter {private static final String FORMAT_TIME = "yyyy-MM-dd HH:mm:ss";private final long windowSize; // 窗口大小,默認是毫秒private final int maxRequests; // 最大請求數private int currentRequests; //當前窗口內的請求數private LocalDateTime lastRestTime; //上次窗口重置時間,即窗口開始時間private final Lock resetMutex; //重置鎖public FixedWindowLimiter(Duration windowSize, int maxRequests) {this.windowSize = windowSize.toMillis();this.maxRequests = maxRequests;this.currentRequests = 0;this.lastRestTime = LocalDateTime.now();this.resetMutex = new ReentrantLock();}public boolean allow() {resetMutex.lock();try {LocalDateTime now = LocalDateTime.now();if (Duration.between(lastRestTime, now).toMillis() >= windowSize) {this.currentRequests = 0;this.lastRestTime = now;}if (currentRequests >= maxRequests) {return false;}currentRequests++;return true;}finally {resetMutex.unlock();}}
}class FixedWindowLimiterApp {private static final String FORMAT_TIME = "yyyy-MM-dd HH:mm:ss";public static void main(String[] args) {System.out.println(System.currentTimeMillis() / 1000);FixedWindowLimiter limiter = new FixedWindowLimiter(Duration.ofSeconds(1), 3);DateTimeFormatter formatter = DateTimeFormatter.ofPattern(FORMAT_TIME);ScheduledExecutorService pool = Executors.newScheduledThreadPool(1);for (int i = 0; i < 20; i++) {Runnable task = () -> {String now = LocalDateTime.now().format(formatter);if (limiter.allow()) {System.out.println(now + "請求通過");} else {System.out.println(now + "請求被限流");}};pool.schedule(task, i * 100, TimeUnit.MILLISECONDS);}pool.shutdown();try {if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {pool.shutdownNow();}} catch (InterruptedException e) {pool.shutdownNow();}}
}
2.2. 滑動窗口限流算法
滑動窗口限流算法是固定窗口限流算法的升級版本,限流的粒度更小。
滑動窗口和固定窗口相比:滑動窗口是將時間按照一定的比例分片。
假如接口限流每分鐘處理60個請求,可以將1分鐘分成60個小窗口,然后每隔一秒移動一次,每個窗口一秒只能處理不大于60(請求數) / 60(窗口數),如果當前窗口的請求計數綜合超過了限制的數量的話,就觸發拒絕策略。
這樣的話,可以處理[0.5秒 ~ 1.5秒]內的臨界問題,紅色部分的請求將會觸發拒絕策略。
優點:
- 相比于固定窗口算法,滑動窗口計數器算法可以應對突發流量,解決一些臨界問題。
- 限流粒度更小,可以提供更精確的限流控制。
缺點: - 和固定窗口一樣,存在請求不夠均勻的情況。
2.3. 漏桶限流算法
漏桶限流的核心思想就是將請求存儲在一個漏桶中,無論我們以任意速率存入請求,漏桶始終以固定的速率流出。所以漏桶算法可以將突發流量均勻地處理,確保系統在穩定的負載下運行。
優點:
- 實現簡單,易于理解。
- 可以控制限流速率,避免網絡擁塞和系統過載。
缺點: - 在突發情況請求過多時,會丟棄過多的請求。
2.4. 令牌桶限流算法
令牌桶算法也比較簡單,和漏桶算法一樣,主角還是桶。不過現在桶里面裝的是令牌,請求在被處理之前需要拿到一個令牌,請求處理完畢之后直接丟棄令牌即可。我們可以根據限流大小,按照一定的速率往桶里添加令牌,如果桶裝滿了,就不能繼續往桶里添加令牌了。
優點:
- 可以應對突發流量:由于令牌可以在桶中堆積,當流量突然增大時,如果桶中有足夠的令牌,系統可以快速響應這種突發流量,避免請求被立即拒絕。這使得令牌桶算法特別適合處理具有突發性流量的應用場景。
- 靈活性強:通過適當調整流派的生成速率和桶的容量,可以靈活地控制流量。
算法設計:
public class TokenBucketLimiter {private final int rate; // 令牌產生的速度,即每秒生成多少個令牌private final int capacity; // 令牌桶容量,最多可存儲令牌數private int currentTokenNum; // 當前令牌數private LocalDateTime lastTime; // 上次請求時間private final Lock lock; // 請求鎖public TokenBucketLimiter(int rate, int capacity) {this.rate = rate;this.capacity = capacity;this.currentTokenNum = 0;this.lastTime = LocalDateTime.now();this.lock = new ReentrantLock();}public int getCurrentTokenNum() {return currentTokenNum;}public int getRate() {return rate;}public int getCapacity() {return capacity;}public boolean allow() {lock.lock();try {// 計算當前時間和上一次更新時間的時間間隔long timeDiff = Duration.between(lastTime, LocalDateTime.now()).toMillis();// 計算時間間隔內生成的令牌數量int tokenCount = (int) (timeDiff / 1000.0 * rate);if (tokenCount > 0) {currentTokenNum += tokenCount;lastTime = LocalDateTime.now();}// 令牌的數量不能超過桶的大小if (currentTokenNum > capacity) {currentTokenNum = capacity;}// 獲取令牌if (currentTokenNum > 0) {currentTokenNum--;return true;}return false;} finally {lock.unlock();}}}class TokenBucketLimiterApp {public static void mockRequest(int n, Duration d, TokenBucketLimiter limiter) {ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);for (int i = 0; i < n; i++) {int requestId = i + 1;executor.schedule(() -> {if (limiter.allow()) {System.out.printf("第%d個請求通過\n", requestId);} else {System.out.printf("第%d個請求被限流\n", requestId);}}, i * d.toMillis(), TimeUnit.MILLISECONDS);}executor.shutdown();try {if (!executor.awaitTermination(5, TimeUnit.SECONDS)) {executor.shutdownNow();}} catch (InterruptedException e) {executor.shutdownNow();}}public static void main(String[] args) {System.out.println("=================令牌桶算法=================");// 創建一個令牌桶,令牌的生成速度為每秒4個,桶容量為5個請求TokenBucketLimiter limiter = new TokenBucketLimiter(4, 5);// 發送10個請求,每50ms發送一個mockRequest(10, Duration.ofMillis(50), limiter);System.out.println("------------------------------------------");}
}
3. 針對什么進行限流?
實際項目中,針對的先流對象是什么?也就是說針對什么來進行限流?常見的限流對象如下:
- 針對IP限流:比較常見,比如通過X-forwarded-For或TCP options字段中真實源IP信息。
- 業務ID:挑選唯一的業務ID實現限流。比如,根據用戶的ID進行限流。(寫leetcode的時候多次提交,提醒提交太快)。
- 個性化:根據用戶的屬性或者行為。進行不同的限流策略。例如,VIP用戶不限流,普通用戶限流。
4. 單機限流
單機限流是指在單臺服務器上,通過限制其在單位時間內處理的請求數量來防止過載。
單機限流可以使用Google Guava自帶的限流工具類 RateLimiter。RateLimiter是基于令牌桶算法實現的,可以應對突發流量。
5. 分布式限流
單機限流只能保證一個節點限流,但是無法保證分布式限流。
分布式限流和分布式鎖的思想是一樣的,就是將原本設計實現在本地服務的限流操作抽離出來,做成一個中心化的限流器,實現全局共享。
分布式限流常見方案:可以借助Sentinel或者使用Redis來自己實現對應的限流邏輯。
基于Redis的實現步驟如下:(Redis + Lua)
- 選取一個Redis中心化組件
- 設定限流規則,比如每秒允許的最大請求數(QPS),并且將該值存儲在Redis中。
- 每當有請求到達時,服務器首先會向Redis請求令牌。
- 如果獲得令牌,請求可以繼續處理;如果未獲得令牌,則表示請求被限流,執行拒絕策略。
local key = KEYS[1] -- hashmap的key
local rate = tonumber(ARGV[1]) -- 令牌生成速率
local capacity = tonumber(ARGV[2]) -- 桶的容量
local now = tonumber(ARGV[3]) -- 當前時間(毫秒)
local requested = tonumber(ARGV[4]) -- 請求的令牌數-- 獲取上次更新時間和當前令牌數
local last_time = redis.call('HGET', key, 'last_time')
local current_tokens = redis.call('HGET', key, 'tokens')-- 如果是首次訪問,那么就將last_time初始化為當前時間,當前令牌數為桶容量
if last_time == false thenlast_time = nowcurrent_tokens = capacity
else-- 不是第一次訪問,那么從redis獲取上次訪問時間和當前令牌數last_time = tonumber(last_time)current_tokens = tonumber(current_tokens)
end-- 計算時間差
local time_diff = now - last_time
-- 計算新的令牌數
local new_tokens = math.min(capacity, current_tokens + (time_diff * rate / 1000))local allowed = new_tokens >= requestedif allowed thennew_tokens = new_tokens - requested
endredis.call('HSET', key, 'last_time', now)
redis.call('HSET', key, 'tokens', new_tokens)return allowed