我们的业务逻辑是如图所示,
限流思路
我们 实现登录接口之后,我们想像这么一个场景,因为我们的登录接口在我们的拦截器中是放行的,如果这时候有人恶意来爆刷我们的登录接口,那我们的这个接口不就爆掉了吗,但是我们如果因为这一个登录接口就去为我们的整个项目添加限流成本就会显的比较高了,而对单一接口限流的话,我们完全可以用成本更低的 令牌桶来实现
那么什么是令牌桶
令牌桶使用
而我们使用令牌桶也很简单,只需要引入对应的依赖就行了
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1-jre</version>
</dependency>
然后看下面的方法
create方法中是 设定桶中有几个令牌,也就是一秒钟 可以使用的请求有多少次,
这个方法的意思是在 指定的多少时间内获得令牌,如果获得令牌返回true否则立刻返回false
,那么有了这两个方法,我们可以直接在我们接口中 创建限流器,然后进行限流了,但是这样不优雅,实在是不太优雅
优雅版本
我们为了让我们的增强我们的代码可用性,我们可以使用注解+aop的形式来进行令牌桶的限流,这样以后我们有多个接口的话我们直接加一个注解就可以进行限流了,十分的方便
下面来看
注解配置
package com.example.captchalogin.aop;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface limit {
//不同的方法有不同的降级策略
String key() default "";
/**
* 最多的访问限制次数 也就是令牌桶中 存放的令牌数量
*/
double permitsPerSecond () ;
/**
* 获取令牌最大等待时间
*/
long timeout();
/**
* 获取令牌最大等待时间,单位(例:分钟/秒/毫秒) 默认:毫秒
*/
TimeUnit timeunit() default TimeUnit.MILLISECONDS;
/**
* 得不到令牌的提示语
*/
String msg() default "系统繁忙,请稍后再试.";
}
aop配置
package com.example.captchalogin.aop;
import com.google.common.collect.Maps;
import com.google.common.util.concurrent.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.Map;
@Slf4j
@Aspect
@Component
public class RateLimiting {
//多线程情况下 访问的接口不同 该map可以记录使用过接口限流的接口,有更好的并发性能
private final Map<String, RateLimiter> limitMap = Maps.newConcurrentMap();
@Before("@annotation(com.example.captchalogin.aop.limit)")
public void rateLimit(JoinPoint joinPoint) throws IOException {
MethodSignature joinPointSignature = (MethodSignature )joinPoint.getSignature();
Method joinPointSignatureMethod = joinPointSignature.getMethod();
limit limit = joinPointSignatureMethod.getAnnotation(limit.class);
if(limit!=null){
//查看接口中是否有 ratelimiter
if(!limitMap.containsKey(limit.key())){
//不包含 就加一个限流器
RateLimiter rateLimiter = RateLimiter.create(limit.permitsPerSecond());
limitMap.put(limit.key(), rateLimiter);
log.info("创建了一个限流器,容量为{}",limit.permitsPerSecond());
}
//包含该方法的限流器的话
RateLimiter rateLimiter = limitMap.get(limit.key());
boolean tryAcquire = rateLimiter.tryAcquire(limit.timeout(), limit.timeunit());
log.info("获得到的令牌{}",tryAcquire);
if(!tryAcquire){
log.debug("获取令牌失败{}",limit.msg());
// 发送HTTP 429 Too Many Requests错误 //正常可以设置一个全局异常处理器 抛出异常返回给前端
((HttpServletResponse) joinPoint.getArgs()[0])
.sendError(HttpServletResponse.SC_NOT_ACCEPTABLE, limit.msg());
}
}
}
}
正常来说 tryacquire 之后应该直接抛出异常返回给异常处理器的,我懒了点,直接中断请求
然后验证码登录接口
@GetMapping("/captcha")
@limit(key = "limitCaptcha",permitsPerSecond = 1.0, timeout = 500)
public void getCaptcha( HttpServletResponse response) throws IOException {
LineCaptcha lineCaptcha = CaptchaUtil.createLineCaptcha(200, 100);
//获得验证码
String captchaCode = lineCaptcha.getCode();
//存入reids
stringRedisTemplate.opsForValue().set(MessageContext.Captcha,captchaCode,60, TimeUnit.SECONDS);
//获得到图形验证码 作为流返回给浏览器
lineCaptcha.write(response.getOutputStream());
response.getOutputStream().close();
}}
然后我们进行jmeter压测,并且查看我们的验证码登录接口
问题分析
可以看到返回了406,前面两次返回的都是200,因为 这个桶的机制是 每秒钟自动产生自己设置的令牌数, 当我们 请求没有发送过来的时候,桶中已经自动生成了一个令牌,当我们一使用,立马桶中又产生了一个令牌,所以会出现的问题就显而易见了,假如我们对这个接口访问频率设置为 每秒钟 5次,当我们一段时间不请求,突然有一段时间来了大量请求,我们的接口访问成功的频率 可能就会是10次,而不是五次。怎么解决呢,只能说没有好的解决方案,只能根据业务来自己进行对应不同算法配置