高并发服务限流实践(一)
本文从限流背景开始,介绍了限流的常用方法、代码实现和限流组件源码分析。本文是该系列的第一篇,介绍限流背景,限流算法和RateLimiter限流实现。第二篇会介绍RateLimiter的源码实现。
一、限流背景
限流是保护系统的重要利器,通过对并发访问或请求数进行限制或者对一个时间窗口内的请求数进行限速,用于防止大流量或突发流量导致服务崩溃。一旦达到限制速率则可以拒绝服务或进行流量整形。
在实践中会在网络层,接入层(Nginx),应用层进行限流。本文介绍的是应用层的限流方式,对于其它层原理类似,分层或使用的技术手段不同而已。
一般的大流量场景如秒杀,抢购系统,大并发电商系统等,实现技术可以限制线程池,数据库连接池,瞬间并发数,接口调用速率、限制MQ消费速率。根据网络连接数、网络流量、CPU或内存负载等来限流等。
二、限流算法
限流常用的基本算法有两种,漏桶算法和令牌桶算法。
如上图就像一个漏斗一样,进来的水量就像访问流量一样,出去的水量像系统处理请求一样。当访问流量过大时,漏斗中就会积水,如果水太多了就会溢出。
2.1 漏桶算法
漏桶算法一般有队列(Queue)和处理器两个组件构成,队列用于存放请求,处理器复杂处理请求。
(1)请求达到如果未满,则放入队列,之后有处理器按照固定速率取出请求进行处理。
(2)如果请求量过大超出了最大限制,则新来的请求会被抛弃。
漏桶算法示意图
2.2 令牌桶算法
令牌桶算法与漏桶算法组件构成结构类似,区别在于令牌桶会先发放令牌,如果有令牌,则继续处理,无则抛弃请求。
(1)往桶内按照固定速率,添加固定容量的令牌。令牌数量达到最大限制,则会丢弃或拒绝。
(2)当请求到达时,如果获取到令牌则直接处理,如果获取不到,则会丢弃或放到缓存区。
令牌桶算法示意图
2.3令牌桶算法和漏桶算法对比
(1)令牌桶算法是按照固定速率往桶中添加令牌,请求是否被处理需要看桶中令牌是否足够,当令牌数减为零时则拒绝新的请求;漏桶则是按照常量固定速率流出请求,流入请求速率任意,当流入的请求数累积到漏桶容量时,则新流入的请求被拒绝;
(2)令牌桶算法限制的是平均流入速率,允许突发请求,只要有令牌就可以处理,支持一次拿多个令牌,比如10个或15个;漏桶算法限制的是常量流出速率,即流出速率是一个固定常量,比如速率流出1,则一直都是1,而不能一次是1,下次是2,从而可以平滑突发流入速率;
(3)令牌桶算法允许一定程度的突发流量,而漏桶算法可以平滑流出速率;
三、限流实现
Guava的RateLimiter提供了令牌桶算法实现:平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。我们将通过案例介绍RateLimiter的使用。
3.1 maven引用
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>28.1-jre</version>
</dependency>
3.2 定义注解
({ElementType.PARAMETER, ElementType.METHOD})
(RetentionPolicy.RUNTIME)
public RequestRateLimitAnnotation {
/**
* 固定令牌个数
* @return
*/
double limitNum();
/**
* 获取令牌超时时间
* @return
*/
long timeout();
/**
* 单位-默认毫秒
* @return
*/
TimeUnit timeUnit() default TimeUnit.MILLISECONDS;
/**
* 无法获取令牌时的错误信息
* @return
*/
String errMsg() default "请求太频繁!";
}
3.3 AOP拦截
public class RequestRateLimitAspect {
/**
* 使用url做为key,存放令牌桶 防止每次重新创建令牌桶
*/
private Map<String, RateLimiter> limitMap = Maps.newConcurrentMap();
public void pointCut() {
}
public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable {
//获取请求uri
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String reqUrl=request.getRequestURI();
// 获取令牌
RequestRateLimitAnnotation rateLimiter = this.getRequestRateLimiter(joinPoint);
if (Objects.nonNull(rateLimiter)) {
RateLimiter limiter = getRateLimiter(reqUrl, rateLimiter);
boolean acquire = limiter.tryAcquire(rateLimiter.timeout(), rateLimiter.timeUnit());
if (!acquire) {
return new Response<>(“200”, reqUrl.concat(rateLimiter.errMsg()));
}
}
//获得令牌,继续执行
return joinPoint.proceed();
}
/**
* 获取RateLimiter
*
* @return
*/
private RateLimiter getRateLimiter(String reqUrl, RequestRateLimitAnnotation rateLimiter) {
RateLimiter limiter = limitMap.get(reqUrl);
if (Objects.isNull(limiter)) {
synchronized (this) {
limiter = limitMap.get(reqUrl);
if (Objects.isNull(limiter)) {
limiter = RateLimiter.create(rateLimiter.limitNum());
limitMap.put(reqUrl, limiter);
log.info("RequestRateLimitAspect请求{},创建令牌桶,容量{} 成功", reqUrl, rateLimiter.limitNum());
}
}
}
return limiter;
}
/**
* 获取注解对象
* @param joinPoint 对象
* @return ten LogAnnotation
*/
private RequestRateLimitAnnotation getRequestRateLimiter(final JoinPoint joinPoint) {
Method[] methods = joinPoint.getTarget().getClass().getDeclaredMethods();
String name = joinPoint.getSignature().getName();
if (!StringUtils.isEmpty(name)) {
for (Method method : methods) {
RequestRateLimitAnnotation annotation = method.getAnnotation(RequestRateLimitAnnotation.class);
if (!Objects.isNull(annotation) && name.equals(method.getName())) {
return annotation;
}
}
}
return null;
}
}
3.4 Controller应用
public class ExampleController {
public String testRateLimit() {
/**
* 测试代码
*/
return "success";
}
3.5 执行效果
如果超过最大限流,则打印日志如下:
com.itlfy8.test.controller.TestController result:{"code":"200","msg":"/test/ratelimit请求太频繁!","data":{}}
四、总结
本文从限流场景、限流算法和RateLimit的使用三个方面,介绍了高并发限流的方法,代码提供了一种较为通用的实现方式,在此基础上扩展可以实现比较通用的限流机制。