一、背景與需求
在實際項目開發中,經常遇到接口被前端高頻觸發、按鈕被多次點擊或者接口重復提交的問題,導致服務壓力變大、數據冗余、甚至引發冪等性/安全風險。
常規做法是前端節流/防抖、后端用Redis全局限流、或者API網關限流。但在很多場景下:
-
接口只要求單機(本地)防抖,不需要全局一致性;
-
只想讓同一個業務對象(同一手機號、同一業務ID、唯一標識)在自定義設置秒內只處理一次;
-
想要注解式配置,讓代碼更優雅、好維護。
這個時候,Caffeine+自定義注解+AOP的本地限流(防抖)方案非常合適。
二、方案設計
1. Caffeine介紹
Caffeine 是目前Java領域最熱門、性能最高的本地內存緩存庫,QPS可達百萬級,適用于低延遲、高并發、短TTL緩存場景。
在本地限流、防抖、接口去重等方面天然有優勢。
2. 自定義注解+AOP
用自定義注解(如@DebounceLimit
)標記要防抖的接口,AOP切面攔截后判斷是否需要限流,核心思路是:
-
以唯一標識作為key;
-
每次訪問接口,先查詢本地Caffeine緩存;
-
如果key在2秒內已被處理過,則直接攔截;
-
否則執行業務邏輯,并記錄處理時間。
這種方式無侵入、代碼簡潔、可擴展性強,適合絕大多數本地場景。
效果圖如下:
三、完整實現步驟
1.Pom依賴如下
<dependency><groupId>com.github.ben-manes.caffeine</groupId><artifactId>caffeine</artifactId><version>2.9.3</version></dependency><dependency><groupId>org.aspectj</groupId><artifactId>aspectjweaver</artifactId></dependency>
2. 定義自定義注解
import java.lang.annotation.*;@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DebounceLimit {/*** 唯一key(支持SpEL表達式,如 #dto.id)*/String key();/*** 防抖時間,單位秒*/int ttl() default 2;/*** 是否返回上次緩存的返回值*/boolean returnLastResult() default true;
}
3. 配置Caffeine緩存Bean
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;import java.util.concurrent.TimeUnit;@Configuration
public class DebounceCacheConfig {@Beanpublic Cache<String, Object> debounceCache() {return Caffeine.newBuilder().expireAfterWrite(1, TimeUnit.MINUTES).maximumSize(100_000).build();}
}
4. 編寫AOP切面
import com.github.benmanes.caffeine.cache.Cache;
import com.lps.anno.DebounceLimit;
import lombok.Getter;
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.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;@Slf4j
@Aspect
@Component
public class DebounceLimitAspect {@Autowiredprivate Cache<String, Object> debounceCache;private final ExpressionParser parser = new SpelExpressionParser();@Around("@annotation(debounceLimit)")public Object around(ProceedingJoinPoint pjp, DebounceLimit debounceLimit) throws Throwable {// 1. 獲取方法、參數MethodSignature methodSignature = (MethodSignature) pjp.getSignature();Method method = methodSignature.getMethod();Object[] args = pjp.getArgs();String[] paramNames = methodSignature.getParameterNames();StandardEvaluationContext context = new StandardEvaluationContext();for (int i = 0; i < paramNames.length; i++) {context.setVariable(paramNames[i], args[i]);}// 2. 解析SpEL表達式得到唯一keyString key = parser.parseExpression(debounceLimit.key()).getValue(context, String.class);String cacheKey = method.getDeclaringClass().getName() + "." + method.getName() + ":" + key;long now = System.currentTimeMillis();DebounceResult<Object> debounceResult = (DebounceResult<Object>) debounceCache.getIfPresent(cacheKey);if (debounceResult != null && (now - debounceResult.getTimestamp() < debounceLimit.ttl() * 1000L)) {String methodName = pjp.getSignature().toShortString();log.error("接口[{}]被限流, key={}", methodName, cacheKey);// 是否返回上次結果if (debounceLimit.returnLastResult() && debounceResult.getResult() != null) {return debounceResult.getResult();}// 統一失敗響應,可自定義異常或返回結構return new RuntimeException("操作過于頻繁,請稍后再試!");}Object result = pjp.proceed();debounceCache.put(cacheKey, new DebounceResult<>(result, now));return result;}@Getterstatic class DebounceResult<T> {private final T result;private final long timestamp;public DebounceResult(T result, long timestamp) {this.result = result;this.timestamp = timestamp;}}
}
5. 控制器里直接用注解實現防抖
@RestController
@RequiredArgsConstructor
@Slf4j
public class DebounceControl {private final UserService userService;@PostMapping("/getUsernameById")@DebounceLimit(key = "#dto.id", ttl = 10)public String test(@RequestBody User dto) {log.info("在{}收到了請求,參數為:{}", DateUtil.now(), dto);return userService.getById(dto.getId()).getUsername();}
}
只要加了這個注解,同一個id的請求在自定義設置的秒內只處理一次,其他直接被攔截并打印日志。
四、擴展與注意事項
-
SpEL表達式靈活
-
可以用
#dto.id
、#dto.mobile
、#paramName
等,非常適合多參數、復雜唯一性業務場景。
-
-
returnLastResult適合有“緩存返回結果”的場景
-
比如查詢接口、表單重復提交直接復用上次的返回值。
-
-
本地限流僅適用于單機環境
-
多節點部署建議用Redis分布式限流,原理一樣。
-
-
緩存key建議加上方法簽名
-
避免不同接口之間key沖突。
-
-
Caffeine最大緩存、過期時間應根據業務并發和內存合理設置
-
絕大多數接口幾千到幾萬key都沒壓力。
-
五、適用與不適用場景
適用:
-
單機接口防抖/限流
-
短時間重復提交防控
-
按業務唯一標識維度防刷
-
秒殺、報名、投票等接口本地保護
不適用:
-
分布式場景(建議用Redis或API網關限流)
-
需要全局一致性的業務
-
內存非常敏感/極端高并發下,需結合Redis做混合限流
六、總結
Caffeine + 注解 + AOP的本地限流防抖方案,實現簡單、代碼優雅、性能極高、擴展靈活。
?