需求分析
请设计一套 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限制接口访问频率 - 这些错误千万不能犯
标签:窗口,请求,次数,限流,注解,设计,redisTemplate,SDK From: https://www.cnblogs.com/ba11ooner/p/18081003