限流器
如有侵权,请联系,无心侵权~
如有错误,欢迎批评指正!
1、限流器的概述
限流器是一种控制请求流量的机制,通常用于计算机网络、API、数据库等场景,以保护系统免受过载或滥用。
1.1、限流的目的
- 性能保护:避免过多请求同时到达系统,导致性能下降或崩溃。
- 资源管理:合理分配系统资源,确保服务质量。
- 安全防护:防止恶意攻击(如 DDoS 攻击)或滥用(如接口频繁调用)。
- 用户体验:平滑地处理流量,提供更好的用户体验。
1.2、限流策略
- 固定窗口计数法:
- 设定一个固定的时间窗口,记录在这个窗口内的请求次数。一旦请求次数超过设定限额,后续请求会被拒绝或延迟。
- 优点:实现简单,适用于流量相对均匀的场景。
- 缺点:在窗口边界会出现突发流量的问题。
- 滑动窗口计数法:
- 通过滑动窗口动态计算请求次数,避免固定窗口的瞬时爆发问题。滑动窗口更精细,可以平滑请求流量。
- 优点:避免固定窗口的突发问题,提供更平滑的流量控制。
- 缺点:实现复杂度相对较高。
- 漏桶算法(Leaky Bucket):
- 形象地将请求视作水流,一定数量的水(请求)流入桶中并缓慢泄漏。请求的速率是恒定的,即使瞬时请求量很高,也会被平滑处理。也就是请求放入到桶中,然后恒速进行消费。
- 优点:通过稳定输出速率,有效平滑请求流量。
- 缺点:若请求流量持续高于桶的容量,重要请求可能会被丢弃。
- 令牌桶算法(Token Bucket):
- 每个请求需要消耗一个令牌,系统每秒生成一定数量的令牌。当请求到达时,如果有令牌可用,则允许请求,否则拒绝。令牌桶允许突发请求,但保持平均速率。即,令牌放入到桶中,请求去桶中获取令牌。感觉和漏桶算法是反过来的。
- 优点:允许突发请求,同时限制了平均流量。
- 缺点:需要管理令牌的生成与消耗,稍显复杂。
2、令牌桶的算法实现-guava
2.1、源码
令牌桶算法是按照固定的频率生成令牌。当然,直观上我们可以使用信号量JUC包下的Semaphore来实现【通过一个定时器每间隔一定的时间就增加一个令牌,每个请求获取一个令牌】。
guava却只是通过一个指针来实现,这样实现有很多好处,例如,实现简单、并且限流主要发生在在高并发的情况下,系统压力已经临近极限了,此时使用定时器这个实现就有问题了。问题就出在定时器上,在高并发场景下,当系统压力已经临近极限的时候,定时器的精度误差会非常大,同时定时器本身会创建调度线程,也会对系统的性能产生影响。
com.google.common.util.concurrent.RateLimiter:
public abstract class RateLimiter {
@CanIgnoreReturnValue
public double acquire(int permits) {
// 获取permits个令牌需要等待的时间
long microsToWait = reserve(permits);
// 线程sleep这么久【microsToWait】并且不能打断
stopwatch.sleepMicrosUninterruptibly(microsToWait);
// 等待时间从微秒转换为秒
return 1.0 * microsToWait / SECONDS.toMicros(1L);
}
final long reserve(int permits) {
// 校验permits的合法性
checkPermits(permits);
// 加锁
synchronized (mutex()) {
return reserveAndGetWaitLength(permits, stopwatch.readMicros());
}
}
final long reserveAndGetWaitLength(int permits, long nowMicros) {
// 获取最早令牌桶中有可用令牌的时间点
long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
// 需要等待的时间
return max(momentAvailable - nowMicros, 0);
}
}
abstract class SmoothRateLimiter extends RateLimiter {
@Override
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
resync(nowMicros);
// nextFreeTicketMicros含义:获取令牌桶下次产生令牌的时间,这里直接赋值给returnValue,返回。所以,返回的是下次令牌桶有第一个令牌的时间,
// 但是不满足requiredPermits个数。
long returnValue = nextFreeTicketMicros;
double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
double freshPermits = requiredPermits - storedPermitsToSpend;
// 如果令牌不够,需要等待的时间。这个等待时间,也只是影响下一个获取限流器令牌的请求,不会影响本次
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) {
double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
storedPermits = min(maxPermits, storedPermits + newPermits);
nextFreeTicketMicros = nowMicros;
}
}
}
一句话:
你通过acquire(n)获取限流:
-
这个限流器有令牌,会立刻让你通过,并且如果n比较大它会预支未来的令牌,未来一段时间所有的请求都被限流;
-
这个令牌桶没有令牌,会等令牌桶有第一个令牌就会立刻返回。
2.2、测试验证
public class BaseTest {
@Test
public void testLimiter() {
RateLimiter rateLimiter = RateLimiter.create(1);
long time1 = System.currentTimeMillis();
rateLimiter.acquire(10);
long time2 = System.currentTimeMillis();
System.out.println("第一次获取等待时间: " + (time2 - time1)/1000);
rateLimiter.acquire(15);
long time3 = System.currentTimeMillis();
System.out.println("第二次获取等待时间: " + (time3 - time1)/1000);
rateLimiter.acquire();
long time4 = System.currentTimeMillis();
System.out.println("第三次获取等待时间: " + (time4 - time1)/1000);
}
}
运行结果:
第一次获取等待时间: 0
第二次获取等待时间: 10
第三次获取等待时间: 25
很好验证了上面的说法。本次的获取的限流值只会影响下次的请求,会预支之后的令牌。
指导:
杜绝使用acquire(n),多个请求同时打过来,聚合到一起去限流器获取令牌,而应该是一个请求去令牌桶获取一次,调用acquire(1)