高并发教程七:限流设计与实践

1,469 阅读14分钟

为什么需要限流

在应对秒杀,抢购等高并发压力的场景时,限流已经成为了标配技术解决方案,为保证系统的平稳运行起到了关键性的作用。不管应用场景是哪种,限流无非就是针对超过预期的流量,通过预先设定的限流规则选择性的对某些请求进行限流“熔断” 。通过限流,我们可以很好地控制系统的QPS,从而达到保护系统的目的

  1. 系统资源有限,承受能力有限,瞬时流量过高,系统容易拖慢、系统宕机、引起雪崩等
  2. 某个接口模块被恶意用户高频光顾,导致服务器宕机
  3. 消息消费过快,导致数据库压力过大,性能下降甚至崩溃

常见限流算法

计数器固定窗口算法

固定窗口又称固定窗口(又称计数器算法,Fixed Window)限流算法,是最简单的限流算法,通过在单位时间内维护的计数器来控制该时间单位内的最大访问量

WechatIMG2960.png

固定窗口最大的优点在于易于实现;并且内存占用小,我们只需要存储时间窗口中的计数即可;它能够确保处理更多的最新请求,不会因为旧请求的堆积导致新请求被饿死。当然也面临着临界问题,当两个窗口交界处,瞬时流量可能为2n

bad case

image.png 假设有一个恶意用户,他在 0:59 时,瞬间发送了 100 个请求,并且 1:00 又瞬间发送了 100 个请求,那么其实这个用户在 1 秒里面,瞬间发送了 200 个请求。我们刚才规定的是 1 分钟最多 100 个请求,也就是每秒钟最多 1.7 个请求,用户通过在时间窗口的重置节点处突发请求,可以瞬间超过我们的速率限制。用户有可能通过算法的这个漏洞,瞬间压垮我们的应用

单机限流

假设限制每秒请求量不超过30,设置一个计数器,当请求到达时如果计数器到达阈值,则拒绝请求,否则计数器加1;每分钟重置计数器为0。代码实现如下:

package com.lm.java.share.thread2.limit;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author lm
 * API在一分钟内只能固定被访问N次
 * @created 2021/7/7 下午3:12
 **/
public class CounterLimiter {
    private int windowSize; //窗口大小,毫秒为单位
    private int limit;//窗口内限流大小
    private AtomicInteger count;//当前窗口的计数器
    public CounterLimiter(int windowSize, int limit) {
        this.limit = limit;
        this.windowSize = windowSize;
        count = new AtomicInteger(0);
        //开启一个线程,达到窗口结束时清空count
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    try {
                        Thread.sleep(windowSize);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    count.set(0);
                }
            }
        }).start();
    }
    //请求到达后先调用本方法,若返回true,则请求通过,否则限流
    public synchronized boolean tryAcquire() {
        int newCount = count.addAndGet(1);
        if (newCount > limit) {
            return false;
        } else {
            return true;
        }
    }

    //测试
    public static void main(String[] args) throws InterruptedException {
        //每秒20个请求
        CounterLimiter counterLimiter = new CounterLimiter(1000, 30);
        int count = 0;
        //模拟50次请求,看多少能通过
        for (int i = 0; i < 50; i++) {
            if (counterLimiter.tryAcquire()) {
                count++;
            }
        }
        System.out.println("第一拨50次请求中通过:" + count + ",限流:" + (50 - count));
        Thread.sleep(2000);
        //模拟50次请求,看多少能通过
        count = 0;
        for (int i = 0; i < 50; i++) {
            if (counterLimiter.tryAcquire()) {
                count++;
            }
        }
        System.out.println("第二拨50次请求中通过:" + count + ",限流:" + (50 - count));
    }
}

结果:

第一拨50次请求中通过:30,限流:20
第二拨50次请求中通过:30,限流:20

分布式限流

一般分布式我们都是借助 Redis + Lua 来实现,放两个 Lua 脚本参考

  • 一个秒级限流(每秒限制多少请求)
  • 一个自定义参数限流(自定义多少时间限制多少请求)

秒级限流(每秒限制多少请求)

-- 实现原理
-- 每次请求都将当前时间,精确到秒作为 key 放入 Redis 中
-- 超时时间设置为 2s, Redis 将该 key 的值进行自增
-- 当达到阈值时返回错误,表示请求被限流
-- 写入 Redis 的操作用 Lua 脚本来完成
-- 利用 Redis 的单线程机制可以保证每个 Redis 请求的原子性

