需求分析
請設計一套 SDK,用於實現介面限流,針對某個 IP 對於特定介面方法的單位時間訪問次數進行控制。
- 限流演算法:滑動視窗
- 可配置項
- 時間視窗
- 限流次數
實現思路
演算法知識補充
透過滑動視窗實現限流
思想源於計數器(單位時間內數量超過閾值時拒絕請求),但是引入了滑動視窗,相較於固定視窗,更新過程更為平滑,不會出現臨界問題(即在更新時刻前後快速湧入流量,不能防止短期流量劇增,卻又導致長期流量受控)。
具體實現
SDK 結構
- annotation:自定義註解
- RequestLimit:標識限流介面,支援屬性配置(時間視窗、限流次數)
- aop
- RequestLimitAspect:限流實現切面,統一實現限流處理
- common:自定義的 ResponseEntity 的等效實現
- BaseResponse:自定義統一響應物件
- ErrorCode:自定義錯誤碼
- ResultUtils:自定義返回工具
- config
- RedisConfig:配置 Redis
- exception:統一異常處理
- BusinessException:業務異常,與系統異常做區分
- GlobalExceptionHandler:全域性異常處理器
不加粗部分為基礎設施,詳情參見 專案學習 魚皮使用者中心
本文詳細介紹加粗部分的實現。
Maven 依賴
<!--提供 AOP 支援-->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.4</version>
</dependency>
<!--提供日誌支援-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<!--提供 @Data-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!--提供 Redis 支援-->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.6.1</version>
</dependency>
程式碼實現
自定義註解
-
宣告標識的位置 → 修飾方法
-
宣告註解存在的時間 → 執行時仍保留註解
-
宣告註解屬性
- 視窗大小(時間長度)
- 訪問上限
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequestLimit {
// 限制時間 單位:秒(預設值:一分鐘)
long period() default 60;
// 允許請求的次數(預設值:5次)
long count() default 5;
}
切面邏輯
巧用 Redis 的過期策略,可以方便地實現滑動視窗的效果。
每次訪問介面後,介面方法執行前:
-
記錄請求:使用 Redis 中的 ZSet (有序集合)進行記錄。
-
獲取申請請求的 IP 和 URI。
-
獲取當前時間戳。
-
利用 Zet 的新增功能,記錄請求。
- 設定 Key:將字串
req_limit_
與IP
與URI
的拼接作為 Key。 - 設定 Value:將時間戳作為 Value。
- 設定 Score:將時間戳作為 Score。
- 設定 Key:將字串
-
設定過期時間:安全機制,避免長間隔請求持續佔用記憶體。
因為視窗控制僅在請求呼叫時進行,如果長期不呼叫介面,又不設定過期時間,會導致不必要的記憶體消耗。
-
-
控制視窗:刪除滑動視窗以外的值。
- 從註解中獲取視窗大小(即時間段長度)
- 利用 ZSet 的刪除功能,刪除滑動視窗以外的值。
-
判斷當前訪問次數是否已經大於限制次數。
- 利用 ZSet 的統計功能統計 Key 出現次數,即視窗內 IP 訪問 URI 的次數。
- 從註解中獲取訪問次數上限。
- 比較訪問次數和次數上限,若訪問次數超過次數上限,則丟擲異常。
@Aspect
@Component
public class RequestLimitAspect {
Logger logger = LoggerFactory.getLogger(this.getClass());
@Resource
RedisTemplate<String, Long> redisTemplate;
// 定義切點
@Pointcut("@annotation(requestLimit)")
public void controllerAspect(RequestLimit requestLimit) {
}
// 織入邏輯
// 在指定切點周圍新增業務邏輯
@Around("controllerAspect(requestLimit)")
public Object doAround(ProceedingJoinPoint joinPoint, RequestLimit requestLimit) throws Throwable {
// 獲取註解中記錄的屬性
long period = requestLimit.period(); // 視窗大小
long limitCount = requestLimit.count(); // 限制次數
// 引入 ZSet
ZSetOperations<String, Long> zSetOperations = redisTemplate.opsForZSet();
// region 記錄請求
// 獲取請求:根據引數型別獲取 HttpServletRequest
Object[] args = joinPoint.getArgs();
HttpServletRequest httpServletRequest = null;
for (Object arg : args) {
if (arg instanceof HttpServletRequest) {
httpServletRequest = (HttpServletRequest) arg;
break;
}
}
// 從 HttpServletRequest 中獲取 IP 和 URI
// 例:訪問 https://www.example.com/products?id=123
// 假設 www.example.com 對應的 IP 地址為:192.168.1.1
// getRemoteAddr() → 192.168.1.1
// getRequestURI → /products?id=123
String ip = "";
String uri = "";
if (httpServletRequest != null) {
ip = httpServletRequest.getRemoteAddr();
uri = httpServletRequest.getRequestURI();
System.out.println(ip);
System.out.println(uri);
} else {
// 沒有找到HttpServletRequest引數
throw new BusinessException(PARAMS_ERROR, "沒有找到HttpServletRequest引數");
}
// 利用 URI 和 IP 拼接 Key
String key = "req_limit_".concat(uri).concat(ip);
// 獲取當前時間戳,作為 Value 和 Score
long currentMs = System.currentTimeMillis();
// add 引數說明:
// key:鍵
// value:值
// score :排序權重
zSetOperations.add(key, currentMs, currentMs);
// 設定過期時間:安全機制,避免長間隔請求持續佔用記憶體。
// 即確保記憶體中的滑動視窗資料不會一直累積,避免記憶體佔用過多。
// 因為視窗控制僅在請求呼叫時進行,如果長期不呼叫介面,又不設定過期時間,會導致不必要的記憶體消耗。
redisTemplate.expire(key, period, TimeUnit.SECONDS);
//endregion
// region 控制視窗
// 刪除滑動視窗以外的值,根據當前時間和註解中設定的 period 確定視窗大小
// removeRangeByScore 引數說明:
// key:表示有序集合的鍵名。
// minScore:表示刪除範圍的最小分數。
// maxScore:表示刪除範圍的最大分數。
zSetOperations.removeRangeByScore(key, 0, currentMs - period * 1000);
//endregion
// region 判斷當前訪問次數是否已經大於限制次數
// 統計當前訪問次數
// zCard 功能說明:獲取有序集合中成員的數量。
// zCard 引數說明:key,表示有序集合的鍵名。
Long count = zSetOperations.zCard(key);
if (count > limitCount) {
logger.error("介面攔截:{} 請求超過限制頻率【{}次/{}s】,IP為{}", uri, limitCount, period, ip);
throw new BusinessException(FORBIDDEN_ERROR, "請求超過限制頻率");
}
//endregion
// 執行使用者請求
return joinPoint.proceed();
}
}
Redis 配置
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(@Qualifier("jedisConnectionFactory") JedisConnectionFactory jedisConnectionFactory) {
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(jedisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new JdkSerializationRedisSerializer());
return redisTemplate;
}
@Bean
@Primary
public JedisConnectionFactory jedisConnectionFactory() {
JedisConnectionFactory jedisConnectionFactory = new JedisConnectionFactory();
jedisConnectionFactory.setHostName("localhost");
jedisConnectionFactory.setPort(6379);
return jedisConnectionFactory;
}
}
壓力測試
Postman vs JMeter
Postman 的 runner 本質上是序列執行多次請求
Jmeter 則是並行執行多個請求
專案原始碼
基於滑動視窗演算法的限流注解實現
參考文件
SpringBoot限制介面訪問頻率 - 這些錯誤千萬不能犯
JMeter 使用教程
《最佳化介面設計的思路》系列:第七篇—介面限流策略