引言:為什么需要限流?
在現代分布式系統中,服務的穩定性是至關重要的。在遇到突發的請求量激增,惡意的用戶訪問,亦或是請求頻率過高給下游服務帶來較大壓力時,我們常常需要通過緩存、限流、熔斷降級、負載均衡等多種方式保證服務的穩定性。其中限流是不可或缺的一環,這篇文章介紹限流相關知識。
限流(Rate Limiting)?正是為了解決這一問題而生的關鍵技術。它通過控制請求的速率,將流量限制在系統所能處理的合理范圍內,從而:
保障服務可用性:防止系統被過量請求拖垮。
實現公平使用:保護資源不被少數用戶或惡意請求獨占。
應對突發流量:通過平滑處理請求,避免后端服務被沖垮。
四種常見的限流算法
限流方式 | 核心原理 | 實現復雜度 |
---|---|---|
固定窗口限流算法 | 將時間劃分為固定窗口(如 1 秒),統計窗口內請求數,超過閾值則限流 | 低 |
滑動窗口限流算法 | 將固定窗口劃分為多個小時間片,隨時間滑動計算窗口內請求數 | 中 |
漏桶算法 | 請求像水一樣進入固定容量的桶,桶以固定速率出水,溢出則限流 | 中 |
令牌桶算法 | 系統以固定速率向桶中放令牌,請求需獲取令牌才能通過,桶可累積令牌 | 中 |
1.固定窗口限流算法
1.1 什么是固定窗口算法
固定窗口算法又叫計數器算法,是一種簡單方便的限流算法。他的原理是:維護一個計數器,將固定單位時間段當作一個窗口,計數器會記錄這個窗口接收請求的次數。
當一個新的請求到達時,系統會檢查當前窗口內的計數器值。
1.如果計數器未達到預設的閾值,允許請求訪問,計數器自增。
2.當計數器已達到或大于限流閾值,拒絕該請求,觸發限流規則。
3.直到進入下一個時間窗口,此時計數器會被重置為零,重新開始計數
假設單位時間是60秒,限流閥值為10。在單位時間60秒內,每來一個請求,計數器就加1,如果計數器累加的次數超過限流閥值10,后續的請求全部會被拒絕。直到進入下一個時間窗口,此時計數器會被重置為零,重新開始計數。
1.2 偽代碼實現
public static Integer counter = 0; //統計請求數public static long lastAcquireTime = 0L;public static final Long windowUnit = 1000L ; //假設固定時間窗口是1000mspublic static final Integer threshold = 10; // 窗口閥值是10public synchronized boolean fixedWindowsTryAcquire() {long currentTime = System.currentTimeMillis(); //獲取系統當前時間if (currentTime - lastAcquireTime > windowUnit) { //檢查是否在時間窗口內counter = 0; // 計數器清0lastAcquireTime = currentTime; //開啟新的時間窗口}if (counter < threshold) { // 小于閥值counter++; //計數統計器加1return true;}return false;}
1.2 固定窗口算法的優缺點
優點:實現簡單,資源消耗低
缺點:存在臨界問題,可能在窗口切換時出現雙倍流量峰值。
舉例:窗口大小為 1 秒,閾值為 10 次請求。第 1 個窗口的最后 100ms 內涌入 10 次請求(未超限)。第 2 個窗口的前 100ms 內又涌入 10 次請求(未超限)。實際 200ms 內總請求數達到 20 次,遠超 “1 秒 10 次” 的限制,可能瞬間壓垮系統。
2.?滑動窗口限流算法
2.1 什么是滑動窗口限流算法
滑動窗口算法可以看作是固定窗口算法的一種改進和優化。它不再將時間劃分為離散的、獨立的窗口,而是將時間視為一個連續的整體。它將單位時間周期分為n個小周期,分別記錄每個小周期內接口的訪問次數,并且根據時間滑動刪除過期的小周期。
2.2 滑動窗口解決邊界問題
如圖:在 0.8-1.0 秒來了 5 個請求后,1.0-1.2 秒再發 5 個請求,固定窗口會認為是兩個窗口而全部允許,滑動窗口則因這 10 個請求都在 0.2-1.2 秒的滑動區間內而限流后 5 個,解決了邊界流量突增問題。
場景 | 固定窗口處理邏輯 | 滑動窗口處理邏輯 |
---|---|---|
邊界點前后請求 | 分屬兩個窗口,分別計數 | 同屬 “最近 1 秒” 窗口,合并計數 |
1000~1200ms 請求數 | 允許 5 個(總 10 個) | 全部限流(總請求數超 5) |
核心原因 | 窗口固定,邊界點前后割裂 | 窗口動態滑動,覆蓋邊界點前后區間 |
2.3 偽代碼實現
?
public class SimpleSlidingWindow {// 窗口配置private static final long WINDOW_MS = 1000; // 總窗口1秒private static final int SUB_WINDOWS = 5; // 5個小窗口private static final long SUB_WINDOW_MS = WINDOW_MS / SUB_WINDOWS; // 每個200msprivate static final int THRESHOLD = 5; // 限流閾值// 存儲每個小窗口的請求數private final int[] counts = new int[SUB_WINDOWS];private int totalRequests; // 當前窗口總請求數private final Lock lock = new ReentrantLock();public boolean tryAcquire() {long now = System.currentTimeMillis();lock.lock();try {// 1. 計算當前小窗口索引int currentIdx = (int) (now / SUB_WINDOW_MS % SUB_WINDOWS);// 2. 清理過期的小窗口long windowStart = now - WINDOW_MS;for (int i = 0; i < SUB_WINDOWS; i++) {long subWindowStart = (i * SUB_WINDOW_MS);if (subWindowStart < windowStart) {totalRequests -= counts[i];counts[i] = 0;}}// 3. 判斷是否超過閾值if (totalRequests >= THRESHOLD) {return false;}// 4. 記錄當前請求counts[currentIdx]++;totalRequests++;return true;} finally {lock.unlock();}}?
2.4 滑動窗口算法的優缺點
優點:能平滑流量,解決固定窗口臨界問題,避免窗口切換時流量翻倍。
缺點:實現復雜度高,需維護動態數據結構,增加內存和計算開銷。
3. 漏桶限流算法
3.1 什么是漏桶限流算法
漏桶限流算法的核心思想是將請求視為水滴,進入一個固定容量的桶(隊列)中。桶的底部有一個小孔,水會以一個恒定的速率從桶中流出,這個速率代表了系統能夠穩定處理請求的速率。當請求(水滴)的流入速率小于或等于桶的流出速率時,請求可以被及時處理,不會發生積壓。
當請求的流入速率小于或等于桶的流出速率時,請求可以被及時處理,不會發生積壓。
當請求的流入速率超過流出速率時,桶中的水會逐漸增多,桶中的未處理請求會增加。
如果桶被裝滿,那么后續新進入的請求就會被直接丟棄或拒絕,直到桶中有足夠的空間為止。
3.2 偽代碼實現
public class LeakyBucketRateLimiter {private final long capacity; // 桶的容量private long water; // 當前桶中的水量(請求數)private final long rate; // 漏出速率 (請求/秒)private long lastLeakTimestamp; // 上次漏水時間戳public LeakyBucketRateLimiter(long capacity, long rate) {this.capacity = capacity;this.rate = rate;this.water = 0;this.lastLeakTimestamp = System.currentTimeMillis();}public synchronized boolean tryAcquire() {long now = System.currentTimeMillis();// 計算這段時間內應該漏掉的水量long leakedWater = (now - lastLeakTimestamp) * rate / 1000;// 更新桶中的水量,但不能小于0water = Math.max(0, water - leakedWater);lastLeakTimestamp = now;// 判斷桶是否還有空間if (water < capacity) {water++; // 新請求加入桶中return true; // 請求被接受} else {return false; // 桶滿了,拒絕請求}}
}
3.3?漏桶算法的優缺點
優點:漏桶算法最突出的優點是能提供絕對平滑、恒定的請求處理速率(水流穩定輸出)
缺點:無法應對突發流量,即便系統有空閑資源,超出自設流出速率的請求也會被拒;當請求速率持續高于流出速率時,桶會快速填滿,導致后續請求被阻或拒,增加平均延遲甚至超時。
4. 令牌桶限流算法(非常推薦)
4.1?什么是令牌桶限流算法
令牌桶算法的核心思想是,系統會以一個恒定的速率向一個固定容量的桶中放入令牌。每個請求在到達時,都需要先從桶中獲取一個令牌。如果桶中有可用的令牌,請求就可以立即被處理,并消耗掉一個令牌。如果桶中沒有令牌了,那么請求就會被拒絕或阻塞,直到有新的令牌被放入桶中。
解決突發流量問題:令牌桶在應對突發流量的時候,桶內假如有 10?個令牌,那么這 10 個令牌可以馬上被取走,而不像漏桶那樣勻速的消費。所以在應對突發流量的時候令牌桶表現的更佳。
4.2 偽代碼實現
public class TokenBucket {private final double capacity; // 令牌桶容量private final double rate; // 令牌生成速率 (令牌/毫秒)private double tokens; // 當前桶中的令牌數private long lastRefillTimestamp; // 上次填充令牌的時間戳public TokenBucket(double capacity, double rate) {this.capacity = capacity;this.rate = rate;this.tokens = capacity; // 初始時桶是滿的this.lastRefillTimestamp = System.currentTimeMillis();}public synchronized boolean acquire(int numTokens) {long now = System.currentTimeMillis();// 計算這段時間內應該生成的令牌數double newTokens = (now - lastRefillTimestamp) * rate;// 更新桶中的令牌數,但不能超過容量tokens = Math.min(capacity, tokens + newTokens);lastRefillTimestamp = now;// 判斷桶中是否有足夠的令牌if (tokens >= numTokens) {tokens -= numTokens; // 消耗令牌return true; // 請求被允許} else {return false; // 令牌不足,請求被拒絕}}
}
在這個實現中,
tryAcquire()
方法首先根據時間差計算出應該“漏掉”的請求數量,并更新桶中的當前水量。然后,它檢查桶是否已滿。如果未滿,則將新請求加入桶中(water++
),并返回true
;否則,返回false
,表示請求被拒絕。這種實現方式確保了請求的處理速率被嚴格限制在rate
所定義的范圍內,從而實現了流量的平滑輸出。
4.3?漏桶算法的優缺點
優點:支持突發流量,低負載時積累的令牌可應對突發請求,適用于秒殺、突發熱點等場景;靈活性高,可通過調整令牌生成速率和桶容量平衡系統穩定性與資源利用率。
缺點:實現較復雜,需精確處理令牌生成與消費及并發一致性。(復雜度較高)
5. 基于Redisson的令牌桶限流實戰
RRateLimiter+AOP:
基于令牌桶算法的分布式限流器RRateLimiter,
結合Spring框架的AOP(面向切面編程)技術,將限流邏輯應用到業務代碼中,實現注解式的分布式限流。
5.1 自定義限流注解@RateLimiter
首先,我們需要定義一個自定義注解@RateLimiter
,用于標記需要進行限流的方法。這個注解可以包含一些配置屬性,如限流的key、限流速率、時間單位、限流類型(按IP、按用戶ID等)以及被限流時的提示信息等
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiter {// 限流key的前綴String key() default "";// 限流速率(單位時間內允許的請求數)int count() default 10;// 限流時間單位(秒)int time() default 60;// 限流類型(IP、用戶等)LimitType limitType() default LimitType.USER;// 被限流時的提示信息String message() default "請求頻繁,請稍后重試";
}enum LimitType {IP,USER,API
}
5.2 定義限流切面RateLimiterAspect
接下來,我們需要定義一個AOP切面?RateLimiterAspect?
,它負責攔截所有帶有?@RateLimiter?
注解的方法,并在方法執行前執行限流邏輯。這個切面會解析注解中的配置,生成唯一的限流key,然后調用Redisson的RRateLimiter
來判斷當前請求是否應該被允許。
@Aspect
@Component
@Slf4j
public class RateLimitAspect {@Resourceprivate RedissonClient redissonClient;@Resourceprivate UserService userService;@Before("@annotation(rateLimit)")public void doBefore(JoinPoint point, RateLimit rateLimit) {String key = generateRateLimitKey(point, rateLimit);// 使用Redisson的分布式限流器RRateLimiter rateLimiter = redissonClient.getRateLimiter(key);rateLimiter.expire(Duration.ofHours(1)); // 1 小時后過期// 設置限流器參數:每個時間窗口允許的請求數和時間窗口rateLimiter.trySetRate(RateType.OVERALL, rateLimit.rate(), rateLimit.rateInterval(), RateIntervalUnit.SECONDS);// 嘗試獲取令牌,如果獲取失敗則限流if (!rateLimiter.tryAcquire(1)) {throw new BusinessException(ErrorCode.TOO_MANY_REQUEST, rateLimit.message());}}private String generateRateLimitKey(JoinPoint point, RateLimit rateLimit) {StringBuilder keyBuilder = new StringBuilder();keyBuilder.append("rate_limit:");// 添加自定義前綴if (!rateLimit.key().isEmpty()) {keyBuilder.append(rateLimit.key()).append(":");}// 根據限流類型生成不同的keyswitch (rateLimit.limitType()) {case API:// 接口級別:方法名MethodSignature signature = (MethodSignature) point.getSignature();Method method = signature.getMethod();keyBuilder.append("api:").append(method.getDeclaringClass().getSimpleName()).append(".").append(method.getName());break;case USER:// 用戶級別:用戶IDtry {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();if (attributes != null) {HttpServletRequest request = attributes.getRequest();User loginUser = userService.getLoginUser(request);keyBuilder.append("user:").append(loginUser.getId());} else {// 無法獲取請求上下文,使用IP限流keyBuilder.append("ip:").append(getClientIP());}} catch (BusinessException e) {// 未登錄用戶使用IP限流keyBuilder.append("ip:").append(getClientIP());}break;case IP:// IP級別:客戶端IPkeyBuilder.append("ip:").append(getClientIP());break;default:throw new BusinessException(ErrorCode.SYSTEM_ERROR, "不支持的限流類型");}return keyBuilder.toString();}private String getClientIP() {ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();if (attributes == null) {return "unknown";}HttpServletRequest request = attributes.getRequest();String ip = request.getHeader("X-Forwarded-For");if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {ip = request.getHeader("X-Real-IP");}if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {ip = request.getRemoteAddr();}// 處理多級代理的情況if (ip != null && ip.contains(",")) {ip = ip.split(",")[0].trim();}return ip != null ? ip : "unknown";}}
3.2.3 在業務方法上應用限流注解
只需在需要進行限流控制的方法上添加@RateLimiter
注解,配置實際要求即可。
//@GetMapping(value = "/chat/gen/code", produces = MediaType.TEXT_EVENT_STREAM_VALUE)//用戶限制 單個窗口請求數量 窗口時長 響應的消息
@RateLimit(limitType = RateLimitType.USER, rate = 5, rateInterval = 60, message = "AI 對話請求過于頻繁,請稍后再試")
public Flux<ServerSentEvent<String>> chatToGenCode(@RequestParam Long appId,@RequestParam String message,HttpServletRequest request) {}
在這個例子中,/chat/gen/code
接口被配置為單個用戶每分鐘最多只能處理5個請求。任何超出這個頻率的請求都會被AOP切面攔截并拒絕。這種方式將限流邏輯與業務邏輯完全解耦,使得代碼更加清晰、易于維護。