前言
分布式环境下应对高并发保证服务稳定几招, 按照个人理解, 优先级从高到低分别为缓存, 限流, 降级, 熔断, 每招都有它的作用, 本文重点就讲讲限流这部分.
坦白讲, 其实上面的说法也不准确, 因为服务降级, 熔断本身也是限流的一种, 因为它们本质上也是阻断了流量进来, 但是本文希望大家可以把限流当做一个单纯的名词来理解, 看一下对请求做流控的几种算法及具体实现方式.
为什么要限流
其实很好理解的一个问题, 为什么要限流, 自然就流量过大了呗, 一个对外服务有很多场景都会流量增大:
业务用户量不断攀升
各种促销
网络爬虫
恶意刷单
注意这个 "大",1000QPS 大吗? 5000QPS 大吗? 10000QPS 大么? 没有答案, 因为没有标准, 因此,"大" 一定是和正常流量相比的大. 流量一大, 服务器扛不住, 扛不住就挂了, 挂了没法提供对外服务导致业务直接熔断. 怎么办, 最直接的办法就是从源头把流量限制下来, 例如服务器只有支撑 1000QPS 的处理能力, 那就每秒放 1000 个请求, 自然保证了服务器的稳定, 这就是限流.
下面看一下常见的两种限流算法.
漏桶算法
漏桶算法的原理比较简单, 水 (请求) 先进入到漏桶里, 人为设置一个最大出水速率, 漏桶以<= 出水速率的速度出水, 当水流入速度过大会直接溢出(拒绝服务):
因此, 这个算法的核心为:
存下请求
匀速处理
多于丢弃
因此这是一种强行限制请求速率的方式, 但是缺点非常明显, 主要有两点:
无法面对突发的大流量 ---- 比如请求处理速率为 1000, 容量为 5000, 来了一波 2000/s 的请求持续 10s, 那么后 5s 的请求将全部直接被丢弃, 服务器拒绝服务, 但是实际上网络中突发一波大流量尤其是短时间的大流量是非常正常的, 超过容量就拒绝, 非常简单粗暴
无法有效利用网络资源 ---- 比如虽然服务器的处理能力是 1000/s, 但这不是绝对的, 这个 1000 只是一个宏观服务器处理能力的数字, 实际上一共 5 秒, 每秒请求量分别为 1200,1300,1200,500,800, 平均下来 qps 也是 1000/s, 但是这个量对服务器来说完全是可以接受的, 但是因为限制了速率是 1000/s, 因此前面的三秒, 每秒只能处理掉 1000 个请求而一共打回了 700 个请求, 白白浪费了服务器资源
所以, 通常来说利用漏桶算法来限流, 实际场景下用得不多.
令牌桶算法
令牌桶算法是网络流量整形 (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 脚本, 实现分布式的令牌桶算法, 网上实现还是比较多的, 可以参考这篇文章
总得来说, 集群限流的实现也比较简单.
总结
本文主要写了常见的两种限流算法漏桶算法与令牌桶算法, 并且演示了 Guava 中 RateLimiter 的实现, 相信看到这里的朋友一定都懂了, 恭喜你们!
令牌桶算法是最常用的限流算法, 它最大的特点就是容许一定程度的突发流量.
漏桶算法同样也有自己的应用之处, 例如 Nginx 的限流模块就是基于漏桶算法的, 它最大的特点就是强行限制流量按照指定的比例下发, 适合那种对流量有绝对要求的场景, 就是流量可以容许在我指定的值之下, 可以被多次打回, 但是无论如何决不能超过指定的.
虽然令牌桶算法相对更好, 但是还是我经常说的, 使用哪种完全就看大家各自的场景, 适合的才是最好的.
来源: https://www.cnblogs.com/xrq730/p/11025029.html