为什么要做?
网站免费接口被竞品恶意请求,导致数据量和并发暴增,一开始封禁了他的IP,但是后面他又换了IP来搞我们,没办法只能被迫"应战"。
为什么不在网关层做?
也有想过用Nginx,但是我们想要的是更加细粒度的控制和更加灵活的配置。
Redis+Lua+Annotation限流实例
引入相关依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
</dependencies>
Lua脚本
local key = "rate.limit:" .. KEYS[1] --限流KEY
local limit = tonumber(ARGV[1]) --限流大小
local current = tonumber(redis.call('get', key) or "0")
if current + 1 > limit then --如果超出限流大小
return 0
else --请求数+1,并设置2秒过期
redis.call("INCRBY", key,"1")
redis.call("expire", key,"2")
return current + 1
end
1、通过KEYS[1] 获取传入的key参数
2、通过ARGV[1]获取传入的limit参数
3、redis.call方法,从缓存中get和key相关的值,如果为nil那么就返回0
4、接着判断缓存中记录的数值是否会大于限制大小,如果超出表示该被限流,返回0
5、如果未超过,那么该key的缓存值+1,并设置过期时间为1秒钟以后,并返回缓存值+1
注解(支持SPEL表达式)
/**
* 分布式限流注解
* @since 1.0.0
*/
@Target(value = {ElementType.METHOD})
@Retention(value = RetentionPolicy.RUNTIME)
public @interface RateLimit {
/**
* 限流唯一标示 支持spel表达式
*
* @return
*/
String key() default "";
/**
* 限流时间 单位:秒
*
* @return
*/
long time();
/**
* 限流次数 单位:次
*
* @return
*/
int count();
}
自动装配
public class RateLimitAutoConfiguration {
/**
* 初始化限流脚本
*
* @return
*/
@Bean
public DefaultRedisScript<Number> redisluaScript() {
DefaultRedisScript<Number> redisScript = new DefaultRedisScript<>();
redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("/redisScript/rateLimit.lua")));
redisScript.setResultType(Number.class);
return redisScript;
}
}
拦截器
@Aspect
@Configuration
@Slf4j
public class LimitAspect {
@Autowired
private DefaultRedisScript<Number> redisluaScript;
/**
* 参数发现器
*/
private static final ParameterNameDiscoverer NAME_DISCOVERER = new DefaultParameterNameDiscoverer();
/**
* Express语法解析器
*/
private static final ExpressionParser PARSER = new SpelExpressionParser();
@Around("@annotation(RateLimit)")
public Object interceptor(ProceedingJoinPoint joinPoint) throws Throwable {
//得到被代理的方法
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = method.getDeclaringClass();
RateLimit rateLimit = method.getAnnotation(RateLimit.class);
//得到被切面修饰的方法的参数列表
Object[] args = joinPoint.getArgs();
if (rateLimit != null) {
//得到注解的key
String key = rateLimit.key();
//清除当前类的缓存
EvaluationContext context = new MethodBasedEvaluationContext(null, method, args, NAME_DISCOVERER);
//解析spel表达式
String realKey = String.valueOf(PARSER.parseExpression(key).getValue(context));
//获取请求ip
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String ipAddress = IpUtil.getIpAddr(request);
//拼接redis-key
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(ipAddress).append("-")
.append(targetClass.getName()).append("-")
.append(method.getName()).append("-")
.append(realKey);
List<String> keys = Collections.singletonList(stringBuffer.toString());
Number number = RedisUtil.getRedisTemplate().execute(redisluaScript, keys, rateLimit.count(), rateLimit.time());
if (number != null && number.intValue() != 0 && number.intValue() <= rateLimit.count()) {
return joinPoint.proceed();
}
} else {
return joinPoint.proceed();
}
return RetMsg.fail("访问过于频繁,请稍后再试!");
}
}
使用示范
//每个用户 每60秒可以访问五次 注:subject为登录实体 包含登录信息
@RateLimit(key = "#subject.user.id", time = 60, count = 5)
public RetMsg getBaseInfo(Subject subject) {
//do something
}
网友评论