在开发高并发系统时,一般都需要一些手段来保护系统。比如缓存,降级,限流等。
缓存用于提升系统访问速度和增大系统处理能力;降级一般当服务出现问题或者影响到核心流程的性能,需要暂时屏蔽掉一些功能,待高峰过去或问题解决后再重新打开。而对于稀缺资源的访问,频繁调用复杂查询等需要大量计算资源的请求等,需要一种手段来限制这些场景下的并发量或请求量,此时需要使用的手段就是限流。
限流的目的是通过对并发访问、请求进行限速或者限制在一个时间窗口内的请求数量来保护系统。一旦达到限流上限,可以拒绝服务,也可以采取将请求放入缓存队列等待等手段进行处理。
一般高并发系统常见的限流有:限制并发总数(数据库连接池,线程池)、限制瞬间并发数、显示时间窗口内的平均速率,以及限制远程接口调用速率,限制MQ消费速度等。限流的使用需要做好评估,否则有可能出现一些奇怪的问题,或者造成不好的用户体验。
常见的限流算法有:令牌桶,漏桶。也可以简单的使用计数器来实现。
令牌桶
令牌桶是指将令牌存放到一个固定容量的桶中,按照固定速率向桶中添加令牌。如果桶被填满后,新增令牌会被丢弃。当请求到来时,需要消耗指定数量的令牌(例如每个请求需要1个令牌),方可进入之后的处理流程,若桶中没有组够的令牌,请求将会被抛弃或进入等待队列。
漏桶
漏桶,首先也存在一个令牌桶,同样是将令牌存放到桶中,按照固定速率向桶中添加令牌。然后将所有的请求都放入漏桶(可以是一个队列),进入漏桶的速度随意,当漏桶被填满之后,请求将被抛弃。漏桶中的请求消耗令牌,然后从漏桶中移除,请求被处理。
令牌桶的作用是限制流入速度,但是允许一定程度的突发流量。
漏桶限制的是请求的流出速率,对突发流量进行平滑处理。
与令牌桶其实是一样的,只是发生作用的方向相反。对于相同的参数,起到的效果是一样的。
有时我们还可以使用计数器来对并发总数进行限制。当在单位时间内请求达到了预设的阈值,则进行限流。这种限流是对请求总数的限制,而不是限制平均速率。
自定义注解+拦截器+Redis实现限流
从代码层面来看,此方式实现还是比较优雅的,对业务层也没有太多的耦合。注意:此种方式单体和分布式均适用,因为用户实际的访问次数都是存在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
@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的配置文件中加入自定义拦截器*/
访问效果如下,10s内访问接口超过4次以上就过滤请求,原理和计数器算法类似:
Guava的RateLimiter实现限流
guava提供的RateLimiter可以限制物理或逻辑资源的被访问速率,咋一听有点像java并发包下的Samephore,但是又不相同,RateLimiter控制的是速率,Samephore控制的是并发量。RateLimiter的原理就是令牌桶,它主要由许可发出的速率来定义,如果没有额外的配置,许可证将按每秒许可证规定的固定速度分配,许可将被平滑地分发,若请求超过permitsPerSecond则RateLimiter按照每秒 1/permitsPerSecond 的速率释放许可。注意:RateLimiter适用于单体应用。下面简单的写个测试:
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实现高级定制化定时任务(包含管理界面)
-----------------------------------------这又是一点分隔线 2018-08-16补充-----------------------------------
今天翻阅是发现使用上述方式使用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
推荐阅读:elastic search搜索引擎实战demo:https://github.com/simonsfan/springboot-quartz-demo,分支:feature_es
推荐阅读:使用quartz实现定制化定时任务(包含管理界面)
我们在使用Tomcat时,以 下几个参数都与限流有关acceptCount:如果Tomcat的处理线程都被占用,新来的连接请求将会进入队列,如果超出排队大小,则拒绝连接。
MaxConnections:瞬时最大连接数,超出的会排队等待。
maxThreads:tomcat能启动用来处理请求的最大线程数如果请求处理量远大于最大线程数,则会引起相应变慢或请求假死。
参考:https://www.jianshu.com/p/41781605ed29?from=groupmessage
https://blog.csdn.net/fanrenxiang/article/details/80683378