-- 资源唯一标志位
local key = KEYS[1]
-- 限流大小
local limit = tonumber(ARGV[1])

-- 获取当前流量大小
local currentLimit = tonumber(redis.call('get', key) or "0")

if currentLimit + 1 > limit then
    -- 达到限流大小 返回
    return 0;
else
    -- 没有达到阈值 value + 1
    redis.call("INCRBY", key, 1)
    -- 设置过期时间
    redis.call("EXPIRE", key, 2)
    return currentLimit + 1
end
/**
 * 秒级限流判断(每秒限制多少请求)
 */
public Long limit(String maxRequest) {
    // 获取key名,当前时间戳
    String key = LIMIT + String.valueOf(System.currentTimeMillis() / 1000);
    // 传入参数,限流最大请求数
    List<String> args = new ArrayList<>();
    args.add(maxRequest);
    return eval(getScript("redis/limit-seckill.lua"), Collections.singletonList(key), args);
}
/**
 * 执行Lua脚本方法
 */
private Long eval(String script, List<String> keys, List<String> args) {
    // 执行脚本
    Object result = JedisUtil.eval(script, keys, args);
    // 结果请求数大于0说明不被限流
    return (Long) result;
}
/**
 * 脚本执行
 */
public static Object eval(String script, List<String> keys, List<String> args) {
    Object result = null;
    try (Jedis jedis = jedisPool.getResource()) {
        result = jedis.eval(script, keys, args);
        return result;
    } catch (Exception e) {
        throw new CustomException("Redis脚本执行eval方法异常:script=" + script + " keys=" +
                keys.toString() + " args=" + args.toString() + " cause=" + e.getMessage());
    }
}

自定义参数限流(自定义多少时间限制多少请求)

-- 实现原理
-- 每次请求都去 Redis 取到当前限流开始时间和限流累计请求数
-- 判断限流开始时间加超时时间戳(限流时间)大于当前请求时间戳
-- 再判断当前时间窗口请求内是否超过限流最大请求数
-- 当达到阈值时返回错误,表示请求被限流,否则通过
-- 写入 Redis 的操作用 Lua 脚本来完成
-- 利用 Redis 的单线程机制可以保证每个 Redis 请求的原子性

-- 一个时间窗口开始时间(限流开始时间)key名称
local timeKey = KEYS[1]
-- 一个时间窗口内请求的数量累计(限流累计请求数)key名称
local requestKey = KEYS[2]
-- 限流大小,限流最大请求数
local maxRequest = tonumber(ARGV[1])
-- 当前请求时间戳
local nowTime = tonumber(ARGV[2])
-- 超时时间戳,一个时间窗口时间(毫秒)(限流时间)
local timeRequest = tonumber(ARGV[3])

-- 获取限流开始时间,不存在为0
local currentTime = tonumber(redis.call('get', timeKey) or "0")
-- 获取限流累计请求数,不存在为0
local currentRequest = tonumber(redis.call('get', requestKey) or "0")

-- 判断当前请求时间戳是不是在当前时间窗口中
-- 限流开始时间加超时时间戳(限流时间)大于当前请求时间戳
if currentTime + timeRequest > nowTime then
    -- 判断当前时间窗口请求内是否超过限流最大请求数
    if currentRequest + 1 > maxRequest then
        -- 在时间窗口内且超过限流最大请求数,返回
        return 0;
    else
        -- 在时间窗口内且请求数没超,请求数加一
        redis.call("INCRBY", requestKey, 1)
        return currentRequest + 1;
    end
else
    -- 超时后重置,开启一个新的时间窗口
    redis.call('set', timeKey, nowTime)
    redis.call('set', requestKey, '0')
    -- 设置过期时间
    redis.call("EXPIRE", timeKey, timeRequest / 1000)
    redis.call("EXPIRE", requestKey, timeRequest / 1000)
    -- 请求数加一
    redis.call("INCRBY", requestKey, 1)
    return 1;
end

计数器滑动窗口算法

为了防止瞬时流量,可以把固定窗口近一步划分成多个格子,每次向后移动一小格,而不是固定窗口大小,这就是滑动窗口(Sliding Window)。

