在大型分布式系统中,限流是一种重要的防护机制,可以帮助我们控制流量并减轻系统的负担。Google的Guava库提供了一种方便的限流器实现,可以帮助我们轻松地实现限流功能。本文将介绍Guava中限流器的基本概念和使用方法。
一、什么是限流器?
限流器是一种控制系统流量的机制,可以帮助我们在高负载情况下保护系统免受过载和崩溃的风险。通过限制进入系统的请求速率,我们可以避免服务器过载和资源耗尽的问题。
常用的限流方式和场景有:
- 限制总并发数(比如数据库连接池、线程池)
- 限制瞬时并发数(如nginx的limitconn模块,用来限制瞬时并发连接数,Java的Semaphore也可以实现)
- 限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limitreq模块,限制每秒的平均速率)
- 其他:比如如限制远程接口调用速率、限制MQ的消费速率。另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流。
二、Guava中限流器的实现原理
Guava中的限流器实现基于令牌桶算法,它是一种流量控制算法,可以帮助我们平滑地限制请求速率。限流器将请求速率限制为指定的速率,以确保系统能够在高负载情况下保持稳定。
Guava中的限流器使用一个桶来保存令牌,桶中的令牌代表可用的请求数量。每当一个请求到达时,限流器会尝试从桶中取出一个令牌,如果桶中没有可用的令牌,则该请求将被阻塞,直到有足够的令牌可用为止。当令牌被用尽时,限流器将根据配置的速率自动添加新的令牌到桶中,以确保系统能够继续处理请求。
guava单机限流RateLimiter简介
RateLimiter是基于令牌桶算法实现的一个限流组件,其代码看起来很简单,一共就两个类:抽象父类 RateLimiter和实际的 SmoothRateLimiter。其中 SmoothWarmingUP和 SmmothBursty是 SmoothRateLimiter的两个内部类。但实际真的要看懂也需要花点时间的,这里其实主要就是算法上的考虑不好看懂。
RateLimiter提供了两种限流模式:
- 普通的限流SmoothBursty
- 带预热的限流。即在指定预热期,允许放过的流量逐渐增加。预热期结束后,允许放过的流量就等于设定的限流值。这个目的是为了解决软件重启等情况,由于缓存等还没有初始化化、jvm还是解释执行等,能够承受的流量比稳定运行后更小,防止在服务刚刚启动就被大流量打挂了,所以RateLimiter提供了一个预热器。
使用方式:
当然也提供了非阻塞的tryAcquire()方法。
RateLimiter属性简介
- stableIntervalMicros:
令牌产生的稳定速率,只是这里的速率是转换成了两个令牌生产之间的时间间隔(毫秒)。之所以是稳定速率,是因为SmoothWarmUp,在预热阶段产生令牌的速率会低于这个值。RateLimiter初始化的时候,传入的permitsPerSecond表示的是每秒产生的产生的令牌个数,也就是说令牌的生产速率的时间单位就给固定了,那么stableIntervalMicros = 1s/permitsPerSecond。
Ps:速率(单位时间生产个数) = 时间段内总个数/时间长度 = 时间内长度/生产两个令牌的时间间隔 可以来表示生效速率。反过来使用两个令牌生产间隔其实也就可以表示速率。
- maxPermits:
令牌桶的容量,即令牌桶中最大的令牌数。对于无预热的限流器, maxPermits = 1s/stableIntervalMicros。之所以要这么计算,是因为初始化RateLimiter时,传入的permitsPerSecond是个double,用小数来表达限流窗口不是1s的情况。
而对于有预热的限流器,预热期间,就是1s/stableIntervalMicros的一半。预热结束就是1s/stableIntervalMicros。
所以这个maxPermits的最大值,其实就是初始化RateLimiter的时候设置的限流阈值做了整数转换。
- storedPermits:
令牌桶中当前拥有令牌的个数。
- nextFreeTicketMicros:
下次无需等待就能直接获取token的时间。它的值的计算包含两部分:
- 按照正常速率生产令牌,下一次能够直接获得令牌的。比如令牌的生产速率是每秒100个,如果在T1时刻将令牌桶token清空了,那么下次无需等待就能获得令牌的时间就是T1之后的100ms处。
- 预支的令牌生产的时间。RateLimiter为了支持一定的流量突发,当一次调用acquire()的时候,如果当前令牌桶中没有足够的令牌,也不会阻塞当前acquire()请求,而是直接返回,然后将预支的那些令牌的生产时间累加到nextFreeTicketMicros,然后下次调用acquire()的时候就会阻塞更久。
比如:当前令牌桶中的令牌数storedPermits=2,但是acquire(5)的时候不会立马阻塞,而是将超支的3个令牌的生产时间转义到下次调用acquire()的时候。
即nextFreeTicketMicros += 3*100ms。
RateLimiter的设计哲学: 它允许瞬间的流量波峰超过QPS,但瞬间过后的请求将会等待较长的时间来缓解上次的波峰,以使得平均的QPS等于预定值。
这4个参数是SmoothBursty和SmoothWarmUp共有的,且维护逻辑也都是一样的。
SmoothBursty自己的属性
maxBurstSeconds:这个是影响maxPermits,在计算maxPermits的时候,实际是maxBurstSeconds * permitsPerSecond,如果maxBurstSeconds大于1,那其实就允许令牌桶中多余初始化RateLimiter时指定的阈值,以应对一定的突发流量。在guava 30.1版本中,这个值还是写死的1.0.
SmoothWarmUp自己的属性:
- warmupPeriodMicros:
预热期时间长度,这个是初始化RateLimiter传入的。
- thresholdPermits:预热期内令牌桶内最大的令牌数。
其值=0.5 * warmupPeriodMicros/stableIntervalMicros
- coldFactor:
预热期令牌生产速率的减缓因子。正常情况下,令牌的生产间隔就是stableIntervalMicros = 1s/permitsPerSecond,而在预热期,令牌的生产速率=coldFactor * stableIntervalMicros。
在guava 30.1版本中,这个值还是写死的3.0。
所以,对于限流阈值设置成1s内100个,那么stableIntervalMicros=100ms,但是在预热期令牌生产速率=3*100ms=300ms.
- slope:
其值=(stableIntervalMicros * coldFactor - stableIntervalMicros) / (maxPermits - thresholdPermits)
三、如何使用Guava中的限流器?
在Guava中使用限流器非常简单,只需要按照以下步骤即可:
1.创建一个限流器对象
Guava中的限流器对象是通过RateLimiter类实现的。您可以使用RateLimiter.create(double permitsPerSecond)方法创建一个限流器对象,其中参数permitsPerSecond是每秒允许的请求数。
例如,以下代码创建一个每秒允许10个请求的限流器对象:
RateLimiter limiter =RateLimiter.create(10);
2.获取许可证
当您的应用程序需要处理一个请求时,可以使用RateLimiter.acquire()方法获取许可证。这个方法将会阻塞,直到有足够的令牌可用。如果限流器没有可用的令牌,则该方法将阻塞,直到有足够的令牌可用。
例如,以下代码获取一个许可证并处理请求:
limiter.acquire();
handleRequest();
3.尝试获取许可证
如果您希望尝试获取许可证而不是阻塞,则可以使用RateLimiter.tryAcquire()方法。这个方法将尝试获取许可证,如果限流器没有可用的令牌,则该方法将返回false,否则将返回true。
例如,以下代码尝试获取一个许可证:
if (limiter.tryAcquire()) {
handleRequest();
} else {
handleRateLimitedRequest();
}
4.调整速率
您还可以使用RateLimiter.setRate(double permitsPerSecond)方法动态地调整限流器的速率。这个方法可以帮助您根据系统负载和流量需求动态调整请求速率。
例如,以下代码动态调整限流器的速率:
limiter.setRate(20);
5.处理多个请求
如果您需要处理多个请求,则可以使用RateLimiter.acquire(int permits)方法获取多个许可证。这个方法将会阻塞,直到有足够的令牌可用。
例如,以下代码获取5个许可证并处理5个请求:
limiter.acquire(5);
handleRequest1();
handleRequest2();
handleRequest3();
handleRequest4();
handleRequest5();
四、Guava限流器的注意事项
在使用Guava限流器时,需要注意以下几点:
- 限流器的速率应该根据系统负载和流量需求进行动态调整。
- 如果您需要处理多个请求,请确保您的限流器对象能够处理足够的请求数量。
- 在高负载情况下,限流器可能会导致请求超时或错误,请确保您的应用程序能够处理这些情况。
- 限流器的速率应该设置为适当的值,以确保系统能够处理所有的请求。
五、总结
Guava中的限流器提供了一种简单而有效的方法来控制系统流量,以保护系统免受过载和崩溃的风险。使用Guava限流器,您可以轻松地实现限流功能,并根据系统负载和流量需求动态调整请求速率。在实现分布式系统时,使用限流器是一种重要的防护机制,可以帮助我们确保系统的稳定性和可靠性。
六、SpringBoot使用限流器示例
1、pom文件添加依赖
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1-jre</version>
</dependency>
2、给接口加上限流逻辑
@Slf4j
@RestController
@RequestMapping("/limit")
public class LimitController {
/**
* 限流策略 :1秒钟2个请求
*/
private final RateLimiter limiter = RateLimiter.create(2.0);
private DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@GetMapping("/test1")
public String testLimiter() {
// 500毫秒内,没拿到令牌,就直接进入服务降级
boolean tryAcquire = limiter.tryAcquire(500, TimeUnit.MILLISECONDS);
if (!tryAcquire) {
log.warn("进入服务降级,时间{}", LocalDateTime.now().format(dtf));
return "当前排队人数较多,请稍后再试!";
}
log.info("获取令牌成功,时间{}", LocalDateTime.now().format(dtf));
return "请求成功";
}
}
以上用到了RateLimiter的2个核心方法:create()、tryAcquire(),以下为详细说明
- acquire() 获取一个令牌, 改方法会阻塞直到获取到这一个令牌, 返回值为获取到这个令牌花费的时间
- acquire(int permits) 获取指定数量的令牌, 该方法也会阻塞, 返回值为获取到这 N 个令牌花费的时间
- tryAcquire() 判断时候能获取到令牌, 如果不能获取立即返回 false
- tryAcquire(int permits) 获取指定数量的令牌, 如果不能获取立即返回 false
- tryAcquire(long timeout, TimeUnit unit) 判断能否在指定时间内获取到令牌, 如果不能获取立即返回 false
- tryAcquire(int permits, long timeout, TimeUnit unit) 同上
3、体验效果
通过访问测试地址:http://127.0.0.1:8080/limit/test1,反复刷新并观察后端日志
WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:37
WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:37
INFO LimitController:39 - 获取令牌成功,时间2021-09-25 21:39:37
WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:37
WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:37
INFO LimitController:39 - 获取令牌成功,时间2021-09-25 21:39:37
WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:38
INFO LimitController:39 - 获取令牌成功,时间2021-09-25 21:39:38
WARN LimitController:35 - 进入服务降级,时间2021-09-25 21:39:38
INFO LimitController:39 - 获取令牌成功,时间2021-09-25 21:39:38
从以上日志可以看出,1秒钟内只有2次成功,其他都失败降级了,说明已经成功给接口加上了限流功能。
七、SpringBoot基于AOP实现接口限流
1、加入AOP依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
2、自定义限流注解
@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 "系统繁忙,请稍后再试.";
}
3、使用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);
}
}
4、给需要限流的接口加上注解
@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";
}
}
5、体验效果
通过访问测试地址:http://127.0.0.1:8080/limit/test2,反复刷新并观察输出结果:
正常响应时:
{"status":100,"message":"操作成功","data":"ok","timestamp":1632579377104}
触发限流时:
{"status":2001,"message":"系统繁忙,请稍后再试!","data":null,"timestamp":1632579332177}
通过观察得之,基于自定义注解同样实现了接口限流的效果。
6、小结
一般在系统上线时我们通过对系统压测可以评估出系统的性能阈值,然后给接口加上合理的限流参数,防止出现大流量请求时直接压垮系统。今天我们介绍了几种常见的限流算法(重点关注令牌桶算法),基于Guava工具类实现了接口限流并利用AOP完成了对限流代码的优化。