限流算法和RateLimiter的使用
在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流。限流通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。除此以外,限流还会有其他的原因,比如控制单个服务的流量、管理资源配额、避免费用超额等等。
References:
- 《实战Java高并发程序设计(第2版)》
- https://cloud.google.com/solutions/rate-limiting-strategies-techniques
- https://symfony.com/doc/current/rate_limiter.html
- https://en.wikipedia.org/wiki/Rate_limiting
- https://segmentfault.com/a/1190000016182737
- https://docs.hangfire.io/en/latest/background-processing/throttling.html
- http://antsnote.club/2019/05/21/Java-Guava%E5%B7%A5%E5%85%B7%E7%B1%BB%E4%B9%8B%E4%BD%BF%E7%94%A8RateLimiter%E8%BF%9B%E8%A1%8C%E9%99%90%E6%B5%81/
任何应用和模块组件都有一定的访问速率上限,如果请求速率突破了这个上限,不但多余的请求无法处理,甚至会压垮系统使所有的请求均无法有效处理。因此,对请求进行限流是非常必要的。在确定要强制执行速率限制,选择适合的限制方式就是重点了。
一般来说,速率是实例随时间出现次数的简单计数。不过,衡量和限制速率有几种不同的方式,每种方式都有自己的用途和影响。
- 固定时段(Fixed window)也译为固定窗口:固定时段限制(例如每小时 3000 个请求或每天 10 个请求)是一种简单的限流算法,当请求数量超过限制时,余下的请求丢弃或者等待。但这种简单的算法有一个严重的问题,就是很难控制边界时间上的请求。假设时间单位是1小时,限制每小时请求不超过4个。如果在这一小时的最后一刻有4个请求,下一小时的开始又有4个请求,那么在这中间的一小时内,就会处理8个请求,而这明显违反了限流的基本需求, 服务可能因此而过载。这是一种简单粗暴的总数量限流而不是平均限流,如下图所示
- 滑动时段(Sliding window)也译为滑动窗口:滑动时段具有固定时段的优势,但滚动时段可以尽可能消除突发问题, Redis等系统使用过期密钥来协助该方式。在固定时段中,整个时间线被划分为较大的固定间隔,而滑动时段将间隔划分为更小的bucket,一个滑动时段包含多个buckets,每个bucket都有时间戳和执行计数器,bucket越小限流越平滑, 但这也会消耗更多的资源,个人感觉还是没有从根本上解决临界突发问题。如下图所示
- 漏桶(Leaky bucket):利用一个缓存区,当有请求进入系统时,无论请求的速率如何,都先在缓存区内保存,然后以固定的流速流出缓存区进行处理,如下图所示。漏桶算法的特点是无论外部请求压力如何,漏桶算法总是以固定的流速处理数据。漏桶的容积和流出速率是该算法的两个重要参数。这种缓冲区容量概念(但不一定使用漏桶)也适用于服务附近的组件,例如负载平衡器和磁盘 I/O 缓冲区。
- 令牌桶(Token bucket):令牌桶算法是一种反向的漏桶算法。在令牌桶算法中,桶中存放的不再是请求,而是令牌。处理程序只有拿到令牌后,才能对请求进行处理。如果没有令牌,那么处理程序要么丢弃请求,要么等待可用的令牌。为了限制流速,该算法在每个单位时间产生一定量的令牌存入桶中。比如,要限定应用每秒只能处理1个请求,那么令牌桶就会每秒产生1个令牌。通常,桶的容量是有限的,比如,当令牌没有被消耗掉时,只能累计有限单位时间内的令牌数量。如下图所示
Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法实现流量限制,使用十分方便,而且十分高效。下面展示RateLimiter的使用方法:
import com.google.common.util.concurrent.RateLimiter;
public class TestRateLimiter {
public static void main(String[] args) {
// 限制RateLimiter每秒只能处理2个请求
RateLimiter limiter = RateLimiter.create(2);
for (int i = 0; i < 10; i++) {
// 获取令牌(控制流量)
limiter.acquire();
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " " + System.currentTimeMillis());
}, "Thread-" + i).start();
}
}
}
输出如下:
Thread-0 1615369434911
Thread-1 1615369435364
Thread-2 1615369435863
Thread-3 1615369436370
Thread-4 1615369436863
Thread-5 1615369437376
Thread-6 1615369437863
Thread-7 1615369438374
Thread-8 1615369438863
Thread-9 1615369439367
从输出的时间戳可以看到每秒之多两条记录,起到了流量控制的效果。当使用acquire()方法时,过多的流量调用会被block,直到获得令牌执行。但在有些场景中,如果系统无法处理请求,为了保证服务质量,更倾向于直接丢弃过载请求,从而避免可能的崩溃,此时,可以使用tryAcquire()方法,如下:
import com.google.common.util.concurrent.RateLimiter;
public class TestRateLimiter {
public static void main(String[] args) {
// 限制RateLimiter每秒只能处理2个请求
RateLimiter limiter = RateLimiter.create(2);
for (int i = 0; i < 10; i++) {
// 获取令牌(控制流量)
// limiter.acquire();
if (!limiter.tryAcquire()) {
continue;
}
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + " " + System.currentTimeMillis());
}, "Thread-" + i).start();
}
}
}
当请求获得令牌成功时,tryAcquire()方法返回true,否则返回false,该方法不会block,在本段代码中,如果访问数据量超过限制,超出部分直接丢弃,显然for循环的效率很高,完全可以在500ms内完成,因此本段代码最终只产生一个输出,其余请求全部丢弃。
Thread-0 1615369871827
tryAcquire()的重载方法还有可以设置超时时间的,在指定时间获取令牌,超过时间就获取失败。更多的用法可以查看文档。