Spring AOP + Guava RateLimiter實現(xiàn)優(yōu)雅限流
寫在前面
提起 AOP(面向切面編程),大家的第一反應(yīng)往往是:“哦,那個用來打印日志、管理事務(wù)、或者做權(quán)限校驗的。”
其實,AOP 的能力遠不止于此。在面對高并發(fā)場景下的接口自我保護時,它同樣能發(fā)揮奇效。
最近在項目中遇到了一個真實場景:這是一個基于 MQ 觸發(fā)的定時跑批任務(wù)。平日里風平浪靜,可是一旦大促或者數(shù)據(jù)量激增,MQ 里的積壓消息就會瞬間推送給消費者。
雖然消費者服務(wù)雖然處理得過來,但底層的核心業(yè)務(wù)數(shù)據(jù)庫卻扛不住了——大量并發(fā)查詢瞬間打滿 CPU,CPU 使用率飆升至 100%,直接影響了線上實時業(yè)務(wù)的穩(wěn)定性。
考慮到該服務(wù)是單節(jié)點部署,引入 Redis 做分布式限流顯得“殺雞用牛刀”,也增加了額外的運維成本。最終,我決定使用 Spring AOP + Guava RateLimiter + 自定義注解,實現(xiàn)一個 無侵入、可配置、輕量級 的單機限流組件。
一、 為什么選擇 AOP + 注解?
在介紹代碼之前,先明確設(shè)計初衷。
以前我剛接觸開發(fā)時,也喜歡在 Service 或 Controller 層直接硬編碼限流邏輯,例如:
// ? 反例:硬編碼,邏輯混雜且難以復用
if (!rateLimiter.tryAcquire()) {
throw new RuntimeException("系統(tǒng)繁忙");
}
doBusiness();這種寫法的弊端很明顯:
- 邏輯混雜:清晰的業(yè)務(wù)代碼中夾雜著非業(yè)務(wù)的限流判斷。
- 復用性差:如果有十個接口需要限流,就需要重復編寫十次。
- 維護困難:一旦需要調(diào)整限流策略(例如升級為分布式限流),涉及的修改點將非常多。
AOP(面向切面編程) 的核心就是 “解耦” 和 “復用”。
我將限流邏輯封裝為一個獨立的“切面”,配合自定義注解作為“開關(guān)”。只需在目標方法上添加一個注解,限流策略隨即生效。后續(xù)的維護與升級,也僅需聚焦于切面邏輯本身,無需觸碰任何業(yè)務(wù)代碼。
二、 Guava RateLimiter 核心原理
我這次選用的核心庫是 Google Guava 的 RateLimiter。它是基于 令牌桶算法(Token Bucket) 實現(xiàn)的。
1. 簡單回顧令牌桶
它的機制不像“漏桶”那樣死板(恒定速率流出),而是更加人性化:
- 生產(chǎn)令牌:系統(tǒng)以固定速率向桶中放入令牌。
- 消費令牌:請求過來時,必須先拿到令牌才能執(zhí)行。
- 關(guān)鍵特性:支持突發(fā)流量。如果一段時間沒有請求,桶里的令牌會積攢起來(直到達到桶上限)。當一波突發(fā)流量到來時,可以直接消耗積攢的令牌立刻執(zhí)行,而不需要排隊等待。
2. 兩種核心模式
Guava 貼心地提供了兩種實現(xiàn):
- SmoothBursty(平滑突發(fā)):默認模式。適合大多數(shù)場景,允許短時間的流量突發(fā)。
- SmoothWarmingUp(平滑預熱):預熱模式。啟動初期令牌發(fā)放速率較慢,隨著時間推移逐步提升到目標 QPS。這對于需要“熱身”的資源(如數(shù)據(jù)庫連接池、緩存填充)非常友好,防止冷啟動時瞬間被打掛。
3. 單機版警告 ??
注意:Guava RateLimiter 是 單機限流 工具!令牌是存在當前 JVM 內(nèi)存里的。
- 如果你的服務(wù)只部署一臺機器,它完美勝任。
- 如果你部署了 10 臺機器,每臺設(shè)置 QPS=5,那么整個集群的總 QPS 上限是 50。
4. 常用 API 詳解
熟練掌握 API 是實戰(zhàn)的基礎(chǔ),以下是 RateLimiter 的核心方法:
核心創(chuàng)建方法
| 方法簽名 | 說明 |
|---|---|
| create(double permitsPerSecond) | 創(chuàng)建 SmoothBursty 限流器,指定每秒生成的令牌數(shù)(默認:permitsPerSecond = QPS = 桶容量)。 |
| create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) | 創(chuàng)建 SmoothWarmingUp 限流器,指定 QPS + 預熱時間。 |
核心獲取方法
| 方法簽名 | 說明 |
|---|---|
| double acquire() | 阻塞式獲取 1 個令牌。若無令牌,線程會一直等待,直到獲取成功。 |
| double acquire(int permits) | 阻塞式獲取指定數(shù)量的令牌(可一次獲取多個)。 |
| boolean tryAcquire() | 非阻塞式獲取 1 個令牌。立即返回:成功 true,失敗 false(不等待)。 |
| boolean tryAcquire(long timeout, TimeUnit unit) | 限時等待獲取 1 個令牌。在超時時間內(nèi)拿到返回 true,否則返回 false。這是最推薦的用法,既避免了線程死等,又提供了一定的緩沖。 |
三、 代碼實戰(zhàn):打造企業(yè)級限流組件
接下來,我來實現(xiàn)一個功能完備的 @RateLimit 組件,支持QPS配置、阻塞/非阻塞模式、超時控制以及預熱模式。
1. 引入依賴
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>32.1.3-jre</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>2. 定義注解@RateLimit
這個注解承載了限流的所有配置元數(shù)據(jù)。
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 限流閾值 (QPS),默認每秒 5 個
*/
double qps() default 5.0;
/**
* 獲取令牌的策略
* true: 阻塞模式(直到拿到令牌或超時)
* false: 非阻塞模式(拿不到立即失?。?
*/
boolean block() default true;
/**
* 阻塞等待的超時時間(僅當 block=true 時生效)
* 默認 0,表示無限等待
*/
long timeout() default 0;
/**
* 超時時間單位
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 預熱時間
* 默認 0 (SmoothBursty);設(shè)置 >0 則開啟預熱模式 (SmoothWarmingUp)
*/
long warmupPeriod() default 0;
/**
* 預熱時間單位
*/
TimeUnit warmupUnit() default TimeUnit.SECONDS;
/**
* 限流提示信息
*/
String message() default "系統(tǒng)繁忙,請稍后再試";
}3. 定義全局異常RateLimitException
public class RateLimitException extends RuntimeException {
public RateLimitException(String message) {
super(message);
}
}
4. 實現(xiàn)切面RateLimitAop
這是限流組件的“大腦”。需要重點關(guān)注實例緩存、線程安全以及不同策略的執(zhí)行邏輯。
import com.google.common.util.concurrent.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Slf4j
@Aspect
@Component
public class RateLimitAop {
// 使用 ConcurrentHashMap 緩存 RateLimiter 實例,確保線程安全
// Key: 方法簽名 (類名.方法名(參數(shù)類型)), Value: 限流器實例
private final Map<String, RateLimiter> rateLimiterCache = new ConcurrentHashMap<>();
@Pointcut("@annotation(com.example.annotation.RateLimit)")
public void rateLimitPointcut() {}
@Around("rateLimitPointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
RateLimit annotation = method.getAnnotation(RateLimit.class);
// 1. 構(gòu)建方法唯一 Key,防止方法重載沖突
String methodKey = buildMethodKey(method);
// 2. 線程安全地創(chuàng)建或獲取限流器
RateLimiter rateLimiter = rateLimiterCache.computeIfAbsent(methodKey, key -> createRateLimiter(annotation));
// 3. 執(zhí)行獲取令牌邏輯
boolean acquireSuccess;
if (annotation.block()) {
// --- 阻塞模式 ---
if (annotation.timeout() <= 0) {
// 無限等待,直到成功
rateLimiter.acquire();
acquireSuccess = true;
} else {
// 限時等待
acquireSuccess = rateLimiter.tryAcquire(annotation.timeout(), annotation.timeUnit());
}
} else {
// --- 非阻塞模式 ---
// 立即嘗試,失敗即返回
acquireSuccess = rateLimiter.tryAcquire();
}
// 4. 限流攔截
if (!acquireSuccess) {
log.warn("【限流報警】方法 {} 請求頻率過高,已拒絕。", methodKey);
throw new RateLimitException(annotation.message());
}
// 5. 放行
return joinPoint.proceed();
}
/**
* 生成方法簽名:Package.Class.Method(ParamType1,ParamType2)
*/
private String buildMethodKey(Method method) {
StringBuilder keyBuilder = new StringBuilder();
keyBuilder.append(method.getDeclaringClass().getName())
.append(".").append(method.getName()).append("(");
Class<?>[] parameterTypes = method.getParameterTypes();
for (int i = 0; i < parameterTypes.length; i++) {
keyBuilder.append(parameterTypes[i].getSimpleName());
if (i < parameterTypes.length - 1) {
keyBuilder.append(",");
}
}
keyBuilder.append(")");
return keyBuilder.toString();
}
/**
* 工廠方法:根據(jù)配置創(chuàng)建具體的 RateLimiter
*/
private RateLimiter createRateLimiter(RateLimit annotation) {
if (annotation.warmupPeriod() > 0) {
log.info("創(chuàng)建預熱限流器: QPS={}, Warmup={}s", annotation.qps(), annotation.warmupPeriod());
return RateLimiter.create(annotation.qps(), annotation.warmupPeriod(), annotation.warmupUnit());
} else {
log.info("創(chuàng)建標準限流器: QPS={}", annotation.qps());
return RateLimiter.create(annotation.qps());
}
}
}
5. 業(yè)務(wù)接入示例
@Service
public class DataSyncService {
// 場景1:核心數(shù)據(jù)同步,允許排隊等待500ms,保證盡可能執(zhí)行
@RateLimit(qps = 10.0, block = true, timeout = 500)
public void syncImportantData(List<Data> dataList) {
// ... 業(yè)務(wù)邏輯 ...
}
// 場景2:非核心接口,流量大時直接丟棄,保護系統(tǒng)
@RateLimit(qps = 50.0, block = false, message = "當前訪問人數(shù)過多")
public void refreshCache() {
// ... 刷新邏輯 ...
}
}
四、 進階:聊聊動態(tài)代理那個“大家都知道”的坑
在使用 AOP 時,有一個經(jīng)典面試題級別的現(xiàn)象:類內(nèi)方法自調(diào)用導致 AOP 失效。作為開發(fā)者,我們不止要知其然,更知其所以然。
場景重現(xiàn)
@Service
public class TradeService {
public void process() {
// ... 前置處理 ...
pay(); // ? 重點在這里:直接調(diào)用內(nèi)部方法
}
@RateLimit(qps = 5.0)
public void pay() { ... }
}
為什么會失效?
Spring AOP 的底層使用的是 動態(tài)代理。
- 容器啟動時,Spring 為
TradeService生成了一個代理對象(Proxy)。 - 外部調(diào)用
process()時,先走的是代理。 - 但在
process()內(nèi)部執(zhí)行pay()時,使用的是this.pay()。這里的this指向的是目標對象本身,而非代理對象。 - 既然沒經(jīng)過代理,切面邏輯自然就像空氣一樣被穿透了。
避坑建議
針對此類問題,我推薦以下處理方式:
推薦:拆分大法(Best Practice)
將 pay() 方法拆分到另一個獨立的 Bean(例如 PayService)中。通過注入的方式調(diào)用,天然符合“通過代理調(diào)用”的規(guī)則,代碼結(jié)構(gòu)也更清晰。
推薦:AopContext
直接從 Spring 上下文中撈取當前代理對象。(老功能修改)
- SpringBoot啟動類上開啟配置:
@EnableAspectJAutoProxy(exposeProxy = true) - 具體代碼中修改:
((TradeService) AopContext.currentProxy()).pay();
不推薦:@Autowired 注入自身
雖然能解決問題,但容易引發(fā)循環(huán)依賴異常,增加系統(tǒng)啟動風險。
五、 進階思考:從單機到分布式
前面我強調(diào)了 Guava RateLimiter 是單機限流。那么,如果系統(tǒng)做大了,部署了 50 個節(jié)點,需要對某個下游 API 做全局每秒 1000 次的限流,該怎么辦?
這時候,AOP + 注解 設(shè)計模式的威力就體現(xiàn)出來了。
你完全不需要修改任何業(yè)務(wù)代碼,也不用刪掉 @RateLimit 注解。 你只需要做一個動作:修改 RateLimitAop 切面的實現(xiàn)。
把切面里獲取令牌的邏輯,從 Guava RateLimiter 換成 Redis + Lua 腳本,或者直接接入 Redisson 的 RRateLimiter。
// 偽代碼示例:無縫切換分布式限流
private RRateLimiter getRedisLimiter(String key) {
RRateLimiter limiter = redissonClient.getRateLimiter(key);
// ... 初始化 Redis 限流器 ...
return limiter;
}
// 在 around 方法里,將 RateLimiter.tryAcquire() 替換為 Redisson 的實現(xiàn)
RRateLimiter limiter = getRedisLimiter(methodKey);
if (!limiter.tryAcquire(annotation.qps(), annotation.timeout(), annotation.timeUnit())) {
throw new RateLimitException("分布式限流生效中...");
}
看,這就是架構(gòu)設(shè)計的藝術(shù)。業(yè)務(wù)方無感知,底層能力平滑升級。
六、 總結(jié)與結(jié)語
總的來說,AOP 讓限流這類“基礎(chǔ)設(shè)施”悄無聲息地融入了業(yè)務(wù)脈絡(luò),這正是優(yōu)雅架構(gòu)的魅力所在——將復雜性收斂于一點,在別處換來 simplicity。
到此這篇關(guān)于Spring AOP + Guava RateLimiter實現(xiàn)優(yōu)雅限流的文章就介紹到這了,更多相關(guān)Spring AOP Guava RateLimiter限流內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
SpringBoot集成Mybatis的實現(xiàn)步驟
這篇文章主要介紹了SpringBoot集成Mybatis的實現(xiàn)步驟,本文通過SpringBoot +MyBatis 實現(xiàn)對數(shù)據(jù)庫學生表的查詢操作,需要的朋友可以參考下2020-12-12
Java List轉(zhuǎn)換成String數(shù)組幾種實現(xiàn)方式詳解
這篇文章主要介紹了Java List轉(zhuǎn)換成String數(shù)組幾種實現(xiàn)方式詳解的相關(guān)資料,需要的朋友可以參考下2016-12-12
spring?boot集成jasypt?并實現(xiàn)自定義加解密的詳細步驟
由于項目中的配置文件?配置的地方過多,現(xiàn)將配置文件統(tǒng)一放到nacos上集中管理?且密碼使用加密的方式放在配置文件中,配置文件的加密使用加密庫jasypt,本文給大家介紹spring boot集成jasypt并實現(xiàn)自定義加解密,感興趣的朋友一起看看吧2023-08-08
IDEA創(chuàng)建SpringBoot的maven項目的方法步驟
這篇文章主要介紹了IDEA創(chuàng)建SpringBoot的maven項目的方法步驟,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2021-04-04
Java中NoClassDefFoundError?和?ClassNotFoundException的區(qū)別
Java中NoClassDefFoundError和ClassNotFoundException的區(qū)別,從類繼承層次上來看,ClassNotFoundException是從Exception繼承的,所以ClassNotFoundException是一個檢查異常。具體詳情需要的朋友可以參考下面文章內(nèi)容2022-06-06

