使用 Guava 中 RateLimiter 遇到的一个问题

线上业务使用了 Guava 中的 RateLimiter 来做单机的限流。最近运维同事反馈,在修改了限流的配置之后,请求频率没有超过限流的配置却触发了限流逻辑,在此对该问题进行一下分析与总结。

问题模拟

根据上述问题的现象,初步推测问题可能跟 Guava 中 RateLimiter 的限流算法实现有关,为了方便排查问题,参考线上业务的限流的逻辑写了一个 demo,其核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@RestController
@RequestMapping("/hello")
public class HelloController {

/** 用于存储不同的接口,对应的 RateLimiter **/
private static Map<String, RateLimiter> RATE_LIMITER_MAP = Maps.newConcurrentMap();
/** 用于存储不同接口限流配置的参数 **/
private static Map<String, String> RATE_MAP = Maps.newConcurrentMap();

static {
// 初始化一个接口默认的限流配置
RATE_MAP.put("loan", "100000");
}

/**
* 测试限流的接口
* @param key 限流配置的 key
*/
@GetMapping("/testLimit")
public String testLimit(String key) {
String value = RATE_MAP.get(key);
logger.info("自定义限流配置={}", value);
Double currentRate = Double.parseDouble(value);

RateLimiter rateLimiter = RATE_LIMITER_MAP.get(key);
if (rateLimiter == null || !isSameRate(currentRate, rateLimiter.getRate())) {
logger.info("创建新的RateLimiter|速率={}", currentRate.toString());
rateLimiter = RateLimiter.create(currentRate);
RATE_LIMITER_MAP.putIfAbsent(key, rateLimiter);
}
boolean allowed = rateLimiter.tryAcquire();
logger.info("是否允许当前请求通过={}|rate={}", allowed, rateLimiter.getRate());
return allowed ? "SUCCESS" : "FAIL";
}

/**
* 模拟修改限流配置
* @param key 限流配置的key
* @param rate 限流的值
*/
@GetMapping("/setRate")
public String setRate(String key, String rate) {
RATE_MAP.put(key, rate);
String currentValue = RATE_MAP.get(key);
logger.info("设置完之后的值={}", currentValue);
return "SUCCESS";
}

}

启动工程,我们模拟一下出现问题时的操作。

  1. 构造 HTTP 请求调用 /setRate 接口,将 loan 对应的限流的值修改为 5;
  2. 构造 HTTP 请求快速调用 /testLimit 接口 2 次,发现第一调用接口返回 SUCCESS, 第二次接口返回 FAIL,说明第 2 次请求触发了限流;
  3. 构造 HTTP 请求调用 /testLimit 5 次,保证 5 次请求在 1 秒内接收,发现这 5 次请求接口均返回 SUCCESS,说明这 5 次请求均未触发限流;
  4. 构造 HTTP 请求调用 /testLimit 6 次,保证 6 次请求均在 1 秒内接收,发现前 6 次请求接口均返回 SUCCESS,说明这 6 次请求均未触发限流;
  5. 构造 HTTP 请求调用 /testLimit 7 次,保证 7 次请求均在 1 秒内接收,发发现前 6 次请求接口均返回 SUCCESS,第 7 次请求接口返回 FAIL,说明前 6 次请求均未触发限流,第 7 次请求触发限流。

至此,我们模拟的现象与线上一致。步骤 3 是我们预期的结果,步骤 2 中在未超过限流配置的情况下触发了限流,步骤 4、5 中的第 6 次请求在超过了限流配置的情况下,仍然允许通过,我们直观上认为这些是不合理的。

排查与分析

Guava 中 RateLimiter 提供了两种令牌桶的算法实现:

  • 平滑突发限流(SmoothBursty)
  • 平滑预热限流(SmoothWarmingUp)

我们项目中使用了默认的平滑突发限流(SmoothBursty)算法,这里我们只针对该算法进行分析。

tryAcquire 方法

其源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {
long timeoutMicros = max(unit.toMicros(timeout), 0);
checkPermits(permits);
long microsToWait;
synchronized (mutex()) {
long nowMicros = stopwatch.readMicros();
if (!canAcquire(nowMicros, timeoutMicros)) {
return false;
} else {
microsToWait = reserveAndGetWaitLength(permits, nowMicros);
}
}
stopwatch.sleepMicrosUninterruptibly(microsToWait);
return true;
}

可以观察到只有在 canAcquire 方法返回 false 的情况下,才会返回 false,通过阅读 canAcquire 方法可知该方法跟 SmoothBursty 中的成员变量 nextFreeTicketMicros 有关,该成员变量的释义如下:

The time when the next request (no matter its size) will be granted. After granting a request, this is pushed further in the future. Large requests push this further than small requests.

