限流算法和RateLimiter的使用

在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流。限流通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。除此以外,限流还会有其他的原因,比如控制单个服务的流量、管理资源配额、避免费用超额等等。

References:

任何应用和模块组件都有一定的访问速率上限,如果请求速率突破了这个上限,不但多余的请求无法处理,甚至会压垮系统使所有的请求均无法有效处理。因此,对请求进行限流是非常必要的。在确定要强制执行速率限制,选择适合的限制方式就是重点了。

一般来说,速率是实例随时间出现次数的简单计数。不过,衡量和限制速率有几种不同的方式,每种方式都有自己的用途和影响。

  • 固定时段(Fixed window)也译为固定窗口:固定时段限制(例如每小时 3000 个请求或每天 10 个请求)是一种简单的限流算法,当请求数量超过限制时,余下的请求丢弃或者等待。但这种简单的算法有一个严重的问题,就是很难控制边界时间上的请求。假设时间单位是1小时,限制每小时请求不超过4个。如果在这一小时的最后一刻有4个请求,下一小时的开始又有4个请求,那么在这中间的一小时内,就会处理8个请求,而这明显违反了限流的基本需求, 服务可能因此而过载。这是一种简单粗暴的总数量限流而不是平均限流,如下图所示
    fixed-window.png
  • 滑动时段(Sliding window)也译为滑动窗口:滑动时段具有固定时段的优势,但滚动时段可以尽可能消除突发问题, Redis等系统使用过期密钥来协助该方式。在固定时段中,整个时间线被划分为较大的固定间隔,而滑动时段将间隔划分为更小的bucket,一个滑动时段包含多个buckets,每个bucket都有时间戳和执行计数器,bucket越小限流越平滑, 但这也会消耗更多的资源,个人感觉还是没有从根本上解决临界突发问题。如下图所示
    sliding-window.png
  • 漏桶(Leaky bucket):利用一个缓存区,当有请求进入系统时,无论请求的速率如何,都先在缓存区内保存,然后以固定的流速流出缓存区进行处理,如下图所示。漏桶算法的特点是无论外部请求压力如何,漏桶算法总是以固定的流速处理数据。漏桶的容积和流出速率是该算法的两个重要参数。这种缓冲区容量概念(但不一定使用漏桶)也适用于服务附近的组件,例如负载平衡器和磁盘 I/O 缓冲区。
    leaky-bucket.png
  • 令牌桶(Token bucket):令牌桶算法是一种反向的漏桶算法。在令牌桶算法中,桶中存放的不再是请求,而是令牌。处理程序只有拿到令牌后,才能对请求进行处理。如果没有令牌,那么处理程序要么丢弃请求,要么等待可用的令牌。为了限制流速,该算法在每个单位时间产生一定量的令牌存入桶中。比如,要限定应用每秒只能处理1个请求,那么令牌桶就会每秒产生1个令牌。通常,桶的容量是有限的,比如,当令牌没有被消耗掉时,只能累计有限单位时间内的令牌数量。如下图所示
    token_bucket.png

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()的重载方法还有可以设置超时时间的,在指定时间获取令牌,超过时间就获取失败。更多的用法可以查看文档。

标签: none

添加新评论

ali-01.gifali-58.gifali-09.gifali-23.gifali-04.gifali-46.gifali-57.gifali-22.gifali-38.gifali-13.gifali-10.gifali-34.gifali-06.gifali-37.gifali-42.gifali-35.gifali-12.gifali-30.gifali-16.gifali-54.gifali-55.gifali-59.gif

加载中……