比如每分钟可以分为6个10秒中的单元格,每个格子中分别维护一个计数器,窗口每次向前滑动一个单元格。每当请求到达时,只要窗口中所有单元格的计数总和不超过阈值都可以放行。TCP协议中数据包的传输,同样也是采用滑动窗口来进行流量控制。

image.png

滑动窗口解决了计数器中的瞬时流量高峰问题,其实计数器算法也是滑动窗口的一种,只不过窗口没有进行更细粒度单元的划分。对比计数器可见,当窗口划分的粒度越细,则流量控制更加精准和严格。

不过当窗口中流量到达阈值时,流量会瞬间切断,在实际应用中我们要的限流效果往往不是把流量一下子掐断,而是让流量平滑地进入系统当中。

单机实现

package com.lm.java.share.thread2.limit;

import com.sankuai.meituan.banma.biz.common.util.JsonUtils;

import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.Iterator;
import java.util.Map;
import java.util.Random;
import java.util.TreeMap;

/**
 * @author lm
 * @version 1.0
 * @desc CounterSildeWindowLimiter
 * @created 2021/7/7 下午4:37
 **/
public class CounterSildeWindowLimiter {
    /**
     * 每分钟限制请求数
     */
    private long permitsPerMinute;
    /**
     * 计数器, k-为当前窗口的开始时间值秒,value为当前窗口的计数
     */
    private final TreeMap<Long, Integer> counters;

    public CounterSildeWindowLimiter(long permitsPerMinute) {
        this.permitsPerMinute = permitsPerMinute;
        this.counters = new TreeMap<>();
    }

    public synchronized boolean tryAcquire() {
        // 获取当前时间的所在的子窗口值; 10s一个窗口
        long currentWindowTime =
                LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) / 10 * 10;
        // 获取当前窗口的请求总量
        int currentWindowCount = getCurrentWindowCount(currentWindowTime);
        if (currentWindowCount >= permitsPerMinute) {
            return false;
        }
        // 计数器 + 1
        counters.merge(currentWindowTime, 1, Integer::sum);
        return true;
    }

    /**
     * 获取当前窗口中的所有请求数(并删除所有无效的子窗口计数器)
     *
     * @param currentWindowTime 当前子窗口时间
     * @return 当前窗口中的计数
     */
    private int getCurrentWindowCount(long currentWindowTime) {
        // 计算出窗口的开始位置时间
        long startTime = currentWindowTime - 60;
        int result = 0;
        // 遍历当前存储的计数器,删除无效的子窗口计数器,并累加当前窗口中的所有计数器之和
        Iterator<Map.Entry<Long, Integer>> iterator =
                counters.entrySet().iterator();
        while (iterator.hasNext()) {
            Map.Entry<Long, Integer> entry = iterator.next();
            if (entry.getKey() < startTime) {
                iterator.remove();
            } else {
                result += entry.getValue();
            }
        }
        System.out.println(JsonUtils.encode(counters));
        return result;
    }
    public static void main(String[] args) throws InterruptedException {
        CounterSildeWindowLimiter counterSildeWindowLimiter =
                new CounterSildeWindowLimiter(10);
        for (int i = 0; i < Integer.MAX_VALUE; i++) {
            if (counterSildeWindowLimiter.tryAcquire()) {
                System.out.println(i + "--》成功");
                Thread.sleep(new Random().nextInt(10) * 1000);
            } else {
                System.out.println(i + "--》失败");
                Thread.sleep(new Random().nextInt(10) * 1000);
            }
        }
    }
}

结果

0--》成功
{"1627416820":1}
1--》成功
{"1627416820":1,"1627416830":1}
2--》成功
{"1627416820":1,"1627416830":2}
3--》成功
{"1627416820":1,"1627416830":3}
4--》成功
{"1627416820":1,"1627416830":3,"1627416840":1}
5--》成功
{"1627416820":1,"1627416830":3,"1627416840":1,"1627416850":1}
6--》成功
{"1627416820":1,"1627416830":3,"1627416840":1,"1627416850":2}
7--》成功
{"1627416820":1,"1627416830":3,"1627416840":1,"1627416850":3}
8--》成功
{"1627416820":1,"1627416830":3,"1627416840":1,"1627416850":3,"1627416860":1}
9--》成功
{"1627416820":1,"1627416830":3,"1627416840":1,"1627416850":3,"1627416860":2}
10--》失败
{"1627416820":1,"1627416830":3,"1627416840":1,"1627416850":3,"1627416860":2}
11--》失败
{"1627416820":1,"1627416830":3,"1627416840":1,"1627416850":3,"1627416860":2}
12--》失败
{"1627416820":1,"1627416830":3,"1627416840":1,"1627416850":3,"1627416860":2}
13--》失败
{"1627416820":1,"1627416830":3,"1627416840":1,"1627416850":3,"1627416860":2}
14--》失败
{"1627416820":1,"1627416830":3,"1627416840":1,"1627416850":3,"1627416860":2}
15--》失败
{"1627416830":3,"1627416840":1,"1627416850":3,"1627416860":2}
16--》成功
{"1627416830":3,"1627416840":1,"1627416850":3,"1627416860":2,"1627416890":1}

