基于SpringBoot+AOP實(shí)現(xiàn)接口限流
前面我們用 AOP 實(shí)現(xiàn)了操作日志和權(quán)限校驗(yàn),徹底擺脫了代碼冗余的困擾;今天繼續(xù) AOP 實(shí)例——SpringBoot + AOP 實(shí)現(xiàn)接口限流。
做后端開(kāi)發(fā)的同學(xué)都知道,接口限流是系統(tǒng)穩(wěn)定性的“第一道防線”:
比如登錄接口、短信驗(yàn)證碼接口、支付接口,很容易被惡意請(qǐng)求刷爆(比如頻繁調(diào)用發(fā)送短信、惡意登錄試錯(cuò)),導(dǎo)致系統(tǒng)響應(yīng)變慢、服務(wù)崩潰,甚至產(chǎn)生額外的費(fèi)用(短信費(fèi)、接口調(diào)用費(fèi))。
如果在每個(gè)接口中手動(dòng)寫(xiě)限流邏輯,不僅代碼冗余,還難以統(tǒng)一管理和擴(kuò)展。而用 AOP 實(shí)現(xiàn)接口限流,只需一行注解,就能靈活控制接口的請(qǐng)求頻率,不侵入業(yè)務(wù)代碼,兼顧優(yōu)雅和實(shí)用。
一、接口限流的核心場(chǎng)景
接口限流的核心是“限制單位時(shí)間內(nèi)的請(qǐng)求次數(shù)”,結(jié)合企業(yè)實(shí)戰(zhàn)場(chǎng)景,本次需求覆蓋以下核心點(diǎn),可直接適配大部分項(xiàng)目:
- 多限流策略:支持固定窗口限流(簡(jiǎn)單易實(shí)現(xiàn))和滑動(dòng)窗口限流(精準(zhǔn)度高,避免臨界問(wèn)題),可靈活選擇;
- 自定義限流key:支持按 IP 限流(限制單個(gè)IP的請(qǐng)求頻率)、按用戶ID限流(限制單個(gè)用戶的請(qǐng)求頻率),適配不同場(chǎng)景;
- 自定義限流參數(shù):可靈活配置“單位時(shí)間”和“最大請(qǐng)求次數(shù)”(如 1分鐘內(nèi)最多請(qǐng)求10次、10秒內(nèi)最多請(qǐng)求3次);
- 統(tǒng)一限流響應(yīng):觸發(fā)限流時(shí),返回統(tǒng)一的 JSON 格式,包含錯(cuò)誤碼、錯(cuò)誤信息,便于前端提示用戶“請(qǐng)求過(guò)于頻繁”;
- 不侵入業(yè)務(wù)代碼:通過(guò) AOP 增強(qiáng)實(shí)現(xiàn),業(yè)務(wù)接口無(wú)需修改,降低耦合度;
- 分布式適配:支持單機(jī)限流(本地緩存)和分布式限流(Redis),適配集群部署場(chǎng)景;
- 異常處理:限流邏輯異常時(shí),不影響接口正常訪問(wèn),保證系統(tǒng)穩(wěn)定性。
二、設(shè)計(jì)思路
在寫(xiě)代碼前,先搞懂兩個(gè)核心限流策略(新手也能輕松理解),以及整體設(shè)計(jì)思路,避免寫(xiě)代碼時(shí)邏輯混亂。
1. 兩種核心限流策略
(1)固定窗口限流
原理:將時(shí)間劃分為固定的窗口(如 1分鐘一個(gè)窗口),統(tǒng)計(jì)每個(gè)窗口內(nèi)的請(qǐng)求次數(shù),超過(guò)最大次數(shù)則觸發(fā)限流。
舉例:配置“1分鐘內(nèi)最多請(qǐng)求10次”,第一個(gè)窗口(0-60秒)請(qǐng)求10次后,后續(xù)請(qǐng)求被限流;60秒后進(jìn)入新窗口,請(qǐng)求次數(shù)重置,可再次請(qǐng)求。
優(yōu)點(diǎn):實(shí)現(xiàn)簡(jiǎn)單、性能高;缺點(diǎn):存在臨界問(wèn)題(比如59秒請(qǐng)求10次,61秒再請(qǐng)求10次,2秒內(nèi)請(qǐng)求20次,突破限流閾值)。
(2)滑動(dòng)窗口限流
原理:將固定窗口拆分為多個(gè)小窗口(如 1分鐘拆分為6個(gè)10秒小窗口),每次請(qǐng)求時(shí),只統(tǒng)計(jì)“當(dāng)前時(shí)間往前推1分鐘”內(nèi)的請(qǐng)求次數(shù),超過(guò)閾值則限流。
舉例:同樣配置“1分鐘內(nèi)最多請(qǐng)求10次”,59秒請(qǐng)求10次后,61秒請(qǐng)求時(shí),統(tǒng)計(jì)的是1-61秒內(nèi)的請(qǐng)求次數(shù)(仍為10次),會(huì)被限流,避免臨界問(wèn)題。
優(yōu)點(diǎn):限流精準(zhǔn),無(wú)臨界問(wèn)題;缺點(diǎn):實(shí)現(xiàn)稍復(fù)雜,性能略低于固定窗口。
2. 整體設(shè)計(jì)思路
- 自定義注解:創(chuàng)建
@RateLimit注解,用于標(biāo)記需要限流的接口,配置限流策略、限流key、時(shí)間窗口、最大請(qǐng)求次數(shù); - 限流工具類:分別實(shí)現(xiàn)固定窗口和滑動(dòng)窗口的限流邏輯,支持本地緩存(單機(jī))和 Redis(分布式)存儲(chǔ)請(qǐng)求次數(shù);
- AOP 切面:定義切點(diǎn)(攔截所有添加了
@RateLimit注解的方法),用環(huán)繞通知實(shí)現(xiàn)限流校驗(yàn)邏輯; - 限流key生成:根據(jù)注解配置,生成不同的限流key(IP/用戶ID),實(shí)現(xiàn)精準(zhǔn)限流;
- 統(tǒng)一異常與響應(yīng):觸發(fā)限流時(shí),拋出自定義限流異常,通過(guò)全局異常處理器返回統(tǒng)一 JSON 響應(yīng);
- 多場(chǎng)景測(cè)試:覆蓋單機(jī)/分布式、不同限流策略、不同限流key,驗(yàn)證限流效果。
三、完整代碼
本次實(shí)戰(zhàn)基于 SpringBoot 2.7.x 版本,整合 Redis(支持分布式限流),所有代碼添加詳細(xì)注釋,新手也能輕松理解每一步的作用,無(wú)需修改核心邏輯,直接適配項(xiàng)目。
步驟1:導(dǎo)入核心依賴
需要導(dǎo)入 AOP 依賴、Redis 依賴、工具包,pom.xml 如下:
<!-- Spring AOP 核心依賴(限流核心) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Redis 依賴(分布式限流必備,單機(jī)可省略) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 工具包(JSON 響應(yīng)、緩存操作,簡(jiǎn)化代碼) -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.32</version>
</dependency>
<!-- lombok 依賴(簡(jiǎn)化實(shí)體類、工具類代碼) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>步驟2:配置文件(application.yml)
配置 Redis、服務(wù)器端口,單機(jī)限流可省略 Redis 配置:
server:
port: 8080 # 服務(wù)器端口
# Redis 配置(分布式限流必備)
spring:
redis:
host: localhost # Redis 地址(本地測(cè)試用)
port: 6379 # Redis 端口
password: # Redis 密碼(無(wú)密碼則留空)
database: 0 # 數(shù)據(jù)庫(kù)索引
lettuce:
pool:
max-active: 100 # 最大連接數(shù)
max-idle: 10 # 最大空閑連接
min-idle: 5 # 最小空閑連接
# 限流全局配置(可選,可在注解中覆蓋)
rate-limit:
default-time: 60 # 默認(rèn)時(shí)間窗口(秒)
default-count: 10 # 默認(rèn)最大請(qǐng)求次數(shù)
default-type: FIXED_WINDOW # 默認(rèn)限流策略(FIXED_WINDOW:固定窗口,SLIDING_WINDOW:滑動(dòng)窗口)
default-key-type: IP # 默認(rèn)限流key類型(IP:按IP限流,USER_ID:按用戶ID限流)步驟3:自定義限流注解
創(chuàng)建 @RateLimit 注解,用于標(biāo)記需要限流的接口,可靈活配置限流參數(shù),貼合企業(yè)實(shí)戰(zhàn)需求:
import java.lang.annotation.*;
/**
* 自定義接口限流注解
* @Target(ElementType.METHOD):僅作用于方法(接口方法)
* @Retention(RetentionPolicy.RUNTIME):運(yùn)行時(shí)保留,AOP 切面可獲取注解屬性
* @Documented:生成 API 文檔時(shí),顯示該注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 限流策略(固定窗口/滑動(dòng)窗口)
* 默認(rèn)為全局配置的策略,可在接口上單獨(dú)配置覆蓋
*/
LimitType type() default LimitType.FIXED_WINDOW;
/**
* 限流key類型(按IP/按用戶ID)
* 默認(rèn)為全局配置的key類型,可單獨(dú)覆蓋
*/
KeyType keyType() default KeyType.IP;
/**
* 時(shí)間窗口(單位:秒)
* 默認(rèn)為全局配置的時(shí)間,可單獨(dú)覆蓋(如 60=1分鐘,10=10秒)
*/
int time() default 0;
/**
* 單位時(shí)間內(nèi)的最大請(qǐng)求次數(shù)(限流閾值)
* 默認(rèn)為全局配置的次數(shù),可單獨(dú)覆蓋
*/
int count() default 0;
/**
* 限流提示信息(觸發(fā)限流時(shí)返回)
*/
String message() default "請(qǐng)求過(guò)于頻繁,請(qǐng)稍后再試!";
/**
* 限流存儲(chǔ)方式(本地緩存/Redis)
* 默認(rèn)為 Redis,單機(jī)部署可改為 LOCAL
*/
StoreType storeType() default StoreType.REDIS;
/**
* 限流策略枚舉
*/
enum LimitType {
FIXED_WINDOW, // 固定窗口限流
SLIDING_WINDOW // 滑動(dòng)窗口限流
}
/**
* 限流key類型枚舉
*/
enum KeyType {
IP, // 按請(qǐng)求IP限流(最常用)
USER_ID // 按當(dāng)前登錄用戶ID限流(需結(jié)合用戶上下文)
}
/**
* 存儲(chǔ)方式枚舉
*/
enum StoreType {
LOCAL, // 本地緩存(單機(jī)部署用)
REDIS // Redis(分布式部署用)
}
}注解屬性說(shuō)明:
- type:選擇限流策略,固定窗口簡(jiǎn)單,滑動(dòng)窗口精準(zhǔn),可根據(jù)場(chǎng)景選擇;
- keyType:選擇限流粒度,IP 用于匿名接口(如登錄、短信),USER_ID 用于登錄后接口(如個(gè)人中心);
- time + count:共同定義限流規(guī)則,如 time=60、count=10 → 1分鐘內(nèi)最多請(qǐng)求10次;
- storeType:?jiǎn)螜C(jī)部署用 LOCAL(本地緩存),集群部署用 REDIS(分布式緩存),保證限流統(tǒng)一。
步驟4:核心工具類
這部分是限流的核心,分別實(shí)現(xiàn)固定窗口、滑動(dòng)窗口的限流邏輯,支持本地緩存和 Redis 存儲(chǔ),代碼可直接復(fù)用:
4.1 限流常量類(統(tǒng)一管理key前綴)
/**
* 限流常量類(統(tǒng)一管理 Redis/本地緩存的key前綴,避免混亂)
*/
public class RateLimitConstant {
// 限流key前綴(Redis中使用,如 rate_limit:ip:127.0.0.1:接口路徑)
public static final String RATE_LIMIT_KEY_PREFIX = "rate_limit:";
// 滑動(dòng)窗口小窗口大?。J(rèn)10秒,可根據(jù)需求調(diào)整)
public static final int SLIDING_WINDOW_INTERVAL = 10;
}4.2 限流工具類
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
/**
* 限流工具類(實(shí)現(xiàn)固定窗口、滑動(dòng)窗口限流,支持本地/Redis存儲(chǔ))
*/
@Slf4j
@Component
public class RateLimitUtil {
// 本地緩存(單機(jī)限流用,ConcurrentHashMap 線程安全)
private final ConcurrentHashMap<String, Integer> localCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Long> localWindowCache = new ConcurrentHashMap<>();
// Redis 模板(分布式限流用)
@Autowired(required = false) // 單機(jī)部署時(shí),Redis 可省略,避免報(bào)錯(cuò)
private StringRedisTemplate stringRedisTemplate;
/**
* 固定窗口限流(核心方法)
* @param key 限流key(如 ip:127.0.0.1:接口路徑)
* @param time 時(shí)間窗口(秒)
* @param count 最大請(qǐng)求次數(shù)
* @param storeType 存儲(chǔ)方式(本地/Redis)
* @return true=觸發(fā)限流,false=未觸發(fā)限流
*/
public boolean fixedWindowLimit(String key, int time, int count, RateLimit.StoreType storeType) {
if (storeType == RateLimit.StoreType.LOCAL) {
// 本地緩存實(shí)現(xiàn)固定窗口
return localFixedWindowLimit(key, time, count);
} else {
// Redis 實(shí)現(xiàn)固定窗口(分布式)
return redisFixedWindowLimit(key, time, count);
}
}
/**
* 滑動(dòng)窗口限流(核心方法)
* @param key 限流key(如 ip:127.0.0.1:接口路徑)
* @param time 時(shí)間窗口(秒)
* @param count 最大請(qǐng)求次數(shù)
* @param storeType 存儲(chǔ)方式(本地/Redis)
* @return true=觸發(fā)限流,false=未觸發(fā)限流
*/
public boolean slidingWindowLimit(String key, int time, int count, RateLimit.StoreType storeType) {
if (storeType == RateLimit.StoreType.LOCAL) {
// 本地緩存實(shí)現(xiàn)滑動(dòng)窗口
return localSlidingWindowLimit(key, time, count);
} else {
// Redis 實(shí)現(xiàn)滑動(dòng)窗口(分布式)
return redisSlidingWindowLimit(key, time, count);
}
}
/**
* 本地緩存 - 固定窗口限流
*/
private boolean localFixedWindowLimit(String key, int time, int count) {
// 1. 獲取當(dāng)前窗口的請(qǐng)求次數(shù)
Integer currentCount = localCache.getOrDefault(key, 0);
// 2. 檢查是否超過(guò)限流閾值
if (currentCount >= count) {
log.warn("本地固定窗口限流觸發(fā),key:{},當(dāng)前次數(shù):{},閾值:{}", key, currentCount, count);
return true;
}
// 3. 第一次請(qǐng)求,設(shè)置窗口過(guò)期時(shí)間(time秒后清空)
if (currentCount == 0) {
localWindowCache.put(key, System.currentTimeMillis() + time * 1000);
} else {
// 檢查窗口是否過(guò)期,過(guò)期則重置次數(shù)和窗口時(shí)間
Long expireTime = localWindowCache.get(key);
if (System.currentTimeMillis() > expireTime) {
localCache.put(key, 1);
localWindowCache.put(key, System.currentTimeMillis() + time * 1000);
return false;
}
}
// 4. 未超過(guò)閾值,請(qǐng)求次數(shù)+1
localCache.put(key, currentCount + 1);
return false;
}
/**
* Redis - 固定窗口限流(分布式,集群部署用)
*/
private boolean redisFixedWindowLimit(String key, int time, int count) {
// 1. 拼接 Redis key(加上前綴,避免與其他key沖突)
String redisKey = RateLimitConstant.RATE_LIMIT_KEY_PREFIX + key;
// 2. 自增請(qǐng)求次數(shù)(原子操作,避免并發(fā)問(wèn)題)
Long currentCount = stringRedisTemplate.opsForValue().increment(redisKey, 1);
// 3. 第一次請(qǐng)求,設(shè)置過(guò)期時(shí)間(time秒)
if (currentCount != null && currentCount == 1) {
stringRedisTemplate.expire(redisKey, time, TimeUnit.SECONDS);
}
// 4. 檢查是否超過(guò)限流閾值
if (currentCount != null && currentCount > count) {
log.warn("Redis固定窗口限流觸發(fā),key:{},當(dāng)前次數(shù):{},閾值:{}", redisKey, currentCount, count);
return true;
}
return false;
}
/**
* 本地緩存 - 滑動(dòng)窗口限流
*/
private boolean localSlidingWindowLimit(String key, int time, int count) {
long now = System.currentTimeMillis();
// 1. 計(jì)算當(dāng)前窗口的起始時(shí)間(當(dāng)前時(shí)間 - 時(shí)間窗口)
long windowStart = now - time * 1000;
// 2. 拼接滑動(dòng)窗口的key(包含主key和小窗口時(shí)間)
String windowKey = key + ":" + (now / (RateLimitConstant.SLIDING_WINDOW_INTERVAL * 1000));
// 3. 獲取當(dāng)前小窗口的請(qǐng)求次數(shù)
Integer currentWindowCount = localCache.getOrDefault(windowKey, 0);
// 4. 遍歷所有小窗口,統(tǒng)計(jì)整個(gè)滑動(dòng)窗口內(nèi)的總請(qǐng)求次數(shù)
int totalCount = 0;
for (String cacheKey : localCache.keySet()) {
if (cacheKey.startsWith(key + ":")) {
// 解析小窗口時(shí)間
long windowTime = Long.parseLong(cacheKey.split(":")[2]);
long windowTimeMillis = windowTime * RateLimitConstant.SLIDING_WINDOW_INTERVAL * 1000;
// 只統(tǒng)計(jì)當(dāng)前滑動(dòng)窗口內(nèi)的小窗口
if (windowTimeMillis >= windowStart) {
totalCount += localCache.get(cacheKey);
} else {
// 移除過(guò)期的小窗口緩存
localCache.remove(cacheKey);
}
}
}
// 5. 檢查是否超過(guò)限流閾值
if (totalCount >= count) {
log.warn("本地滑動(dòng)窗口限流觸發(fā),key:{},當(dāng)前總次數(shù):{},閾值:{}", key, totalCount, count);
return true;
}
// 6. 未超過(guò)閾值,當(dāng)前小窗口請(qǐng)求次數(shù)+1
localCache.put(windowKey, currentWindowCount + 1);
return false;
}
/**
* Redis - 滑動(dòng)窗口限流(分布式,集群部署用)
*/
private boolean redisSlidingWindowLimit(String key, int time, int count) {
long now = System.currentTimeMillis();
// 1. 計(jì)算當(dāng)前窗口的起始時(shí)間(當(dāng)前時(shí)間 - 時(shí)間窗口)
long windowStart = now - time * 1000;
// 2. 拼接 Redis key(加上前綴)
String redisKey = RateLimitConstant.RATE_LIMIT_KEY_PREFIX + key;
// 3. 小窗口大?。J(rèn)10秒,可調(diào)整)
int interval = RateLimitConstant.SLIDING_WINDOW_INTERVAL;
// 4. 當(dāng)前小窗口的時(shí)間戳(按小窗口大小取整)
long currentWindow = now / (interval * 1000);
// 5. Redis 原子操作:刪除過(guò)期小窗口 + 統(tǒng)計(jì)當(dāng)前窗口總次數(shù) + 自增當(dāng)前小窗口次數(shù)
// 用 Lua 腳本實(shí)現(xiàn)原子操作,避免并發(fā)問(wèn)題
String luaScript = "local key = KEYS[1]\n" +
"local windowStart = ARGV[1]\n" +
"local currentWindow = ARGV[2]\n" +
"local interval = ARGV[3]\n" +
"local count = ARGV[4]\n" +
"-- 刪除過(guò)期的小窗口(小于windowStart的小窗口)\n" +
"redis.call('ZREMRANGEBYSCORE', key, 0, windowStart)\n" +
"-- 統(tǒng)計(jì)當(dāng)前窗口內(nèi)的總請(qǐng)求次數(shù)\n" +
"local total = redis.call('ZCARD', key)\n" +
"if total >= tonumber(count) then\n" +
" return 1\n" +
"end\n" +
"-- 自增當(dāng)前小窗口的請(qǐng)求次數(shù)(將小窗口時(shí)間戳作為score,請(qǐng)求ID作為value)\n" +
"redis.call('ZADD', key, currentWindow, currentWindow .. ':' .. redis.call('INCR', key .. ':seq'))\n" +
"-- 設(shè)置過(guò)期時(shí)間(確保緩存自動(dòng)清理)\n" +
"redis.call('EXPIRE', key, tonumber(interval) + 1)\n" +
"return 0";
// 執(zhí)行 Lua 腳本
Long result = stringRedisTemplate.execute(
new org.springframework.data.redis.core.script.DefaultRedisScript<>(luaScript, Long.class),
Arrays.asList(redisKey),
String.valueOf(windowStart),
String.valueOf(currentWindow),
String.valueOf(interval),
String.valueOf(count)
);
// 6. 結(jié)果判斷:1=觸發(fā)限流,0=未觸發(fā)
if (result != null && result == 1) {
log.warn("Redis滑動(dòng)窗口限流觸發(fā),key:{},閾值:{}", redisKey, count);
return true;
}
return false;
}
/**
* 清除指定key的限流緩存(用于特殊場(chǎng)景,如用戶注銷、IP解封)
*/
public void clearLimitCache(String key, RateLimit.StoreType storeType) {
if (storeType == RateLimit.StoreType.LOCAL) {
// 清除本地緩存(包含所有小窗口)
localCache.keySet().removeIf(k -> k.startsWith(key) || k.equals(key));
localWindowCache.remove(key);
} else {
// 清除Redis緩存
String redisKey = RateLimitConstant.RATE_LIMIT_KEY_PREFIX + key;
stringRedisTemplate.delete(redisKey);
stringRedisTemplate.delete(redisKey + ":seq");
}
}
}步驟5:輔助工具類(獲取IP、用戶上下文)
實(shí)現(xiàn)獲取客戶端IP、當(dāng)前登錄用戶ID的工具類,用于生成限流key,貼合實(shí)戰(zhàn)場(chǎng)景:
5.1 IP工具類(獲取客戶端真實(shí)IP,處理代理場(chǎng)景)
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* IP工具類(獲取客戶端真實(shí)IP,處理Nginx代理等場(chǎng)景)
*/
public class IpUtil {
/**
* 獲取客戶端真實(shí)IP
*/
public static String getClientIp() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attributes == null) {
return "127.0.0.1"; // 非web環(huán)境,默認(rèn)本地IP
}
HttpServletRequest request = attributes.getRequest();
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 處理多代理場(chǎng)景,取第一個(gè)非unknown的IP
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
// 本地環(huán)境默認(rèn)IP(避免localhost解析問(wèn)題)
return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip;
}
}5.2 用戶上下文
實(shí)際項(xiàng)目中,從JWT Token或Spring Security中獲取用戶ID,這里模擬實(shí)現(xiàn),可直接替換為項(xiàng)目中的真實(shí)邏輯:
/**
* 用戶上下文(獲取當(dāng)前登錄用戶信息,用于按用戶ID限流)
*/
public class UserContext {
/**
* 獲取當(dāng)前登錄用戶ID(模擬,實(shí)際從JWT/Token中解析)
* @return 用戶ID(未登錄返回null)
*/
public static Long getCurrentUserId() {
// 模擬:登錄用戶ID為1001,未登錄返回null
// 實(shí)際項(xiàng)目替換為:JwtUtils.parseToken(token).getUserId()
return 1001L;
}
}步驟6:AOP 限流切面
創(chuàng)建切面類,攔截所有添加了 @RateLimit 注解的接口,實(shí)現(xiàn)限流校驗(yàn)邏輯,優(yōu)先于日志切面執(zhí)行:
import com.alibaba.fastjson2.JSONObject;
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.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.lang.reflect.Method;
/**
* 接口限流切面(核心類)
* @Aspect:標(biāo)記此類為AOP切面
* @Component:交給Spring管理,確保Spring能掃描到
* @Order(1):優(yōu)先級(jí)高于日志切面(避免限流請(qǐng)求被記錄日志)
* @Slf4j:日志輸出
*/
@Aspect
@Component
@Order(1)
@Slf4j
public class RateLimitAspect {
// 注入限流工具類
@Resource
private RateLimitUtil rateLimitUtil;
// 全局默認(rèn)配置(從配置文件讀?。?
@Value("${rate-limit.default-time}")
private int defaultTime;
@Value("${rate-limit.default-count}")
private int defaultCount;
@Value("${rate-limit.default-type}")
private RateLimit.LimitType defaultLimitType;
@Value("${rate-limit.default-key-type}")
private RateLimit.KeyType defaultKeyType;
// 1. 定義切點(diǎn):攔截所有添加了 @RateLimit 注解的方法
@Pointcut("@annotation(com.example.demo.annotation.RateLimit)")
public void rateLimitPointcut() {}
// 2. 環(huán)繞通知:包裹目標(biāo)方法,執(zhí)行限流校驗(yàn)
@Around("rateLimitPointcut()")
public Object doRateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
// 第一步:獲取目標(biāo)方法上的 @RateLimit 注解
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method targetMethod = signature.getMethod();
RateLimit rateLimitAnno = targetMethod.getAnnotation(RateLimit.class);
// 第二步:獲取注解配置的限流參數(shù)(無(wú)配置則用全局默認(rèn)值)
RateLimit.LimitType limitType = rateLimitAnno.type() == RateLimit.LimitType.FIXED_WINDOW ?
rateLimitAnno.type() : defaultLimitType;
RateLimit.KeyType keyType = rateLimitAnno.keyType() == RateLimit.KeyType.IP ?
rateLimitAnno.keyType() : defaultKeyType;
int time = rateLimitAnno.time() == 0 ? defaultTime : rateLimitAnno.time();
int count = rateLimitAnno.count() == 0 ? defaultCount : rateLimitAnno.count();
String message = rateLimitAnno.message();
RateLimit.StoreType storeType = rateLimitAnno.storeType();
// 第三步:生成限流key(根據(jù)keyType生成,確保唯一)
String limitKey = generateLimitKey(joinPoint, keyType);
log.info("接口限流校驗(yàn),key:{},策略:{},時(shí)間窗口:{}秒,閾值:{}次",
limitKey, limitType, time, count);
// 第四步:執(zhí)行限流校驗(yàn)(根據(jù)限流策略選擇對(duì)應(yīng)的方法)
boolean isLimit = false;
if (limitType == RateLimit.LimitType.FIXED_WINDOW) {
isLimit = rateLimitUtil.fixedWindowLimit(limitKey, time, count, storeType);
} else if (limitType == RateLimit.LimitType.SLIDING_WINDOW) {
isLimit = rateLimitUtil.slidingWindowLimit(limitKey, time, count, storeType);
}
// 第五步:判斷是否觸發(fā)限流,觸發(fā)則拋出異常
if (isLimit) {
throw new RateLimitException(429, message);
}
// 第六步:限流校驗(yàn)通過(guò),執(zhí)行目標(biāo)方法(核心業(yè)務(wù)邏輯)
return joinPoint.proceed();
}
/**
* 生成限流key(確保唯一,避免不同接口/不同IP/不同用戶的限流沖突)
* @param joinPoint 切入點(diǎn)(獲取接口路徑)
* @param keyType 限流key類型(IP/USER_ID)
* @return 唯一限流key
*/
private String generateLimitKey(ProceedingJoinPoint joinPoint, RateLimit.KeyType keyType) {
// 獲取接口路徑(如 /api/auth/login)
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
String methodName = signature.getDeclaringTypeName() + "." + signature.getMethod().getName();
// 根據(jù)keyType生成不同的限流key
if (keyType == RateLimit.KeyType.IP) {
// 按IP限流:ip:接口路徑(如 ip:127.0.0.1:com.example.demo.controller.AuthController.login)
String ip = IpUtil.getClientIp();
return "ip:" + ip + ":" + methodName;
} else if (keyType == RateLimit.KeyType.USER_ID) {
// 按用戶ID限流:user:用戶ID:接口路徑(如 user:1001:com.example.demo.controller.UserController.edit)
Long userId = UserContext.getCurrentUserId();
if (userId == null) {
// 未登錄用戶,按IP限流(避免key為空)
String ip = IpUtil.getClientIp();
return "ip:" + ip + ":" + methodName;
}
return "user:" + userId + ":" + methodName;
}
// 默認(rèn)按IP限流
String ip = IpUtil.getClientIp();
return "ip:" + ip + ":" + methodName;
}
}步驟7:自定義限流異常 + 全局異常處理器
觸發(fā)限流時(shí),拋出自定義異常,通過(guò)全局異常處理器返回統(tǒng)一的 JSON 響應(yīng),便于前端統(tǒng)一處理:
7.1 自定義限流異常
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 自定義限流異常(觸發(fā)限流時(shí)拋出)
* 429 狀態(tài)碼:Too Many Requests(請(qǐng)求過(guò)于頻繁)
*/
@Data
@EqualsAndHashCode(callSuper = true)
public class RateLimitException extends RuntimeException {
// 錯(cuò)誤碼(429 標(biāo)準(zhǔn)限流狀態(tài)碼)
private Integer code;
// 錯(cuò)誤信息(自定義提示)
private String message;
// 構(gòu)造方法(簡(jiǎn)化異常拋出)
public RateLimitException(Integer code, String message) {
super(message);
this.code = code;
this.message = message;
}
}7.2 全局異常處理器
import com.alibaba.fastjson2.JSONObject;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 全局異常處理器(統(tǒng)一響應(yīng)格式)
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
// 攔截限流異常(429 狀態(tài)碼)
@ExceptionHandler(RateLimitException.class)
public JSONObject handleRateLimitException(RateLimitException e) {
JSONObject response = new JSONObject();
response.put("code", e.getCode());
response.put("msg", e.getMessage());
response.put("data", null);
return response;
}
// 攔截其他異常(兜底處理)
@ExceptionHandler(Exception.class)
public JSONObject handleException(Exception e) {
JSONObject response = new JSONObject();
response.put("code", 500);
response.put("msg", "服務(wù)器內(nèi)部異常,請(qǐng)聯(lián)系管理員");
response.put("data", null);
return response;
}
}步驟8:接口使用示例
在需要限流的接口上添加 @RateLimit 注解,根據(jù)業(yè)務(wù)需求配置參數(shù),無(wú)需修改接口內(nèi)部業(yè)務(wù)代碼:
import com.example.demo.annotation.RateLimit;
import com.example.demo.util.UserContext;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 測(cè)試接口(覆蓋限流多場(chǎng)景)
*/
@RestController
@RequestMapping("/api")
public class TestController {
/**
* 場(chǎng)景1:短信驗(yàn)證碼接口(按IP限流,固定窗口,1分鐘最多3次)
* 高頻場(chǎng)景,防止惡意刷短信
*/
@RateLimit(
type = RateLimit.LimitType.FIXED_WINDOW,
keyType = RateLimit.KeyType.IP,
time = 60, // 1分鐘
count = 3, // 最多3次
message = "短信發(fā)送過(guò)于頻繁,請(qǐng)1分鐘后再試!"
)
@PostMapping("/sms/send")
public String sendSms(String phone) {
// 核心業(yè)務(wù)邏輯:發(fā)送短信驗(yàn)證碼
return "短信已發(fā)送至:" + phone;
}
/**
* 場(chǎng)景2:登錄接口(按IP限流,滑動(dòng)窗口,10秒最多2次)
* 防止惡意暴力破解密碼,滑動(dòng)窗口避免臨界問(wèn)題
*/
@RateLimit(
type = RateLimit.LimitType.SLIDING_WINDOW,
keyType = RateLimit.KeyType.IP,
time = 10, // 10秒
count = 2, // 最多2次
message = "登錄請(qǐng)求過(guò)于頻繁,請(qǐng)10秒后再試!"
)
@PostMapping("/auth/login")
public String login(String username, String password) {
// 核心業(yè)務(wù)邏輯:用戶登錄
return "登錄成功,歡迎您:" + username;
}
/**
* 場(chǎng)景3:個(gè)人中心接口(按用戶ID限流,固定窗口,1分鐘最多10次)
* 登錄后接口,按用戶ID限流,避免單個(gè)用戶惡意請(qǐng)求
*/
@RateLimit(
type = RateLimit.LimitType.FIXED_WINDOW,
keyType = RateLimit.KeyType.USER_ID,
time = 60,
count = 10,
message = "操作過(guò)于頻繁,請(qǐng)1分鐘后再試!"
)
@GetMapping("/user/profile")
public String userProfile() {
Long userId = UserContext.getCurrentUserId();
// 核心業(yè)務(wù)邏輯:查詢用戶個(gè)人信息
return "用戶ID:" + userId + ",個(gè)人信息查詢成功";
}
/**
* 場(chǎng)景4:分布式限流(Redis存儲(chǔ),滑動(dòng)窗口,5秒最多5次)
* 集群部署場(chǎng)景,確保多節(jié)點(diǎn)限流統(tǒng)一
*/
@RateLimit(
type = RateLimit.LimitType.SLIDING_WINDOW,
keyType = RateLimit.KeyType.IP,
time = 5,
count = 5,
storeType = RateLimit.StoreType.REDIS,
message = "請(qǐng)求過(guò)于頻繁,請(qǐng)5秒后再試!"
)
@GetMapping("/test/distributed")
public String distributedLimit() {
// 核心業(yè)務(wù)邏輯:分布式場(chǎng)景測(cè)試
return "分布式限流測(cè)試成功";
}
}四、測(cè)試驗(yàn)證
用 Postman 測(cè)試以下核心場(chǎng)景,驗(yàn)證限流效果,確保符合預(yù)期:
測(cè)試場(chǎng)景1:短信接口限流(固定窗口,IP限流)
請(qǐng)求地址:http://localhost:8080/api/sms/send?phone=13800138000
請(qǐng)求方式:POST
測(cè)試操作:1分鐘內(nèi)連續(xù)請(qǐng)求4次
測(cè)試結(jié)果:前3次正常返回“短信已發(fā)送”,第4次返回限流響應(yīng)(code=429,msg=短信發(fā)送過(guò)于頻繁),符合預(yù)期。
測(cè)試場(chǎng)景2:登錄接口限流(滑動(dòng)窗口,IP限流)
請(qǐng)求地址:http://localhost:8080/api/auth/login?username=test&password=123456
請(qǐng)求方式:POST
測(cè)試操作:第1次請(qǐng)求(0秒)、第2次請(qǐng)求(5秒)、第3次請(qǐng)求(8秒)
測(cè)試結(jié)果:前2次正常返回,第3次觸發(fā)限流(10秒內(nèi)超過(guò)2次),符合預(yù)期,無(wú)臨界問(wèn)題。
測(cè)試場(chǎng)景3:個(gè)人中心接口(用戶ID限流)
請(qǐng)求地址:http://localhost:8080/api/user/profile
請(qǐng)求方式:GET
測(cè)試操作:1分鐘內(nèi)連續(xù)請(qǐng)求11次
測(cè)試結(jié)果:前10次正常返回,第11次觸發(fā)限流,符合預(yù)期。
測(cè)試場(chǎng)景4:分布式限流(Redis存儲(chǔ))
啟動(dòng)2個(gè)項(xiàng)目節(jié)點(diǎn)(端口8080、8081),用同一IP分別向兩個(gè)節(jié)點(diǎn)請(qǐng)求5次(共10次),時(shí)間窗口5秒
測(cè)試結(jié)果:兩個(gè)節(jié)點(diǎn)合計(jì)請(qǐng)求超過(guò)5次后,觸發(fā)限流,說(shuō)明Redis分布式限流生效,多節(jié)點(diǎn)限流統(tǒng)一。
文末小結(jié)
SpringBoot + AOP 實(shí)現(xiàn)接口限流,是企業(yè)項(xiàng)目中保障系統(tǒng)穩(wěn)定性的必備方案,核心邏輯就是「注解標(biāo)記 + AOP 攔截 + 限流校驗(yàn)」,不侵入業(yè)務(wù)代碼,靈活適配單機(jī)、分布式等多種場(chǎng)景。
以上就是基于SpringBoot+AOP實(shí)現(xiàn)接口限流的詳細(xì)內(nèi)容,更多關(guān)于SpringBoot AOP接口限流的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Spring解決循環(huán)依賴問(wèn)題的四種方法匯總
這篇文章主要介紹了Spring解決循環(huán)依賴問(wèn)題的四種方法匯總,本文給大家介紹的非常詳細(xì),感興趣的朋友跟隨小編一起看看吧2024-07-07
java實(shí)現(xiàn)網(wǎng)頁(yè)爬蟲(chóng)的示例講解
下面小編就為大家?guī)?lái)一篇java實(shí)現(xiàn)網(wǎng)頁(yè)爬蟲(chóng)的示例講解。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-08-08
Java中的== 和equals()方法詳解與實(shí)例
本篇文章介紹了,在java中"==" 與equals方法的使用及其實(shí)例,需要的朋友可以參考下2017-04-04
使用spring?security?BCryptPasswordEncoder接入系統(tǒng)
這篇文章主要介紹了使用spring?security?BCryptPasswordEncoder接入系統(tǒng)方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助,如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-08-08
SpringBoot?自定義注解實(shí)現(xiàn)涉密字段脫敏
關(guān)于數(shù)據(jù)脫敏,網(wǎng)上的文章都是硬編碼規(guī)則,比如對(duì)身份證,手機(jī)號(hào),郵件地址等固定寫(xiě)法脫敏。本文在此基礎(chǔ)上,拓展動(dòng)態(tài)從數(shù)據(jù)庫(kù)查出涉密關(guān)鍵字執(zhí)行脫敏操作。感興趣的同學(xué)可以參考閱讀2023-03-03
解決response.setHeader設(shè)置下載文件名無(wú)效的問(wèn)題
這篇文章主要介紹了解決response.setHeader設(shè)置下載文件名無(wú)效的問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2022-01-01
解決java.lang.ClassCastException的java類型轉(zhuǎn)換異常的問(wèn)題
這篇文章主要介紹了解決java.lang.ClassCastException的java類型轉(zhuǎn)換異常的問(wèn)題,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2020-09-09