该成员变量标识下一次请求获得令牌的最早时间,在创建 SmoothBursty 时初始值为 0,并且每次授权一个请求之后,这个值会向后推移。查找该成员变量引用的地方,发现 SmoothBursty 类中 reserveEarliestAvailable、resync 方法会触发该成员变量的更新,并且观察该方法的调用栈,其中就有 RateLimiter#tryAcquire() 方法。

reserveEarliestAvailable、resync 方法

其源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* @param requiredPermits 表示我们该次请求所取得令牌数,我们这里值为 1
* @param nowMicros 距创建 RateLimiter 时经过了多少时长,单位为微秒
*/
@Override
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
// 刷新令牌数
resync(nowMicros);
long returnValue = nextFreeTicketMicros;
// storedPermits 是创建 RataLimiter 时的一个成员变量,表示当前存储的令牌数,在 resync 方法和本方法中会更新所存储的令牌数
// 当前存储的令牌数跟需要获取的令牌数进行比较,取两者的的最小值,即为当前可以得到令牌数
double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
// 需要预先支付的令牌
double freshPermits = requiredPermits - storedPermitsToSpend;
// 在突然涌入大量请求,而现有的令牌数又不够用的情况下,会预先支付一定的令牌数
// stableIntervalMicros 为创建 RateLimiter 时计算出的稳定时间间隔,其计算公式为 周期 / 令牌数,在我们的例子中,该值即为 200000 微妙(即 200ms)
// waitMicros 表示预先支付令牌的时间
long waitMicros =
storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
+ (long) (freshPermits * stableIntervalMicros);
// 预先支付令牌的时间+原请求获取令牌的最早时间,即为下一次获取令牌的最早时间
this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
// 所存储的令牌数需要减去本次消耗的令牌
this.storedPermits -= storedPermitsToSpend;
// 这里返回旧的获取令牌最早的时间,即本次请求无需为预支付的令牌付出额外的等待时间
return returnValue;
}

void resync(long nowMicros) {
// if nextFreeTicket is in the past, resync to now
if (nowMicros > nextFreeTicketMicros) {
// 在 SmoothBursty 类中 coolDownIntervalMicros() 方法返回的是成员变量 stableIntervalMicros,在我们的例子中,该值即为 200000 微妙(即 200ms)
double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
// maxPermits 在 创建 RataLimiter 时的一个成员变量,表示所能存储的最大令牌数,在我们的例子中,该值为 5
// 更新当前所存储的令牌数,取值为 原存储的令牌数与新产生的令牌数之和 与 最大存储令牌数 两个数值中较小的值,保证可用的令牌数不会大于所允许存储的最大令牌数
storedPermits = min(maxPermits, storedPermits + newPermits);
nextFreeTicketMicros = nowMicros;
}
}

在 reserveEarliestAvailable 方法结尾处打断点,来观察一下修改配置之后,第一次调用该方法时对 nextFreeTicketMicros 成员变量的设值情况。如下图:

通过上图中的执行情况可知,在限流配置修改为 5TPS 的情况下,第一次请求进来之后,令牌桶内所存储的令牌桶还不足 1,发生了预支付令牌的情况,此时 nextFreeTicketMicros 成员变量会被赋值为约 200000 微妙(即 200ms),如果第二个请求是在第一个请求之后 200ms 内请求进来的,一定会触发限流逻辑,如下图:

同样的我们提上面的操作中 1 秒内的连续 6 次请求,第 6 次仍未触发限流,这种现象也是由于预支付令牌产生的。在一段时间内没有请求进来的情况下,令牌桶内累计了 5 个令牌,此时前 5 个令牌会被前 5 个请求消费掉,当第 6 个请求进来的时候,时间仍然在向前推进,只是此时桶内累计的令牌数不足 1 个,会触发预先支付令牌,故这种情况下第 6 个请求,仍然是能够通过的。而当第 7 个请求进来的时候,由于下一次请求获得令牌的最早时间已经大于当前时间了,故取令牌失败,触发限流,如下图:

结论

通过以上源码的分析,Gauava 中 RateLimiter 的平滑突发限流(SmoothBursty)是通过以固定的速率向桶内放置令牌,从而达到平滑输出的效果。由于桶内会进行令牌的积累,可以应对一定程度的流量突发。

对于我们线上遇到的修改配置之后第二个请求就触发了限流的问题,我们在切换配置时应该使用 RateLimiter 的 setRate 方法直接修改其限流配置,这样可以避免重新创建一个新的 RateLimiter 时会创建一个新的跑表(Stopwatch),同时也不会将 nextFreeTicketMicros 重新置为 0,使得在取令牌时对桶内令牌数的计算不会像重新创建一个那么极端。

发散

大家也可以看一下 Guava 中平滑预热限流(SmoothWarmingUp)在实现上与的平滑突发限流(SmoothBursty)有什么区别,此外常见的其他限流算法如漏桶算法、计数法分别是怎么实现的。