漏桶算法

如何更加平滑的限流?不妨看看漏桶算法(Leaky Bucket),请求就像水一样以任意速度注入漏桶,而桶会按照固定的速率将水漏掉;当注入速度持续大于漏出的速度时,漏桶会变满,此时新进入的请求将会被丢弃限流整形是漏桶算法的两个核心能力。

image.png

单机实现

package com.lm.java.share.thread2.limit;

import java.util.LinkedList;

public class LeakyBucketLimiter {
    private int capaticy;//漏斗容量
    private int rate;//漏斗速率
    private LinkedList<Integer> requestList;
    public LeakyBucketLimiter(int capaticy, int rate) {
        this.capaticy = capaticy;this.rate = rate;
        requestList = new LinkedList<>();
        //开启一个定时线程,以固定的速率将漏斗中的请求流出,进行处理
        new Thread(new Runnable() {
            @Override
            public void run() {
                while(true){
                    if(!requestList.isEmpty()){
                        System.out.println("流出:"+requestList.removeFirst());
                    }
                    try {//大约每500ms处理一个
                        Thread.sleep(1000 / rate);
                    } catch (InterruptedException e) { }
                }
            }}).start();
    }
    public synchronized boolean tryAcquire(Integer i){
        if(capaticy - requestList.size()  <= 0){
            return false;
        }else{
            requestList.addLast(i);
            return true;
        }
    }
    public static void main(String[] args)
            throws InterruptedException {
        LeakyBucketLimiter leakyBucketLimiter
                = new LeakyBucketLimiter(5,2);
        for(int i = 1;i <= 10;i ++){
            if(leakyBucketLimiter.tryAcquire(i)){
                System.out.println(i + "号请求被接受");
                Thread.sleep(100);
            }else{
                System.out.println(i + "号请求被拒绝");
                Thread.sleep(100);
            }
        }
    }
}

优点

  • 漏出速率是固定的,可以起到整流的作用、漏斗算法之后,变成了有固定速率的稳定流量,从而对下游的系统起到保护作用

缺点

  • 不能解决流量突发的问题,当短时间内有大量的突发请求时,即便此时服务器没有任何负载,每个请求也都得在队列中等待一段时间才能被响应或直接被丢弃
  • 不能充分使用系统资源,因为漏桶的漏出速率是固定的,即使在某一时刻下游能够处理更大的流量,漏桶也不允许突发流量通过

令牌桶算法

令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,请求则会被阻塞或等待,简单的流程如下

image.png

  1. 所有的请求在处理之前都需要拿到一个可用的令牌才会被处理
  2. 根据限流大小,设置按照一定的速率往桶里添加令牌
  3. 桶设置最大的放置令牌限制,当桶满时、新添加的令牌就被丢弃或者拒绝
  4. 请求达到后首先要获取令牌桶中的令牌,拿着令牌才可以进行其他的业务逻辑,处理完业务逻辑之后,将令牌直接删除
  5. 令牌桶有最低限额,当桶中的令牌达到最低限额的时候,请求处理完之后将不会删除令牌,以此保证足够的限流

令牌桶算法支持先消费后付款,比如一个请求可以获取多个甚至全部的令牌,但是需要后面的请求付费。也就是说后面的请求需要等到桶中的令牌补齐之后才能继续获取

单机实现

  • 单机的可以直接使用 Guava 包中的 RateLimiter,以下是自己代码mock实现
package com.lm.java.share.thread2.limit;

/**
 * lm
 * 令牌桶算法是对漏桶算法的一种改进,除了能够在限制调用的平均速率的同时还允许一定程度的流量突发。
 **/
