背景
项目中限流可以用Guava RateLimiter,不想了解原理是啥吗?!
这篇文章已经写的很好了
没啥可补充了,加一些个人思考吧
思考
如果让你来造一个限流器,有啥想法?
直观想法1,对应参考文章的漏桶算法
就是用一个固定大小的队列。比如设置限流为5qps,1s可以接受5个请求;那我们就造一个大小为5的队列,如果队列为满了,就拒绝请求;如果队列未满,就往队列添加请求。
如何控制速率呢,我们通过控制消费者的消费速率是5qps,1s消费5个即可。
问题,说的挺轻巧,具体怎么控制消费者的速率呢?又加一个定时任务来消费队列吗,也挺费劲的。
想法2,对应参考文章的令牌算法
令牌听起来挺酷的。以固定的速率往桶里发放令牌。然后消费者每次要取到令牌(acquire)才可以响应请求
优点:由于令牌是固定间隔发放的,假设还是5qps,如果我有1s内没有请求,我的令牌桶就满了,可以一瞬间响应5个请求(一次过取5个令牌),也就是可以应对瞬时流量。
那么这里也涉及一个固定间隔发放的问题,难道也是需要定时任务往”桶里“放令牌吗?
那我们来看下Guava怎么搞的,先看下文章中的例子比较好
假设限流为2qps,那么固定发放令牌的时间stableIntervalMicros
就是500ms,初始化的storedPermits
当前桶里的令牌数是0。
操作说明 | requiredPermits请求多少令牌 | storedPermits桶里还有多少令牌 | nextFreeTicketMicros下一次能获得令牌的时间 | 当前时间 |
---|---|---|---|---|
初始化 | none | 0 | 0 | 0 |
直接获取10个令牌,直接预支付成功,获取成功 | 10 | 0 | 0 + 500 * 10 = 5000 | 0 |
按照上一步的计算,下次可以获得令牌的时间是5000ms,过了5000ms,sleep睡醒了,这时候再执行acquire(1) | 1 | 0 | 5000+500*1=5500 | 5000 |
debug看下分析对不对:核心代码:
第1次获取10个令牌
nowMicro是刚开始运行的时间,是一个很小的数,约等于0;
resync(nowMicro),更新令牌数,由于nowMicro约等于0,其实令牌数不会更新((0-0)/5000 = 0),令牌数还是0(约等于0)
storedPermitsToSpend,其实当前并没有令牌,所以取min,约等于0;
freshPermits,需要预支付10个令牌,约等于10;
预支付之后需要等待10*interval = 10 * 500 ,约等于5000ms,5000000微秒
this.nextFreeTicketMicros 需要加上 waitMicros 也就是 下一次可以获得令牌的时间是5000ms之后。
所以我们看到输出信息的第一行在第0s获取了10个令牌之后,下一次再想获取1个令牌需要等待5000ms也就是5s。
第2次获取1个令牌
然后再一次想获取1个令牌,当前时间还是约等于0,这时候resync,nowMicros(0)比nextFreeTicketMicros(5000)小,令牌不更新。returnValue=5000,storedPermitsToSpend=0,freshPermits=1,需要再等 waitMicros=1 * 500ms,然后nextFreeTicketMicros更新为5000+500=5500,返回returnValue=5000;外层函数睡眠5000ms,返回5000(输出打印获取1个token,约5s)
第3次获取10个令牌
上面说的,睡了5000ms,当前时间nowMicros=5000; resync,nowMicros(5000)比nextFreeTicketMicros(5500)小,令牌不更新,还是欠费状态,只能预支付。returnValue=5500, storedPermitsToSpend=0,freshPermits=10,需要预支付10个令牌, waitMicros=10 * 500ms = 5000,然后nextFreeTicketMicros更新为5500+5000=10500,返回returnValue=5500;外层函数睡眠5500-5000=500ms,返回500(输出打印获取10个token,约0.5s)
算法小结
所以咱们这个令牌算法的思路是怎样的呢?
1、对于给定的限流qps(如2),可以得到发放令牌的interval=1/2=0.5s,每0.5s发一个。
2、维护一个nextFreeTicketMicros,记录下一次可以获得令牌的时间;
3、每次来acquire请求,都会:
- 先更新一下当前时间应该发放的令牌数(先发放令牌) :(
当前时间
-nextFreeTicketMicros
)/interval
,同时不能大于桶的最大容量(这里是2);设置nextFreeTicketMicros=当前时间。 - 然后计算一下当前桶里的令牌是不是够花(再扣除令牌)。允许预支。假设真有人奇怪地请求超多令牌 acquire(10),就会允许预支(10-2=8个令牌),然后计算在超支的情况下,waitMicros = 设定间隔 * 超支数 = 0.5 * 8 = 4s, 并将 nextFreeTicketMicros += waitMicros,然后sleep 一段时间(基本都是0),睡醒了再返回成功。
高并发的情况
高并发的情况下是怎么样的呢?
假设我们设置qps=2
我们会在每个请求里用tyrAcuiqre()来限流,每次只获取一个令牌,如果发现当前时间还没到nextFreeTicketMicro
,那就说明无法获取,返回失败;否则的话会走上面那个核心代码,尝试去先发放令牌然后消耗令牌
tryAcquire代码:
原始状态令牌桶里有0个令牌,nextFreeTicketMicros = 0
然后一瞬间来了很多请求。
- 第1个请求,更新令牌数量,nextFreeTicketMicros = 0,当前时间约等于0但是比0稍微大点,不会直接返回false,尝试走后面流程,先尝试发放令牌,当前时间只比0稍微大点,可以发放的令牌约等于0,令牌桶依然为0;然后扣除令牌,请求一个,欠费1个。等待时间(waitNanos=1*0.5=0.5s,nextFreeTicketMicros+=waitNanos=0.5s),返回值等于当前时间,所以睡眠时间=0,不会睡眠,直接返回成功;
- 第2个请求,假设的是一瞬间来的多个请求,时间间隔约等于0,所以nextFreeTicketMicros=0.5s,大于当前时间,直接返回失败;
- 后面多个请求,只要还没到0.5ms,都是因为小于nextFreeTicketMicros,返回失败;
- 然后时间来到0.5s以后,来了一个请求,当前时间比nextFreeTicketMicros大了,开始走后面流程,发放令牌:由于时间差太小了,实际上还是没有增加令牌,令牌桶为空,更新nextFreeTicketMicros=当前时间;扣除令牌:请求一个,令牌桶是空的,欠费1个。等待时间(waitNanos=1*0.5=0.5s,nextFreeTicketMicros+=waitNanos=1.0s),返回值等于当前时间,所以睡眠时间=0,不会睡眠,直接返回成功;
做个实验吧,看看对不对:
@Test
public void testSmoothBursty() throws InterruptedException {
RateLimiter r = RateLimiter.create(2);
Long time = System.currentTimeMillis();
while (true) {
boolean b = r.tryAcquire();
if (b) {
Long diff = System.currentTimeMillis() - time;
time = System.currentTimeMillis();
System.out.println("get 1 tokens: " + diff);
}
// Thread.sleep(100);
}
}
输出结果:
符合预期
第一个请求约等于没有睡眠
后面的请求只有每过500ms的请求返回成功
什么时候会sleep?
我们发现,底层是会调用这个函数的:
reserveAndGetWaitLength
将返回值和当前时间相减,得到的值将用于睡眠。
所以什么时候会睡眠呢?我们仔细分析下代码
返回值就是,调用发放令牌之后的nextFreeTicketMicros
仔细看怎么发放令牌:
如果当前时间大于nextFreeTicketMicros,那就会更新nextFreeTicketMicros=当前时间。
所以,如果当前时间大于nextFreeTicketMicros,更新nextFreeTicketMicros=当前时间,再返回,然后用这个返回值将去当前时间,这么一看,那肯定等于0,不用睡;也就是说,如果当前时间大于nextFreeTicketMicros,不用睡。
所以什么时候要睡呢,那就是当前时间 【不】大于nextFreeTicketMicros呗。什么时候当前时间 【不】大于nextFreeTicketMicros呢?想象这么一个场景:我们在调用acquire而不是tryAcquire的时候,比如acquire(10000),申请超级多的令牌,由于我们支持超支,waitNanos=10000*interval=超多时间,于是就把nextFreeTicketMicros更新成远远超过当前时间了,这时候我们就会发现当前时间 【不】大于nextFreeTicketMicros,需要睡超级久才行。。
综上,当我们使用acquire()而不是tryAcquire的时候,所有请求都会排队,sleep到自己该执行的时间,然后执行。
实验一把:
public void acquireThread(RateLimiter r) {
double acquire = r.acquire();
System.out.println("get 1 tokens: " + acquire);
}
@Test
public void testAcquire() throws InterruptedException {
RateLimiter r = RateLimiter.create(2);
for (int i = 0; i < 20; i++) {
new Thread(() -> acquireThread(r)).start();
// double acquire = r.acquire();
// System.out.println("get 1 tokens: " + acquire);
}
System.out.println("loop end already!!");
Thread.sleep(10000);
}
可以看见,循环启动子线程之后,就跳出循环了,但是这些子线程实际上得慢慢排队,等到属于他们的时刻才可以运行。在这种情况下,虽然我们没有用到队列,却貌似申请实现了队列的效果。
核心原理是啥?实际上是通过锁的机制,抢着去设置nextFreeTicketMicros,nextFreeTicketMicros每次递增interval,每个线程成功设置到nextFreeTicketMicros的话,就会知道自己要睡多久。
具体:初始化nextFreeTicketMicros=0;同时来了20个线程
- 第一个抢到的线程,now约等于0,将nextFreeTicketMicros设置成now,约等于0;扣除令牌:请求1个,发现欠费,nextFreeTicketMicros+=waitMicro(interval*1)=0+0.5=0.5; 将nextFreeTicketMicros设置成0.5,不需要睡;
- 第二个抢到的线程,now约等于0,nextFreeTicketMicros大于now,不会设置将nextFreeTicketMicros(等于上一次请求的0.5,用于返回);扣除令牌:请求1个,发现欠费,nextFreeTicketMicros+=waitMicro(interval*1)=0.5+0.5=1; 将nextFreeTicketMicros设置成1,需要睡0.5-now(约等于0)=0.5;
-
- 第三个抢到的线程,now约等于0,nextFreeTicketMicros大于now,不会设置将nextFreeTicketMicros(等于上一次请求的1,用于返回);扣除令牌:请求1个,发现欠费,nextFreeTicketMicros+=waitMicro(interval*1)=1+0.5=1.5; 将nextFreeTicketMicros设置成1.5,需要睡1-now(约等于0)=1;
小结
再难啃的代码,一行一行debug,用脑子当计算机过几遍,总会有一点理解的,不要害怕不要虚,奥利给!