背景
在对外开放我们的API的时候,有时候API调用不一定是来自于我们的APP或者网站。若是一个资源网站,极有可能会遇到爬虫来盗刷我们的数据。导致短时间内API的调用猛增,耗费服务器资源。因此需要在某些需要查数据库、文件等的API处,增加API调用频率限制。
我的思路
某API 1分钟内调用次数限制思路 ---- 利用redis进行限流。
若是负载均衡的api,则在进行判断的时候,要用redis提供的分布式锁setnx进行互斥执行以下步骤
假设API的地址为: /api/resource/list
redis中的数据结构
key | value | 过期时间 | 备注 |
---|---|---|---|
10.200.120.3:/api/resource/list | 2021062315:40:23#15 | 3min | -----正常记录1分钟内api调用次数及上次调用的时间 |
10.200.120.3:/api/resource/list | locked | 3min | api访问频率达到限制时,限制IP对api的访问,限制时间为3min |
key: 由IP+API地址构成,表明哪个IP正在访问哪个api
value: 由上次访问时间+累计访问次数构成
操作流程说明
- IP为10.200.120.3的客户端每次访问/api/resource/list时,首先从redis中取出key= 10.200.120.3:/api/resource/list 的值value
- 判断value是否等于locked,如果value=locked,跳转到第3步,如果value为空,则跳转到第4步,否则跳转到第5步
- 返回错误代码,提示1分钟内访问api次数超过60次,请稍后再试
- 在redis中新增key=10.200.120.3:/api/resource/list value= 2021062315:40:23#1,设置过期时间为3min,并调用api返回结果到调用者
- 判断当前api调用时间与value中的时间差值diff是否小于1分钟,若大于等于1分钟,则跳转到第6步,否则跳转到第7步
- 更新redis中,key=10.200.120.3:/api/resource/list value= 2021062315:42:23#1,设置超时时间为3min,并调用API返回结果到调用者
- 若调用时间差小于1分钟,则判断value中的调用次数是否小于60次,若大于等于60次,则跳转到第8步,否则跳转到第9步
- 更新redis中,key=10.200.120.3:/api/resource/list value= locked,并且更新过期时间为3min返回错误代码,提示1分钟内访问api次数超过60次,请稍后再试
- 若1分钟内调用次数少于60次,则更新redis中,key=10.200.120.3:/api/resource/list value= 2021062315:42:58#13,设置过期时间为3min,并调用api返回结果到调用者
redis 设置某个key的超时时间后,若后续key的值发生改变,重新set key value时,会删除之前的key value 以及连带的超时时间。所以,如果更新key的值后,还需要有超时时间,最好重新设置一次。
Java代码实现思路
- 自定义一个annotation。
属性有count:调用次数 durationTime: 时间间隔,比如1分钟内,lockTime: API调用达到限制时,限制多少时长后可以访问
其中关于time的,单位可自行定义为秒,或分钟 - 在需要做调用限制的方法上面,添加你自定义的注解。并注入对应的属性值
- 编写拦截器,拦截 ”/“ 开头的或者你自定义的路由前缀,例如拦截: /rest/rsource/**等。
- 进入拦截方法后,判断当前拦截的方法上面是否有自定义的注解,若没有,则步进行后续处理
- 若方法定义了注解,则取出注解中定义的各个属性,count、durationTime、lockTime,按照上面的限制思路进行判断,即可。
目前实现的效果
自定义注解
/**
* @author CoreCmd
* @date 2021/7/5
* @apiNote 在durationTime时间内,对某api的访问次数达到maxCount时,将被禁止访问lockTime时间,时间单位为:timeUnit
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
@Inherited
public @interface AccessLimitAnnotation {
//请求次数限制数量
int maxCount() default 60;
//请求间隔时间
int durationTime() default 1;
//锁定访问时间
int lockTime() default 3;
TimeUnit timeUnit() default TimeUnit.MINUTES;
}
使用方式:在需要访问限制的接口上,加上注解: @AccessLimitAnnotation
@ApiOperation(value = "获取定时任务类列表")
@GetMapping(value = "/list/job-beans")
@ApiImplicitParams({
@ApiImplicitParam(paramType = "header",name = "authToken", value = "登录token", required = true, dataType = "String")
})
@AccessLimitAnnotation
public CustomResponse listJobBeans(@RequestHeader(value = "authToken")String authToken){
CustomResponse customResponse = null;
try {
customResponse = CustomResponse.ok();
customResponse.put("jobBeans",myApplicationListener.getJobDefinitions());
} catch (Exception e){
log.info("获取定时任务类列表异常",e);
customResponse = CustomResponse.error("获取定时任务类列表异常,请联系管理员");
}
return customResponse;
}
自定义拦截器进行api拦截
在实际使用中,key的组成,我换成了token+api的方式,针对某个用户进行限制,不针对IP限制,后续有需要再自行改动。
//校验权限
if(handler instanceof HandlerMethod) {
AccessLimitAnnotation accessLimitAnnotation = ((HandlerMethod) handler).getMethodAnnotation(AccessLimitAnnotation.class);
if (null != accessLimitAnnotation){
int maxCount = accessLimitAnnotation.maxCount();
int durationTime = accessLimitAnnotation.durationTime();
int lockTime = accessLimitAnnotation.lockTime();
TimeUnit timeUnit = accessLimitAnnotation.timeUnit();
String reqPath = request.getRequestURI();
String accessLimitCheckKey = authToken + ":"+ URLEncoder.encode(reqPath,"utf-8");
String accessInfo = redisTemplate.opsForValue().get(accessLimitCheckKey);
if (null != accessInfo){
if ("locked".equalsIgnoreCase(accessInfo)){
isAccessAllowed = false;
} else {
String []accessTimeAndCount = accessInfo.split("#");
Date lastAccessTime = sdf.parse(accessTimeAndCount[0]);
Date nowDate = new Date();
int accessCount = Integer.parseInt(accessTimeAndCount[1]);
long diff = nowDate.getTime() - lastAccessTime.getTime();
long nd = 1000 * 24 * 60 * 60;
long nh = 1000 * 60 * 60;
long nm = 1000 * 60;
long min = diff % nd % nh / nm;
if (min >= durationTime){
redisTemplate.opsForValue().set(accessLimitCheckKey,sdf.format(nowDate)+"#"+1,lockTime,timeUnit);
} else if (accessCount >= maxCount){
redisTemplate.opsForValue().set(accessLimitCheckKey,"locked",lockTime,timeUnit);
isAccessAllowed = false;
} else {
redisTemplate.opsForValue().set(accessLimitCheckKey,sdf.format(nowDate) + "#"+(accessCount + 1),lockTime,timeUnit);
}
}
} else {
redisTemplate.opsForValue().set(accessLimitCheckKey,sdf.format(new Date())+"#"+1,lockTime,timeUnit);
}
}