public class TokenBucketLimiter {
    private int capaticy;//令牌桶容量
    private int rate;//令牌产生速率
    private int tokenAmount;//令牌数量
    public TokenBucketLimiter(int capaticy, int rate) {
        this.capaticy = capaticy;
        this.rate = rate;
        tokenAmount = capaticy;
        new Thread(new Runnable() {
            @Override
            public void run() {
                //以恒定速率放令牌
                while (true){
                    synchronized (this){
                        tokenAmount ++;
                        if(tokenAmount > capaticy){
                            tokenAmount = capaticy;
                        }
                    }
                    try {
                        Thread.sleep(1000 / rate);
                    } catch (InterruptedException e) { }
                }
            }}).start();
    }

    public synchronized boolean tryAcquire(int i){
        if(tokenAmount > 0){
            tokenAmount --;
            System.out.println(i+"拿到令牌");
            return true;
        }else{
            return false;
        }

    }


    public static void main(String[] args)
            throws InterruptedException {
        TokenBucketLimiter tokenBucketLimiter =
                new TokenBucketLimiter(5,2);
        for(int i = 1;i <= 10;i ++){
            if(tokenBucketLimiter.tryAcquire(i)){
                System.out.println(i + "号请求被处理");
                Thread.sleep(100);
            }else{
                System.out.println(i + "号请求被拒绝");
                Thread.sleep(100);
            }
        }
    }
}

结果

1拿到令牌
1号请求被处理
2拿到令牌
2号请求被处理
3拿到令牌
3号请求被处理
4拿到令牌
4号请求被处理
5拿到令牌
5号请求被处理
6拿到令牌
6号请求被处理
7拿到令牌
7号请求被处理
8号请求被拒绝
9号请求被拒绝
10号请求被拒绝

分布式实现

-- 令牌桶限流

-- 令牌的唯一标识
local bucketKey = KEYS[1]
-- 上次请求的时间
local last_mill_request_key = KEYS[2]
-- 令牌桶的容量
local limit = tonumber(ARGV[1])
-- 请求令牌的数量
local permits = tonumber(ARGV[2])
-- 令牌流入的速率
local rate = tonumber(ARGV[3])
-- 当前时间
local curr_mill_time = tonumber(ARGV[4])

-- 添加令牌

-- 获取当前令牌的数量
local current_limit = tonumber(redis.call('get', bucketKey) or "0")
-- 获取上次请求的时间
local last_mill_request_time = tonumber(redis.call('get', last_mill_request_key) or "0")
-- 计算向桶里添加令牌的数量
if last_mill_request_time == 0 then
	-- 令牌桶初始化
	-- 更新上次请求时间
	redis.call("HSET", last_mill_request_key, curr_mill_time)
	return 0
else
	local add_token_num = math.floor((curr_mill_time - last_mill_request_time) * rate)
end

-- 更新令牌的数量
if current_limit + add_token_num > limit then
    current_limit = limit
else
	current_limit = current_limit + add_token_num
end
	redis.pcall("HSET",bucketKey, current_limit)
-- 设置过期时间
redis.call("EXPIRE", bucketKey, 2)

-- 限流判断

if current_limit - permits < 1 then
    -- 达到限流大小
    return 0
else
    -- 没有达到限流大小
	current_limit = current_limit - permits
	redis.pcall("HSET", bucketKey, current_limit)
    -- 设置过期时间
    redis.call("EXPIRE", bucketKey, 2)
	-- 更新上次请求的时间
	redis.call("HSET", last_mill_request_key, curr_mill_time)
end

漏桶与令牌桶对比

漏桶算法与令牌桶算法在表面看起来类似,很容易将两者混淆。但事实上,这两者具有截然不同的特性,且为不同的目的而使用。漏桶算法与令牌桶算法的区别在于:

  • 漏桶算法能够强行限制数据的传输速率
  • 令牌桶算法能够在限制数据的平均传输速率的同时还允许某种程度的突发传输

在某些情况下,漏桶算法不能够有效地使用网络资源。因为漏桶的漏出速率是固定的,所以即使网络中没有发生拥塞,漏桶算法也不能使某一个单独的数据流达到端口速率。因此,漏桶算法对于存在突发特性的流量来说缺乏效率。而令牌桶算法则能够满足这些具有突发特性的流量。通常,漏桶算法与令牌桶算法结合起来为网络流量提供更高效的控制

guava 限流 ratelimiter

