浅谈限流策略

版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/ustczyb/article/details/79619182

1.背景

最近负责了两个接口层的项目,一个对接的是公司内部的爬虫,另一个对接的是公司外部的中航信。虽然接口层不涉及过多的业务,但是想把接口层做好也并不是那么容易的。遇到的一个很大的问题就是限流。数据组的同事经常反馈说我们的调用量在某一时间内过于频繁,导致抓取服务压力过大,而且频繁的请求有IP被封的危险。因此限流对那些下游是抓取的(或者下游有限流策略的)服务来说十分重要。

在谈限流策略前,我们首先明确一下什么是限流。这里说的限流并不具有一般性,只是针对面临到的业务场景。我们约定,本文中的限流就是限制一段时间内(多为秒级,这个时间在下文中也会被称为时间窗)的请求次数。例如,我们下游服务能承受的最大TPS为2,因此我们希望控制请求量每秒不超过两个。

(还有一点需要说明的就是TPS和QPS的区别,这一点在网上查了很多但是一直没有找到满意的回答,大多是针对浏览器端访问页面的,不适用我们的接口层。我个人认为,TPS是每秒事务数,可以认为是服务器每秒处理的请求数,QPS呢是每秒查询数。乍一看好像这两个一样,但是如果对方服务端也有限流策略就不同了。假设我们下游服务给我们提供的TPS是10,我们一秒发了20个请求过去,结果对方这1秒把所有请求都返回了,但是只处理了10个,多出来的10个快速失败。那么我们从流量统计上看QPS是20,但是实际处理的事务数(TPS)是10。以上均为个人观点,如有不正确指出还请指出

下面介绍三种限流方式(这里只考虑限流,至于被限流后如何处理,是简单的丢弃还是将其加入重试队列,需要根据业务自行决定,这些暂不列入讨论范围内):

2.限流策略

2.1 信号量

信号量是一种锁,严格意义上说它并不能够做到限流,只能限制并发数。适用于对上游服务的请求限流,但是无法对下游服务限流,尤其是下游服务已经有了限流策略的时候。

// 初始化
Semaphore semaphore = new Semaphore(limit);

// 获取执行权
public boolean acquire() {
    return semaphore.tryAcquire();
}
// 释放执行权
public void release() {
    return semaphore.release();
}

在对接中航信的服务时,对方给我们提供的最大TPS是10,我们一开始使用的是信号量的方式进行限流。但是发现有大量的请求被航信限流,后来降低信号量阈值,一直降低到6还是有大量请求被航信限流。后来猜测航信使用的限流策略是基于时间窗口的,1s内最多处理10个请求,即使一个时间窗口内只用前0.5s就处理完了10个请求,那么时间窗的后0.5s对所有请求返回失败,而且是快速失败,由于信号量只能限制并发量而不是单位时间的请求量,因此在这个时间窗的后0.5s内仍然有请求发送,这样就可以解释请求中有大量失败的现象了。后来和航信技术部门沟通果然是这样。举这个例子是想说明信号量并不契合限流这种场景,使用需谨慎。

2.2 guava的RateLimiter

2.2.1 RateLimiter简介

rateLimiter是基于令牌桶限流算法的限流组件(令牌桶算法也就是每经过一段时间生成一些令牌放到桶里,每次执行从桶里获取若干个令牌)。它可以限制单机每秒访问数。而且在获取不到执行权时可以等待一段时间。

// 初始化
RateLimiter rateLimiter = RateLimiter.create(10);
// 获取执行权
public boolean acquire(String type) {
    return rateLimiter.tryAcquire();
}

不过guava的RateLimiter有两个需要注意的点:

  • guava的令牌桶的生成单位不是秒,而是令牌。什么意思呢,举个例子,假设设置令牌桶的阈值为一秒5个请求,那么guava的令牌桶是每0.2s生产一个令牌(令牌为单位),而不是每秒生产5个令牌(秒为单位)。这样做会使得流量更加平滑,平滑的粒度已经到了毫秒级别,但是,假设我们的请求时每1s的前0.1s进入5个请求,后面0.9s没有请求进入,那么这种限流策略就会有大量的失败(关于这种情况的处理我们后面会提到)。因此这种方式并不是很契合我们在前面提到的限流。因此会出现每秒请求数小于阈值但还是有失败的情况(这种情况如何避免,见下文)。
  • guava可以在获取在未获取到执行权的时候等待一段时间。见下面的例子
RateLimiter rateLimiter = RateLimiter.create(5);
for(int i = 0; i < 10; i++) {
    System.out.println(rateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS));
}

我们可以猜测一下这段代码的执行结果是什么样子的,正常情况下大家可能会猜测输出5个true和5个false交替出现,执行完的时间为1s,但是实际上只有第一个请求为true,其他的9个请求全为false,执行时间非常快,快到我们的请求根本就没有等待。后来看guava的注释才知道如果等待时间内不会有令牌产生则不会等待,而是直接快速失败。

Acquires a permit from this {@code RateLimiter} if it can be obtained without exceeding the specified {@code timeout}, or returns {@code false} immediately (without waiting) if the permit would not have been granted before the timeout expired.

