背景
原本有在公司框架层的preservice写限流,但经历线上流量突增的雪崩后排查发现,有些接口有非常耗时的参数解密算法在限流之前,这也是个风险点。所以提出了把接口限流方法放到spring拦截器里,在请求一进来的阶段,就先进行限流判断。
实现方式
1、限流方法
首先是限流方法,通过RateLimiter实现,这个网上有很多教程可以参考。
首先创建令牌桶(每秒允许10个请求的限流器对象):
RateLimiter limiter =RateLimiter.create(10);
多渠道限流中可以构建个Map来按业务划分限流值(根据传入的scene取不同的限流值):
private final Map<String,RateLimiter> sceneLimitMap = new ConcurrentHashMap<>();
使用的核心方法(false达到限流值):
boolean permit = RateLimiter.get(method).tryAcquire(tryAcquireTimeOut, TimeUnit.MILLISECONDS)
boolean permit = sceneLimitMap.get(scene).tryAcquire(tryAcquireTimeOut, TimeUnit.MILLISECONDS)
2、限流拦截器
首先创建类实现拦截器方法:
public class TryAcquireInterceptor implements AsyncHandlerInterceptor
重写处理方法:
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception
其中可以通过下面这些方法得到当前请求的uri和方法名,以此判断是否是需要限流的方法和接口,做成可配置的限流方式:
if(handler instanceof HandlerMethod){
String requestUri = request.getRequestURI();
methodName = handler.getMethod().getDeclaringClass().getName()
}
如果是需要限流的方法,就可以在下一步调用 1、限流方法
3、加入拦截器
在@Configuration的类里添加拦截器:
//添加限流拦截器
@Override
public void addInterceptors(InterceptorRegistry registry) {
String[] addPath = {"/**"};
String[] exludePath = {"/error"};
registry.addInterceptor(tryAcquireInterceptor)
.addPathPatterns(addPath)
.excludePathPatterns(exludePath)
;
}
另外还看到了一种初始化方法可以使用的注解:
@ PostConstruct
Java自带的注解,在方法上加该注解会在项目启动的时候执行该方法,也可以理解为在spring容器初始化的时候执行该方法。
4、切面注解实现限流
(1)自定义限流注解
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface Limit {
/**
* 资源的key,唯一
* 作用:不同的接口,不同的流量控制
*/
String key() default "";
/**
* 最多的访问限制次数
*/
double permitsPerSecond () ;
/**
* 获取令牌最大等待时间
*/
long timeout();
/**
* 获取令牌最大等待时间,单位(例:分钟/秒/毫秒) 默认:毫秒
*/
TimeUnit timeunit() default TimeUnit.MILLISECONDS;
/**
* 得不到令牌的提示语
*/
String msg() default "系统繁忙,请稍后再试.";
}
(2)使用AOP切面拦截限流注解
@Slf4j
@Aspect
@Component
public class LimitAop {
/**
* 不同的接口,不同的流量控制
* map的key为 Limiter.key
*/
private final Map<String, RateLimiter> limitMap = Maps.newConcurrentMap();
@Around("@annotation(com.jianzh5.blog.limit.Limit)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable{
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
//拿limit的注解
Limit limit = method.getAnnotation(Limit.class);
if (limit != null) {
//key作用:不同的接口,不同的流量控制
String key=limit.key();
RateLimiter rateLimiter = null;
//验证缓存是否有命中key
if (!limitMap.containsKey(key)) {
// 创建令牌桶
rateLimiter = RateLimiter.create(limit.permitsPerSecond());
limitMap.put(key, rateLimiter);
log.info("新建了令牌桶={},容量={}",key,limit.permitsPerSecond());
}
rateLimiter = limitMap.get(key);
// 拿令牌
boolean acquire = rateLimiter.tryAcquire(limit.timeout(), limit.timeunit());
// 拿不到命令,直接返回异常提示
if (!acquire) {
log.debug("令牌桶={},获取令牌失败",key);
this.responseFail(limit.msg());
return null;
}
}
return joinPoint.proceed();
}
/**
* 直接向前端抛出异常
* @param msg 提示信息
*/
private void responseFail(String msg) {
HttpServletResponse response=((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getResponse();
ResultData<Object> resultData = ResultData.fail(ReturnCode.LIMIT_ERROR.getCode(), msg);
WebUtils.writeJson(response,resultData);
}
}
(3)给需要限流的接口加上注解
@Slf4j
@RestController
@RequestMapping("/limit")
public class LimitController {
@GetMapping("/test2")
@Limit(key = "limit2", permitsPerSecond = 1, timeout = 500, timeunit = TimeUnit.MILLISECONDS,msg = "当前排队人数较多,请稍后再试!")
public String limit2() {
log.info("令牌桶limit2获取令牌成功");
return "ok";
}
@GetMapping("/test3")
@Limit(key = "limit3", permitsPerSecond = 2, timeout = 500, timeunit = TimeUnit.MILLISECONDS,msg = "系统繁忙,请稍后再试!")
public String limit3() {
log.info("令牌桶limit3获取令牌成功");
return "ok";
}
}
总结
通过以上几步,就可以简单实现拦截器限流的方式。之后如果需要在服务接口响应之前做些公共操作也可以考虑拦截器的方式。方便且容易实现,步骤很清晰,可以专注于功能本身的开发。