Guava的 RateLimiter提供了令牌桶算法实现, RateLimiter 有两种限流模式,一种为稳定模式(SmoothBursty:令牌生成速度恒定),一种为渐进模式(SmoothWarmingUp:令牌生成速度缓慢提升直到维持在一个稳定值)。

继承结构如下 image.png 核心思路:响应本次请求之后,动态计算下一次可以服务的时间,如果下一次请求在这个时间之前则需要进行等待。SmoothRateLimiter 类中的 nextFreeTicketMicros 属性表示下一次可以响应的时间。例如,如果我们设置QPS为1,本次请求处理完之后,那么下一次最早的能够响应请求的时间一秒钟之后

常用方法 image.png

稳定模式(SmoothBursty:令牌生成速度恒定)

代码测试

    public static void main(String[] args) {
        //创建一个RateLimiter,指定每秒放0.5个令牌(2秒放1个令牌)
        RateLimiter rateLimiter = RateLimiter.create(0.5);
        int[] a = {1,6,2};
        for(int i = 0; i < a.length; ++i) {
            //acquire(x)  从RateLimiter获取x个令牌,该方法会被阻塞直到获取到请求
            System.out.println(System.currentTimeMillis() + " acq " + a[i] + ": wait " + rateLimiter.acquire(a[i]) + "s");
        }

    }

结果

1627393585611 acq 1: wait 0.0s
1627393585613 acq 6: wait 1.99704s
1627393587613 acq 2: wait 11.997597s

分析

1、create(double permitsPerSecond) 根据指定的QPS数值创建RateLimiter,RateLimiter rateLimiter = RateLimiter.create(0.5);

2、acquire(int permits)  从RateLimiter获取x个令牌,该方法会被阻塞直到获取到请求;主要做了三件事

public double acquire(int permits) {
    //1.获取当前请求需要等待的时间(惰性计算 )
    long microsToWait = reserve(permits);
    
    //2.sleep microsToWait 时间窗口
    stopwatch.sleepMicrosUninterruptibly(microsToWait); 

    //3.返回microsToWait对应的秒级时间
    return 1.0 * microsToWait / SECONDS.toMicros(1L);
  }



final long reserve(int permits) {
    //检查参数是否>0
    checkPermits(permits); 

    synchronized (mutex()) {

        //计算需要等待的时间
      return reserveAndGetWaitLength(permits,stopwatch.readMicros()); 

    }

  }

reserveAndGetWaitLength方法同样通过通过调用 reserveEarliestAvailable 方法实现了更新令牌数和计算等待时间的过程。该方法返回需要等待的时间,是RateLimiter的核心接口

  @Override
  final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
    resync(nowMicros); //1.根据当前时间和预计下一秒时间判断有无新令牌产生,有则更新持有令牌数storedPermits 和 下次请求时间nextFreeTicketMicros
    long returnValue = nextFreeTicketMicros;
    //2.以下两句,根据请求需要的令牌数requiredPermits和storedPermits当前持有的令牌数storedPermits分别计算 持有令牌中可用的数量storedPermitsToSpend和需要预支的令牌数量freshPermits
    double storedPermitsToSpend = min(requiredPermits, this.storedPermits); 
    double freshPermits = requiredPermits - storedPermitsToSpend;
    long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)//3.分别计算storedPermitsToSpend和freshPermits的等待时间
        + (long) (freshPermits * stableIntervalMicros);

    try {
      this.nextFreeTicketMicros = LongMath.checkedAdd(nextFreeTicketMicros, waitMicros); //4.更新nextFreeTicketMicros
    } catch (ArithmeticException e) {
      this.nextFreeTicketMicros = Long.MAX_VALUE;
    }
    this.storedPermits -= storedPermitsToSpend; //4.更新storedPermits 
    return returnValue;
  }

RateLimiter支持突发流量的本质就是,将当前需要的令牌数量requiredPermits拆分成storedPermitsToSpend(持有令牌中可用的数量)和freshPermits(需要预支的令牌数量);分别计算需要等待的时间,然后更新nextFreeTicketMicros下次获取令牌的时间

举例: 当前RateLimiter持有4个令牌,当前请求需要6个令牌;则6个令牌中4个是可以从持有的令牌中直接获取,而另外两个需要预支的令牌则需要单独计算时间;

伪代码:getReqWaitTime(6) = getWaitTime(4) + getFreshWait(6 - 4)

