大型网站系统主要的挑战是庞大的用户流量和高并发的访问,尤其是在一些大促场景下的会流量突增,如果不对这些流量加以管控,任由流量放大冲击系统,将会导致一系列恶劣的问题,例如一些可用的线程资源、连接资源被耗尽,分布式缓存容量被爆掉等,有可能会引起整个系统的雪崩。 当然普通网站系统在出现大量恶意流量和攻击时也会这些问题。

大型网站系统应对大流量和高并发的常见手段包括:扩容、动静分离、缓存、降级、限流等。这些手段对我们来说并不陌生,本篇我们来一起关注一下限流。

限流的目的

任何系统的容量都有上限,如果出现流量过载,系统的吞吐量就会开始下降,最终有可能爆掉系统的容量,出现一系列连锁反应,引起系统雪崩。 限流的目的就是为了防止流量超出系统的容量引起系统雪崩。前面列举了扩容、动静分离、缓存、降级等手段都可以应对大流量和高并发,为什么还要考虑使用限流呢? 原因是有些场景并适合使用前面那些手段来解决。

常见的限流算法

简单的描述限流就是对并发请求进行限制或对一个时间窗内的请求进行限速来保护系统,当请求到达限制时将会被拒绝或降级。 其实不管我们过去是否关注过限流,几乎都和限流打过交道,例如我们在开发中常用的各种资源池技术(线程池、数据库连接池、对象池)本质上都是限制总并发数,实际上就是通过计数器算法的限流。 除了计数器的方式,常见的限流算法还有以下两种:令牌桶算法(Token Bucket)和漏桶算法(Leaky Bucket)。

令牌桶算法(Token Bucket)

令牌桶算法主要用来限制流量的平均流入速率。令牌桶的基本过程简单描述如下:

  • 每秒有r个令牌放入桶中,每个1/r秒有一个令牌被放入桶中
  • 桶的容量为b,即桶中最多放入b个令牌,如果桶已经满了再放入新的令牌就会被丢弃
  • 当n个字节的数据包到达时,需要从桶中消耗n个令牌,然后再发送该数据包
  • 如果桶中令牌数小于n,那么将不会从桶中删除令牌,并且该数据包将会被执行限流处理,会被抛弃或被放入缓冲区

令牌桶允许出现一定程度的突发流量,允许最长b个字节的突发,但从长期运行结果看,数据包的速率被限制成常量r。

漏桶算法(Leaky Bucket)

漏桶算法可以高效的实现流量控制,漏桶算法的基本过程简单描述如下:

  • 可以以任意速率向桶中流入水滴
  • 桶的容量固定,如果桶满了则新流入的水滴将会被抛弃
  • 按照固定的速率从桶中流出水滴

令牌桶算法和漏桶算法都可以实现对流量的管控,两种算法的区别如下:

  • 令牌桶算法限制的是流量的平均流入速率,而且允许一定程度上的突发流量,当桶中的令牌数量不够时,请求将会被限流处理
  • 漏桶算法限制的流量的流出速率,而且这种流出速率保持固定不变,因此不允许像令牌桶算法那样出现突发流量,当流入的水滴超过桶的容量时,新的请求将会被限流处理。可以看出漏桶可以起到平滑流入速率

限流的实现

各种语言的开源项目都有相关限流算法的实现,例如我们如果要实现令牌桶算法那样的平均流入速率限流,在Java中可以使用Guava库提供的RateLimiter抽象类,在Go语言中可以使用github.com/juju/ratelimit。 下面简单介绍一下这两个库。

使用Guava的RateLimiter实现限流

 1// 桶容量为10,每秒向桶中放入10个Token,也就是说每隔100毫秒(0.1秒)才会有1个令牌被放到桶中
 2RateLimiter rateLimiter = RateLimiter.create(10);
 3for (int i = 0; i < 10; i++) {
 4	// 每个请求从桶中消耗一个Token
 5	double waitTime = rateLimiter.acquire();
 6	System.out.println(waitTime);
 7}
 8
 90.0
100.098359
110.097576
120.099045
130.099941
140.099184
150.099933
160.09983
170.098948
180.099605

上面的代码通过调用public static RateLimiter create(double permitsPerSecond)创建了一个RateLimiter实例,桶容量为10,每秒向桶中放入10个Token,也就是说每隔100毫秒(0.1秒)才会有1个令牌被放到桶中,后边的10次循环中每次循环表示一个请求消耗一个Token,可以看出请求也是每隔0.1秒处理一个,系统每秒允许的并发请求是10.

下面我们模拟一下突发流量的情况:

1// 桶容量为10,每秒向桶中放入10个Token,也就是说每隔100毫秒(0.1秒)才会有1个令牌被放到桶中
2RateLimiter rateLimiter = RateLimiter.create(10);
3// 模拟突发流量请求,并发10一次从桶中获取10个Token
4System.out.println(rateLimiter.acquire(10));
5// 每个请求从桶中消耗一个Token
6System.out.println(rateLimiter.acquire());
7
80.0
90.998662

从上面的代码输出可以看出突发的流量请求一次性透支消耗了桶中的令牌,新的请求将等待偿还完透支的令牌后才会被允许。

上面我们已经演示了Guava中的RateLimiter基本用法,更多内容可以查看RateLimiter文档和注释:

  • com.google.common.util.concurrent.RateLimiter.create(double permitsPerSecond, long warmupPeriod, TimeUnit unit)可以设置限流速率从慢速过度到平均速率的缓冲时间,即平滑预热的功能
  • 前面的例子我们使用rateLimiter.acquire(),当请求被执行限流后将一直处于等待状态,大多数情况下我们可能需要短暂等待或直接抛弃请求,此时可以使用rateLimiter.tryAcquire()
     1  // 尝试从桶中获取令牌,获取不到时不等待直接返回false
     2  boolean result = rateLimiter.tryAcquire();
     3  if (result) {
     4  	System.out.println("获取到令牌,执行逻辑");
     5  }else {
     6  	System.out.println("被限流丢弃");
     7  }
     8
     9  // 桶容量为10,每秒向桶中放入10个Token,也就是说每隔100毫秒(0.1秒)才会有1个令牌被放到桶中
    10  RateLimiter rateLimiter = RateLimiter.create(10);
    11  // 尝试从桶中获取令牌,获取不到时等待50毫秒后如果还获取不到返回false
    12  boolean result = rateLimiter.tryAcquire(50, TimeUnit.MILLISECONDS);
    13  if (result) {
    14  	System.out.println("获取到令牌,执行逻辑");
    15  }else {
    16  	System.out.println("被限流丢弃");
    17  }
    

Go语言基于Token Bucket的限流包

github.com/juju/ratelimit这个Golang的项目基于Token Bucket实现了限流。 使用方式也比较简单:

1// NewBucket returns a new token bucket that fills at the rate of one token every fillInterval, 
2// up to the given maximum capacity. Both arguments must be positive. The bucket is initially full.
3func NewBucket(fillInterval time.Duration, capacity int64) *Bucket
4
5// Wait takes count tokens from the bucket, waiting until they are available.
6func (tb *Bucket) Wait(count int64)

参考