最近开发的抢购活动上线后发现了两个比较明显的问题,其一:活动一开始,接口访问量剧增;其二:黑名单中增加了一大批黑名单用户(或者说IP),这其中就包含了一些恶意用户或机器人刷接口。
针对一些高并发的接口,限流是处理高并发的几大利剑之一。一方面,限流可以防止接口被刷,造成不必要的服务层压力,另一方面,是为了防止接口被滥用。
限流的方式也蛮多,本篇只讲几种我自己常用的,并且是后端的限流操作。
漏桶算法
漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。
漏桶算法示意图(图片取自网络)
漏桶算法可以很好地限制容量池的大小,从而防止流量暴增。
令牌桶算法
令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。
令牌桶算法示意图(图片取自网络)
令牌桶算法通过发放令牌,根据令牌的rate频率做请求频率限制,容量限制等。
从代码层面来看,此方式实现还是比较优雅的,对业务层也没有太多的耦合。注意:此种方式单体和分布式均适用,因为用户实际的访问次数都是存在redis容器里的,和应用的单体或分布式无关。
@Inherited
@Documented
@Target({ElementType.FIELD,ElementType.TYPE,ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface AccessLimit {
//标识 指定sec时间段内的访问次数限制
int limit() default 5;
//标识 时间段
int sec() default 5;
}
public class AccessLimitInterceptor implements HandlerInterceptor {
//使用RedisTemplate操作redis
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Method method = handlerMethod.getMethod();
if (!method.isAnnotationPresent(AccessLimit.class)) {
return true;
}
AccessLimit accessLimit = method.getAnnotation(AccessLimit.class);
if (accessLimit == null) {
return true;
}
int limit = accessLimit.limit();
int sec = accessLimit.sec();
String key = IPUtil.getIpAddr(request) + request.getRequestURI();
Integer maxLimit = redisTemplate.opsForValue().get(key);
if (maxLimit == null) {
//set时一定要加过期时间
redisTemplate.opsForValue().set(key, 1, sec, TimeUnit.SECONDS);
} else if (maxLimit < limit) {
redisTemplate.opsForValue().set(key, maxLimit + 1, sec, TimeUnit.SECONDS);
} else {
output(response, "请求太频繁!");
return false;
}
}
return true;
}
public void output(HttpServletResponse response, String msg) throws IOException {
response.setContentType("application/json;charset=UTF-8");
ServletOutputStream outputStream = null;
try {
outputStream = response.getOutputStream();
outputStream.write(msg.getBytes("UTF-8"));
} catch (IOException e) {
e.printStackTrace();
} finally {
outputStream.flush();
outputStream.close();
}
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
}
}
@Controller
@RequestMapping("/activity")
public class AopController {
@ResponseBody
@RequestMapping("/seckill")
@AccessLimit(limit = 4,sec = 10) //加上自定义注解即可
public String test (HttpServletRequest request,@RequestParam(value = "username",required = false) String userName){
//TODO somethings……
return "hello world !";
}
}
/*springmvc的配置文件中加入自定义拦截器*/
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/**"/>
<bean class="com.pptv.activityapi.controller.pointsmall.AccessLimitInterceptor"/>
</mvc:interceptor>
</mvc:interceptors>
访问效果如下,10s内访问接口超过4次以上就过滤请求,原理和计数器算法类似:
guava提供的RateLimiter可以限制物理或逻辑资源的被访问速率,咋一听有点像java并发包下的Samephore,但是又不相同,RateLimiter控制的是速率,Samephore控制的是并发量。RateLimiter的原理就是令牌桶,它主要由许可发出的速率来定义,如果没有额外的配置,许可证将按每秒许可证规定的固定速度分配,许可将被平滑地分发,若请求超过permitsPerSecond则RateLimiter按照每秒 1/permitsPerSecond 的速率释放许可。注意:RateLimiter适用于单体应用。下面简单的写个测试:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>
public static void main(String[] args) {
String start = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
RateLimiter limiter = RateLimiter.create(1.0); // 这里的1表示每秒允许处理的量为1个
for (int i = 1; i <= 10; i++) {
limiter.acquire();// 请求RateLimiter, 超过permits会被阻塞
System.out.println("call execute.." + i);
}
String end = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date());
System.out.println("start time:" + start);
System.out.println("end time:" + end);
}
可以看到,我假定了每秒处理请求的速率为1个,现在我有10个任务要处理,那么RateLimiter就很好的实现了控制速率,总共10个任务,需要9次获取许可,所以最后10个任务的消耗时间为9s左右。
放在Controller中用Jemter压测一下:
可以看到,模拟了20个并发请求,并设置了QPS为1,那么20个并发请求实现了限流的目的,后续的请求都要阻塞1s左右时间才能返回。要注意的是RateLimiter不保证公平性访问!
引申阅读: Guava Limiter实现限流
引申阅读: 使用quartz实现高级定制化定时任务(包含管理界面)
使用上述方式使用RateLimiter的方式不够优雅,尽管我们可以把RateLimiter的逻辑包在service里面,controller直接调用即可,但是如果我们换成:自定义注解+AOP的方式实现的话,会优雅的多,详细见下面代码:
首先定义自定义注解
import java.lang.annotation.*;
/**
* 自定义注解可以不包含属性,成为一个标识注解
*/
@Inherited
@Documented
@Target({ElementType.METHOD, ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimitAspect {
}
自定义切面类
import com.google.common.util.concurrent.RateLimiter;
import com.simons.cn.springbootdemo.util.ResultUtil;
import net.sf.json.JSONObject;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
@Component
@Scope
@Aspect
public class RateLimitAop {
@Autowired
private HttpServletResponse response;
private RateLimiter rateLimiter = RateLimiter.create(5.0); //比如说,我这里设置"并发数"为5
@Pointcut("@annotation(com.simons.cn.springbootdemo.aspect.RateLimitAspect)")
public void serviceLimit() {
}
@Around("serviceLimit()")
public Object around(ProceedingJoinPoint joinPoint) {
Boolean flag = rateLimiter.tryAcquire();
Object obj = null;
try {
if (flag) {
obj = joinPoint.proceed();
}else{
String result = JSONObject.fromObject(ResultUtil.success1(100, "failure")).toString();
output(response, result);
}
} catch (Throwable e) {
e.printStackTrace();
}
System.out.println("flag=" + flag + ",obj=" + obj);
return obj;
}
public void output(HttpServletResponse response, String msg) throws IOException {
response.setContentType("application/json;charset=UTF-8");
ServletOutputStream outputStream = null;
try {
outputStream = response.getOutputStream();
outputStream.write(msg.getBytes("UTF-8"));
} catch (IOException e) {
e.printStackTrace();
} finally {
outputStream.flush();
outputStream.close();
}
}
}
测试controller
import com.simons.cn.springbootdemo.aspect.RateLimitAspect;
import com.simons.cn.springbootdemo.util.ResultUtil;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
/**
* 类描述:RateLimit限流测试(基于注解+AOP)
* 创建人:simonsfan
*/
@Controller
public class TestController {
@ResponseBody
@RateLimitAspect
@RequestMapping("/test")
public String test(){
return ResultUtil.success1(1001, "success").toString();
}
这样通过自定义注解@RateLimiterAspect来动态的加到需要限流的接口上,个人认为是比较优雅的实现吧。
压测结果:
可以看到,10个线程中无论压测多少次,并发数总是限制在6,也就实现了限流,至于为什么并发数是6而不是5,我也很纳闷,这个问题在guava的github上提问了下,后面应该会有小伙伴解答的:https://github.com/google/guava/issues/3240
原文链接:https://blog.csdn.net/fanrenxiang/article/details/80683378