2.2.2 rateLimiter的核心方法实现简介

这个方法实现过程中,关键信息是下面三个:

storedPermits 当前令牌桶中的剩余令牌数
freshPermits 需要新分配的令牌数
nextFreeTicketMicros 可以生产新令牌的时间(这里的新指的是没有被占用,因为ratelimiter允许预支未生成的令牌)
流程:

当执行tryAcquire方法时,首先判断超时时间内能否等到新令牌生产(因为存在令牌被预支的情况),如果不行那么快速返回false,否则开始计算等待时间
等待时间即为当前时间和nextFreeTicketMicros的差,这里可以看出等待时间并不是生产所有令牌的时间。假设我们的请求需要10个令牌,等待时间即为当前时间与生产出的第一个可用的令牌的时间差
计算出等待时间后,更新storedPermits和nextFreeTicketMicros的值
在使用过程中,我们一般调用的permit值为1,因此就称为了平滑限流。为了避免前面提到的的平滑限流导致限流失败的情况,我们一定要传入时间窗口。即避免使用RateLimiter#tryAcquire()方法,而是用带参数的RateLimiter#tryAcquire(long timeout, TimeUnit unit)方法,并且传入的超时时间不得小于时间窗的时间。

这个方法实现过程中,关键信息是下面三个:

  • storedPermits 当前令牌桶中的剩余令牌数
  • freshPermits 需要新分配的令牌数
  • nextFreeTicketMicros 可以生产新令牌的时间(这里的新指的是没有被占用,因为ratelimiter允许预支未生成的令牌)

流程:
1. 当执行tryAcquire方法时,首先判断超时时间内能否等到新令牌生产(因为存在令牌被预支的情况),如果不行那么快速返回false,否则开始计算等待时间
等待时间即为当前时间和nextFreeTicketMicros的差,这里可以看出等待时间并不是生产所有令牌的时间。假设我们的请求需要10个令牌,等待时间即为当前时间与生产出的第一个可用的令牌的时间差
2. 计算出等待时间后,更新storedPermits和nextFreeTicketMicros的值
在使用过程中,我们一般调用的permit值为1,因此就称为了平滑限流。为了避免前面提到的的平滑限流导致限流失败的情况,我们一定要传入时间窗口。即避免使用RateLimiter#tryAcquire()方法,而是用带参数的RateLimiter#tryAcquire(long timeout, TimeUnit unit)方法,并且传入的超时时间不得小于时间窗的时间。

2.3 redis

使用redis限流很简单,由于squirel的incrBy方法是原子的。因此我们可以申请一个失效时间为1s的category,然后使用下面的方法进行限流,非常简单。

public boolean acquire(String key, long limit) {
    StoreKey storeKey = new StoreKey(categoryName, key);
    return storeClient.incrBy(storeKey, 1) <= limit;
}

如果我们的时间窗口不是1s,我们可以添加一行判断来设置失效时间:

public boolean acquire(String key, long limit) {
    StoreKey storeKey = new StoreKey(categoryName, key);
    long count = storeClient.incrBy(storeKey, 1);
    if (count == 1) {
        // 设置失效时间 如果并发量很大在incrBy和expire操作之间有较多请求进入会使得限流不准确,最好的方式还是将这两个操作合并为一个原子操作的在redis中执行
        storeClient.expire(storeKey, invalid_time);
    }
    return count <= limit;
}

redis并不支持失败后的额外处理,例如重试或者等待。如果需要我们要自己实现这些策略,例如借助guava的retryer来实现这些功能。

3. 非平滑时间窗限流的问题

我们设想如下情景。假设我们需要对接的下游业务方对我们的请求进行了限流,阈值为10次/s(非平滑),超过阈值则快速失败。为此我们在已经实现了10次/s的限流策略(使用redis)。那么我们是否就能够保证向业务方发送的请求都能成功了呢,答案是否定的。事实上出现最坏情况时平均每秒仍然会有5个请求被限流。(可以思考下什么时候会出现这种情况以及为什么不会有大于5个的请求被限流)

之所以会出现这种最坏情况是因为我们虽然实现了秒级的限流,但是在这1s的时间内请求并不是平滑的。什么意思呢,有可能在1s时间窗口内的前0.1s同时进来10个请求,后0.9s没有任何请求进入。如下图:

这也就导致了最坏情况的出现,因此我们除了实现秒级限流外最好使1s内的请求平滑传递给下游服务,从而避免因为时间窗口不匹配导致的失败问题。

4. 几种限流策略的比较

限流策略 优点 缺点
信号量 简单 单机限流;不契合限流场景,尤其是对下游业务方限流;阈值在应用初始化时设定,不支持动态修改
RateLimiter 简单;支持失败后等待;可以使得时间窗内的请求平滑 仅限单机限流
redis 全局限流 强依赖redis;未获取到执行权直接返回失败,如果添加重试或等待策略较为复杂;时间窗内的请求不平滑

如果应用对限流精度不是太高,RateLimiter就可以满足多数需要。如果需要使用redis进行限流,那么需要考虑降级方案,以及等待重试机制等。

展开阅读全文

没有更多推荐了,返回首页