而在SmoothBursty模式中, getWaitTime(4)是可以直接获取的,即time=0;getFreshWait(6 - 4)则等于freshPermits * stableIntervalMicros (预支令牌数 * 生成一个令牌需要的时间)

渐进模式(SmoothWarmingUp:令牌生成速度缓慢提升直到维持在一个稳定值)

SmoothWarmingUp是RateLimiter的另一种实例不同于SmoothBursty ,它存在一个“热身”的概念。即:如果当前系统处于“冷却期”(即一段时间没有获取令牌,即:当前持有的令牌数量大于某个阈值),则下一次获取令牌需要等待的时间比SmoothBursty模式下的线性时间要大,并且逐步下降到一个稳定的数值

public static void main(String[] args) throws InterruptedException {

        //预热模式,设置预热时间和QPS,即在正式acquire前,限流器已经持有5*4=20个令牌
        RateLimiter rateLimiter = RateLimiter.create(5, 4000, TimeUnit.MILLISECONDS);
        for(int i = 1; i < 50; i++) {
            System.out.println(System.currentTimeMillis()
                    + " acq " + i + ": wait " + rateLimiter.acquire() + "s");

            if(i == 15) {
                Thread.sleep(2000);
                System.out.println(System.currentTimeMillis()
                        + " acq " + 15 + ": wait " + rateLimiter.acquire() + "s");
            }
        }
    }

结果

1627394552090 acq 1: wait 0.0s
1627394552093 acq 2: wait 0.577101s
1627394552679 acq 3: wait 0.530931s
1627394553215 acq 4: wait 0.494789s
1627394553715 acq 5: wait 0.454858s
1627394554174 acq 6: wait 0.415823s
1627394554594 acq 7: wait 0.376547s
1627394554975 acq 8: wait 0.335186s
1627394555312 acq 9: wait 0.298016s
1627394555611 acq 10: wait 0.259247s
1627394555871 acq 11: wait 0.219174s
1627394556095 acq 12: wait 0.195104s
1627394556294 acq 13: wait 0.196015s
1627394556495 acq 14: wait 0.194949s
1627394556695 acq 15: wait 0.194917s
1627394558897 acq 15: wait 0.0s
1627394558897 acq 16: wait 0.341318s
1627394559239 acq 17: wait 0.300901s
1627394559544 acq 18: wait 0.257622s
1627394559807 acq 19: wait 0.215828s
1627394560028 acq 20: wait 0.195105s
1627394560227 acq 21: wait 0.19614s
1627394560427 acq 22: wait 0.19554s
1627394560627 acq 23: wait 0.195578s
1627394560828 acq 24: wait 0.195113s
1627394561028 acq 25: wait 0.195055s
1627394561226 acq 26: wait 0.196228s
1627394561428 acq 27: wait 0.195024s
1627394561628 acq 28: wait 0.194914s
1627394561828 acq 29: wait 0.194966s
1627394562027 acq 30: wait 0.195278s
1627394562227 acq 31: wait 0.196018s
1627394562427 acq 32: wait 0.195358s
1627394562627 acq 33: wait 0.195754s
1627394562827 acq 34: wait 0.195381s
1627394563026 acq 35: wait 0.196817s
1627394563226 acq 36: wait 0.19613s
1627394563426 acq 37: wait 0.196132s
1627394563628 acq 38: wait 0.194922s
1627394563827 acq 39: wait 0.195142s
1627394564027 acq 40: wait 0.195918s
1627394564226 acq 41: wait 0.196608s
1627394564428 acq 42: wait 0.195045s
1627394564626 acq 43: wait 0.196887s
1627394564827 acq 44: wait 0.195699s
1627394565028 acq 45: wait 0.194653s
1627394565227 acq 46: wait 0.195391s
1627394565427 acq 47: wait 0.195834s
1627394565627 acq 48: wait 0.195929s
1627394565827 acq 49: wait 0.195981s

从输出结果可以看出,RateLimiter具有预消费的能力:

acq 1时并没有任何等待直接预消费了1个令牌 acq 2~11时,由于当前系统处于冷却期,因此开始等待的时间较长,并且逐步下降到一个稳定值 acq 12~15时,等待时间趋于稳定的0.2秒,即1/QPS acq 15 同时,sleep2秒,即在当前基础上,又新增5*2个令牌;将系统过渡到冷却期 acq 15~结束,重复acq 2~15的过程