限流 - guava的RateLimiter 介绍
先来个事例实现:
RateLimiter是Guava的一个限流组件,我这边的系统就有用到这个限流组件,使用起来十分方便。
引入pom依赖:
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>20.0</version> </dependency>
RateLimiter它是基于令牌桶算法的,API非常简单,看以下的Demo:
public static void main(String[] args) { //线程池 ExecutorService exec = Executors.newCachedThreadPool(); //速率是每秒只有3个许可 final RateLimiter rateLimiter = RateLimiter.create(3.0); for (int i = 0; i < 100; i++) { final int no = i; Runnable runnable = new Runnable() { @Override public void run() { try { //获取许可 rateLimiter.acquire(); System.out.println("Accessing: " + no + ",time:" + new SimpleDateFormat("yy-MM-dd HH:mm:ss").format(new Date())); } catch (Exception e) { e.printStackTrace(); } } }; //执行线程 exec.execute(runnable); } //退出线程池 exec.shutdown(); }
我们可以从结果看出,每秒只能执行三个:
RateLimiter使用的是一种叫令牌桶的流控算法,RateLimiter会按照一定的频率往桶里扔令牌,线程拿到令牌才能执行,比如你希望自己的应用程序QPS不要超过1000,那么RateLimiter设置1000的速率后,就会每秒往桶里扔1000个令牌。
RateLimiter经常用于限制对一些物理资源或者逻辑资源的访问速率。与Semaphore 相比,Semaphore 限制了并发访问的数量而不是使用速率。
通过设置许可证的速率来定义RateLimiter。在默认配置下,许可证会在固定的速率下被分配,速率单位是每秒多少个许可证。为了确保维护配置的速率,许可会被平稳地分配,许可之间的延迟会做调整。
可能存在配置一个拥有预热期的RateLimiter 的情况,在这段时间内,每秒分配的许可数会稳定地增长直到达到稳定的速率。
举例来说明如何使用RateLimiter,想象下我们需要处理一个任务列表,但我们不希望每秒的任务提交超过两个:
//速率是每秒两个许可
final RateLimiter rateLimiter = RateLimiter.create(2.0);
void submitTasks(List tasks, Executor executor) {
for (Runnable task : tasks) {
rateLimiter.acquire(); // 也许需要等待
executor.execute(task);
}
}
再举另外一个例子,想象下我们制造了一个数据流,并希望以每秒5kb的速率处理它。可以通过要求每个字节代表一个许可,然后指定每秒5000个许可来完成:
// 每秒5000个许可
final RateLimiter rateLimiter = RateLimiter.create(5000.0);
void submitPacket(byte[] packet) {
rateLimiter.acquire(packet.length);
networkService.send(packet);
}
有一点很重要,那就是请求的许可数从来不会影响到请求本身的限制(调用acquire(1) 和调用acquire(1000) 将得到相同的限制效果,如果存在这样的调用的话),但会影响下一次请求的限制,也就是说,如果一个高开销的任务抵达一个空闲的RateLimiter,它会被马上许可,但是下一个请求会经历额外的限制,从而来偿付高开销任务。注意:RateLimiter 并不提供公平性的保证。
方法摘要
修饰符和类型 | 方法和描述 |
---|---|
double | acquire() 从RateLimiter获取一个许可,该方法会被阻塞直到获取到请求 |
double | acquire(int permits) 从RateLimiter获取指定许可数,该方法会被阻塞直到获取到请求 |
static RateLimiter | create(double permitsPerSecond) 根据指定的稳定吞吐率创建RateLimiter,这里的吞吐率是指每秒多少许可数(通常是指QPS,每秒多少查询) |
static RateLimiter | create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) 根据指定的稳定吞吐率和预热期来创建RateLimiter,这里的吞吐率是指每秒多少许可数(通常是指QPS,每秒多少个请求量),在这段预热时间内,RateLimiter每秒分配的许可数会平稳地增长直到预热期结束时达到其最大速率。(只要存在足够请求数来使其饱和) |
double | getRate() 返回RateLimiter 配置中的稳定速率,该速率单位是每秒多少许可数 |
void | setRate(double permitsPerSecond) 更新RateLimite的稳定速率,参数permitsPerSecond 由构造RateLimiter的工厂方法提供。 |
String | toString() 返回对象的字符表现形式 |
boolean | tryAcquire() 从RateLimiter 获取许可,如果该许可可以在无延迟下的情况下立即获取得到的话 |
boolean | tryAcquire(int permits) 从RateLimiter 获取许可数,如果该许可数可以在无延迟下的情况下立即获取得到的话 |
boolean | tryAcquire(int permits, long timeout, TimeUnit unit) 从RateLimiter 获取指定许可数如果该许可数可以在不超过timeout的时间内获取得到的话,或者如果无法在timeout 过期之前获取得到许可数的话,那么立即返回false (无需等待) |
boolean | tryAcquire(long timeout, TimeUnit unit) 从RateLimiter 获取许可如果该许可可以在不超过timeout的时间内获取得到的话,或者如果无法在timeout 过期之前获取得到许可的话,那么立即返回false(无需等待) |
令牌桶算法
令牌桶算法是网络流量整形(Traffic Shaping)和限流(Rate Limiting)中最常使用的一种算法,它可用于控制发送到网络上数据的数量并允许突发数据的发送。
从某种意义上来说,令牌桶算法是对漏桶算法的一种改进,主要在于令牌桶算法能够在限制调用的平均速率的同时还允许一定程度的突发调用,来看下令牌桶算法的实现原理:
整个的过程是这样的:
- 系统以恒定的速率产生令牌,然后将令牌放入令牌桶中
- 令牌桶有一个容量,当令牌桶满了的时候,再向其中放入的令牌就会被丢弃
- 每次一个请求过来,需要从令牌桶中获取一个令牌,假设有令牌,那么提供服务;假设没有令牌,那么拒绝服务
那么,我们再看一下,为什么令牌桶算法可以防止一定程度的突发流量呢?可以这么理解,假设我们想要的速率是1000QPS,那么往桶中放令牌的速度就是1000个/s,假设第1秒只有800个请求,那意味着第2秒可以容许1200个请求,这就是一定程度突发流量的意思,反之我们看漏桶算法,第一秒只有800个请求,那么全部放过,第二秒这1200个请求将会被打回200个。
注意上面多次提到一定程度这四个字,这也是我认为令牌桶算法最需要注意的一个点。假设还是1000QPS的速率,那么5秒钟放1000个令牌,第1秒钟800个请求过来,第2~4秒没有请求,那么按照令牌桶算法,第5秒钟可以接受4200个请求,但是实际上这已经远远超出了系统的承载能力,因此使用令牌桶算法特别注意设置桶中令牌的上限即可。
总而言之,作为对漏桶算法的改进,令牌桶算法在限流场景下被使用更加广泛。
RateLimiter使用
上面说了令牌桶算法在限流场景下被使用更加广泛,接下来我们看一下代码示例,模拟一下每秒最多过五个请求:
public class RateLimiterTest { private static final SimpleDateFormat FORMATTER = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); private static final int THREAD_COUNT = 25; @Test public void testRateLimiter1() { RateLimiter rateLimiter = RateLimiter.create(5); Thread[] ts = new Thread[THREAD_COUNT]; for (int i = 0; i < THREAD_COUNT; i++) { ts[i] = new Thread(new RateLimiterThread(rateLimiter), "RateLimiterThread-" + i); } for (int i = 0; i < THREAD_COUNT; i++) { ts[i].start(); } for (;;); } public class RateLimiterThread implements Runnable { private RateLimiter rateLimiter; public RateLimiterThread(RateLimiter rateLimiter) { this.rateLimiter = rateLimiter; } @Override public void run() { rateLimiter.acquire(1); System.out.println(Thread.currentThread().getName() + "获取到了令牌,时间 = " + FORMATTER.format(new Date())); } } }
利用RateLimiter.create这个构造方法可以指定每秒向桶中放几个令牌,比方说上面的代码create(5),那么每秒放置5个令牌,即200ms会向令牌桶中放置一个令牌。这边代码写了一条线程模拟实际场景,拿到令牌那么就能执行下面逻辑,看一下代码执行结果:
RateLimiterThread-0获取到了令牌,时间 = 2019-08-25 20:58:53 RateLimiterThread-23获取到了令牌,时间 = 2019-08-25 20:58:54 RateLimiterThread-21获取到了令牌,时间 = 2019-08-25 20:58:54 RateLimiterThread-19获取到了令牌,时间 = 2019-08-25 20:58:54 RateLimiterThread-17获取到了令牌,时间 = 2019-08-25 20:58:54 RateLimiterThread-13获取到了令牌,时间 = 2019-08-25 20:58:54 RateLimiterThread-9获取到了令牌,时间 = 2019-08-25 20:58:55 RateLimiterThread-15获取到了令牌,时间 = 2019-08-25 20:58:55 RateLimiterThread-5获取到了令牌,时间 = 2019-08-25 20:58:55 RateLimiterThread-1获取到了令牌,时间 = 2019-08-25 20:58:55 RateLimiterThread-11获取到了令牌,时间 = 2019-08-25 20:58:55 RateLimiterThread-7获取到了令牌,时间 = 2019-08-25 20:58:56 RateLimiterThread-3获取到了令牌,时间 = 2019-08-25 20:58:56 RateLimiterThread-4获取到了令牌,时间 = 2019-08-25 20:58:56 RateLimiterThread-8获取到了令牌,时间 = 2019-08-25 20:58:56 RateLimiterThread-12获取到了令牌,时间 = 2019-08-25 20:58:56 RateLimiterThread-16获取到了令牌,时间 = 2019-08-25 20:58:57 RateLimiterThread-20获取到了令牌,时间 = 2019-08-25 20:58:57 RateLimiterThread-24获取到了令牌,时间 = 2019-08-25 20:58:57 RateLimiterThread-2获取到了令牌,时间 = 2019-08-25 20:58:57 RateLimiterThread-6获取到了令牌,时间 = 2019-08-25 20:58:57 RateLimiterThread-10获取到了令牌,时间 = 2019-08-25 20:58:58 RateLimiterThread-14获取到了令牌,时间 = 2019-08-25 20:58:58 RateLimiterThread-18获取到了令牌,时间 = 2019-08-25 20:58:58 RateLimiterThread-22获取到了令牌,时间 = 2019-08-25 20:58:58
看到,非常标准,在每次消耗一个令牌的情况下,RateLimiter可以保证每一秒内最多只有5个线程获取到令牌,使用这种方式可以很好的做单机对请求的QPS数控制。
至于为什么2019-08-25 20:58:53这个时间点只有1条线程获取到了令牌而不是有5条线程获取到令牌,因为RateLimiter是按照秒计数的,可能第一个线程是2019-08-25 20:58:53.999秒来的,算在2019-08-25 20:58:53这一秒内;下一个线程2019-08-25 20:58:54.001秒来,自然就算到2019-08-25 20:58:54这一秒去了。
上面的写法是RateLimiter最常用的写法,注意:
- acquire是阻塞的且会一直等待到获取令牌为止,它有一个返回值为double型,意思是从阻塞开始到获取到令牌的等待时间,单位为秒
- tryAcquire是另外一个方法,它可以指定超时时间,返回值为boolean型,即假设线程等待了指定时间后仍然没有获取到令牌,那么就会返回给客户端false,客户端根据自身情况是打回给前台错误还是定时重试
RateLimiter预消费
处理请求,每次来一个请求就acquire一把是RateLimiter最常见的用法,但是我们看acquire还有个acquire(int permits)的重载方法,即允许每次获取多个令牌数。这也是有可能的,请求数是一个大维度每次扣减1,有可能服务器按照字节数来进行限流,例如每秒最多处理10000字节的数据,那每次扣减的就不止1了。
接着我们再看一段代码示例:
@Test public void testRateLimiter2() { RateLimiter rateLimiter = RateLimiter.create(1); System.out.println("获取1个令牌开始,时间为" + FORMATTER.format(new Date())); double cost = rateLimiter.acquire(1); System.out.println("获取1个令牌结束,时间为" + FORMATTER.format(new Date()) + ", 耗时" + cost + "ms"); System.out.println("获取5个令牌开始,时间为" + FORMATTER.format(new Date())); cost = rateLimiter.acquire(5); System.out.println("获取5个令牌结束,时间为" + FORMATTER.format(new Date()) + ", 耗时" + cost + "ms"); System.out.println("获取3个令牌开始,时间为" + FORMATTER.format(new Date())); cost = rateLimiter.acquire(3); System.out.println("获取3个令牌结束,时间为" + FORMATTER.format(new Date()) + ", 耗时" + cost + "ms"); }
代码运行结果为:
获取1个令牌开始,时间为2019-08-25 21:21:09.973 获取1个令牌结束,时间为2019-08-25 21:21:09.976, 耗时0.0ms 获取5个令牌开始,时间为2019-08-25 21:21:09.976 获取5个令牌结束,时间为2019-08-25 21:21:10.974, 耗时0.997237ms 获取3个令牌开始,时间为2019-08-25 21:21:10.976 获取3个令牌结束,时间为2019-08-25 21:21:15.974, 耗时4.996529ms
看到这就是标题所说的预消费能力,也是RateLimiter中允许一定程度突发流量的实现方式。第二次需要获取5个令牌,指定的是每秒放1个令牌到桶中,我们发现实际上并没有等5秒钟等桶中积累了5个令牌才能让第二次acquire成功,而是直接等了1秒钟就成功了。我们可以捋一捋这个逻辑:
- 第一次请求过来需要获取1个令牌,直接拿到
- RateLimiter在1秒钟后放一个令牌,第一次请求预支的1个令牌还上了
- 1秒钟之后第二次请求过来需要获得5个令牌,直接拿到
- RateLimiter在花了5秒钟放了5个令牌,还上了第二次请求预支的5个令牌
- 第三个请求在5秒钟之后拿到3个令牌
也就是说,前面的请求如果流量大于每秒放置令牌的数量,那么允许处理,但是带来的结果就是后面的请求延后处理,从而在整体上达到一个平衡整体处理速率的效果。
突发流量的处理,在令牌桶算法中有两种方式,一种是有足够的令牌才能消费,一种是先消费后还令牌。后者就像我们0首付买车似的,30万的车很少有等攒到30万才全款买的,先签了相关合同把车子给你,然后贷款慢慢还,这样就爽了。RateLimiter也是同样的道理,先让请求得到处理,再慢慢还上预支的令牌,客户端同样也爽了,否则我假设预支60个令牌,1分钟之后才能处理我的请求,不合理也不人性化。
RateLimiter的限制
特别注意RateLimiter是单机的,也就是说它无法跨JVM使用,设置的1000QPS,那也在单机中保证平均1000QPS的流量。
假设集群中部署了10台服务器,想要保证集群1000QPS的接口调用量,那么RateLimiter就不适用了,集群流控最常见的方法是使用强大的Redis:
- 一种是固定窗口的计数,例如当前是2019/8/26 20:05:00,就往这个"2019/8/26 20:05:00"这个key进行incr,当前是2019/8/26 20:05:01,就往"2019/8/26 20:05:01"这个key进行incr,incr后的结果只要大于我们设定的值,那么就打回去,小于就相当于获取到了执行权限
- 一种是结合lua脚本,实现分布式的令牌桶算法,网上实现还是比较多的,可以参考https://blog.csdn.net/sunlihuo/article/details/79700225这篇文章
总得来说,集群限流的实现也比较简单。
总结
本文主要写了常见的两种限流算法漏桶算法与令牌桶算法,并且演示了Guava中RateLimiter的实现,相信看到这里的朋友一定都懂了,恭喜你们!
令牌桶算法是最常用的限流算法,它最大的特点就是容许一定程度的突发流量。
漏桶算法同样也有自己的应用之处,例如Nginx的限流模块就是基于漏桶算法的,它最大的特点就是强行限制流量按照指定的比例下发,适合那种对流量有绝对要求的场景,就是流量可以容许在我指定的值之下,可以被多次打回,但是无论如何决不能超过指定的。
虽然令牌桶算法相对更好,但是还是我经常说的,使用哪种完全就看大家各自的场景,适合的才是最好的。
Guava RateLimiter + AOP注解实现单机限流
1、基于springboot项目pom.xml添加如下依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>18.0</version> </dependency> <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-redis</artifactId> </dependency>
2、创建自定义运行时注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface LxRateLimit { /** * * @return */ String value() default ""; /** * 每秒向桶中放入令牌的数量 默认最大即不做限流 * @return */ double perSecond() default Double.MAX_VALUE; /** * 获取令牌的等待时间 默认0 * @return */ int timeOut() default 0; /** * 超时时间单位 * @return */ TimeUnit timeOutUnit() default TimeUnit.MILLISECONDS; }
3、创建aop切面进行环绕通知:
@Aspect @Component public class LxRateLimitAspect { private final static Logger logger = LoggerFactory.getLogger(LxRateLimitAspect.class); private RateLimiter rateLimiter = RateLimiter.create(Double.MAX_VALUE); /** * 定义切点 * 1、通过扫包切入 * 2、带有指定注解切入 */ // @Pointcut("execution(public * com.ycn.springcloud.*.*(..))") @Pointcut("@annotation(com.ycn.springcloud.annotation.LxRateLimit)") public void checkPointcut() { } @ResponseBody @Around(value = "checkPointcut()") public Object aroundNotice(ProceedingJoinPoint pjp) throws Throwable { logger.info("拦截到了{}方法...", pjp.getSignature().getName()); Signature signature = pjp.getSignature(); MethodSignature methodSignature = (MethodSignature)signature; //获取目标方法 Method targetMethod = methodSignature.getMethod(); if (targetMethod.isAnnotationPresent(LxRateLimit.class)) { //获取目标方法的@LxRateLimit注解 LxRateLimit lxRateLimit = targetMethod.getAnnotation(LxRateLimit.class); rateLimiter.setRate(lxRateLimit.perSecond()); if (!rateLimiter.tryAcquire(lxRateLimit.timeOut(), lxRateLimit.timeOutUnit())) return "服务器繁忙,请稍后再试!"; } return pjp.proceed(); } }
在ctroller中使用自定义注解
@RequestMapping("/testAnnotation") @LxRateLimit(perSecond = 1.0, timeOut = 500) public String testAnnotation() { return "get token success"; }
当接口QPS大于1的时候就会返回 “服务器繁忙,请稍后再试!”
Redis+Lua实现限流
分布式限流最关键的是要将限流服务做成原子化,而解决方案可以使使用redis+lua或者nginx+lua技术进行实现,通过这两种技术可以实现的高并发和高性能。
首先我们来使用redis+lua实现时间窗内某个接口的请求数限流(存在突刺效应),实现了该功能后可以改造为限流总并发/请求数和限制总资源数。Lua本身就是一种编程语言,也可以使用它实现复杂的令牌桶或漏桶算法。
如下操作因是在一个lua脚本中(相当于原子操作),又因Redis是单线程模型,因此是线程安全的。
相比Redis事务来说,Lua脚本有以下优点
减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, 减少网络传输;
原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务;
复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用.
Lua脚本
local key = KEYS[1] --限流KEY(一秒一个) local limit = tonumber(ARGV[1]) --限流大小 local current = tonumber(redis.call('get', key) or "0") if current + 1 > limit then --如果超出限流大小 return 0 else --请求数+1,并设置2秒过期 redis.call("INCRBY", key,"1") redis.call("expire", key,"2") end return 1
java代码
import org.apache.commons.io.FileUtils; import redis.clients.jedis.Jedis; import java.io.File; import java.io.IOException; import java.net.URISyntaxException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; public class RedisLimitRateWithLUA { public static void main(String[] args) { final CountDownLatch latch = new CountDownLatch(1); for (int i = 0; i < 7; i++) { new Thread(new Runnable() { public void run() { try { latch.await(); System.out.println("请求是否被执行:"+accquire()); } catch (Exception e) { e.printStackTrace(); } } }).start(); } latch.countDown(); } public static boolean accquire() throws IOException, URISyntaxException { Jedis jedis = new Jedis("127.0.0.1"); File luaFile = new File(RedisLimitRateWithLUA.class.getResource("/").toURI().getPath() + "limit.lua"); String luaScript = FileUtils.readFileToString(luaFile); String key = "ip:" + System.currentTimeMillis()/1000; // 当前秒 String limit = "5"; // 最大限制 List<String> keys = new ArrayList<String>(); keys.add(key); List<String> args = new ArrayList<String>(); args.add(limit); Long result = (Long)(jedis.eval(luaScript, keys, args)); // 执行lua脚本,传入参数 return result == 1; } }
运行结果
请求是否被执行:true
请求是否被执行:true
请求是否被执行:false
请求是否被执行:true
请求是否被执行:true
请求是否被执行:true
请求是否被执行:false
从结果可看出只有5个请求成功执行
IP限流Lua脚本
local key = "rate.limit:" .. KEYS[1] local limit = tonumber(ARGV[1]) local expire_time = ARGV[2] local is_exists = redis.call("EXISTS", key) if is_exists == 1 then if redis.call("INCR", key) > limit then return 0 else return 1 end else redis.call("SET", key, 1) redis.call("EXPIRE", key, expire_time) return 1 end