目录
一、引言
1.1、什么是限流
保护高并发系统的三把利器是缓存、降级、限流。缓存之前讲过了,内存读取,提升相应速率,对高并发请求做暂存缓冲。熔断降级暂时也不做过多讲述,本篇主要讲限流。
前面聊过网关流量控制(限流),熔断降级功能。那么限流是如何实现控制的呢?
限流分瞬时流量控制和时间窗口流量;被限流的端口仍然可以提交任务,只是任务被阻塞,缓存在内存,会有OOM的风险;是对下游服务的一种保护;
1.2、限流和熔断降级的区别
熔断降级是直接拒绝服务;客户端口提交的请求直接非阻塞的被拒绝并给与请求失败的信息返回;
1.3、常用的限流方式和场景
- 限制总并发数(比如数据库连接池、线程池)
- 限制瞬时并发数(如nginx的limitconn模块,用来限制瞬时并发连接数,Java的Semaphore也可以实现)
- 限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limitreq模块,限制每秒的平均速率)
- 其他还有如限制远程接口调用速率、限制MQ的消费速率。另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流。
二、限流算法:
2.1、固定窗口计数限流
- 在一个时间周期内每来一次请求就将计数器+1
- 如果计数器超过了限制数量, 则拒绝服务
- 时间达到下一个时间窗口, 计数器重置
这种算法很好实现, 但是会出现限流不准确问题。比如每秒通过 5 个请求,时间窗口的大小为 1 秒,当前时间窗口周期内的后半秒正常通过了 5 个请求,下一个时间窗口周期内的前半秒正常通过了 5 个请求,在这两个窗口内都没有超过限制。但是在这两个窗口的中间那一秒实际上通过了 10 个请求,显然不满足每秒 5 个请求的限制。
2.2、滑动窗口计数限流
- 将时间周期设置为滑动窗口大小
- 当有新的请求来临时将窗口滑动到改请求来临的时刻
- 判断窗口内的请求数是否超过了限制, 超过则拒绝服务, 否则请求通过
- 丢弃滑动窗口以外的请求
这种算法解决了固定窗口计数器出现的通过请求数是限制数两倍的缺陷,但是滑动窗口时间记录会比较麻烦(上一次和下一次请求的时间间隔)。
2.3、漏桶限流
- 将进来的请求流量视为水滴放入桶里
- 水从桶的底部以固定速率匀速流出, 相当于匀速请求
- 当漏桶的水满时(超过限流阈值)则拒绝服务
- 这里有两个变量,一个是桶的大小,支持流量突发增多时可以存多少的水(burst),另一个是水桶漏洞的大小(rate)
- 漏桶算法对请求令牌是一个缓冲池一样的作用,将突发的请求缓存到桶中,桶的容量就是任一时刻允许的请求量,超过这个阈值burst,请求就会被阻塞,或者直接拒绝请求,并非阻塞式的返回结果。相当于可以控制任务请求启动执行速率。
- 但是需要注意桶的最大容量的选取,当请求量超过阈值的处理方式最好选用非阻塞的直接拒绝,以免OOM,保护下游的同时也保护该服务端。
- 控制流量流速绝对均匀, 适合流量比较平滑的场景(如数据库), 分布式的实现难度较滑动窗口来说复杂一些。
2.4、令牌桶限流
- 按照一定的速率生产令牌并放入令牌桶中
- 如果桶中令牌已满,则丢弃令牌
- 请求过来时先到桶中拿令牌,拿到令牌则放行通过,否则拒绝请求。
- 固定窗口计数算法简单易实现,其缺陷是可能在中间的某一秒内通过的请求数是限流阈值的两倍,该算法仅适用于对限流准确度要求不高的应用场景。
- 滑动窗口计数算法解决了固定窗口计数算法的缺陷,但是该算法较难实现,因为要记录每次请求所以可能出现比较占用内存比较多的情况。
- 漏桶算法可以做到均匀平滑的限制请求,Ngixn 热 limit_req 模块也是采用此种算法。因为匀速处理请求的缘故所以该算法应对限流阈值内的突发请求无法及时处理。
- 令牌桶算法解决了以上三个算法的所有缺陷,是一种相对比较完美的限流算法,也是限流场景中应用最为广泛的算法。使用 Redis + Lua 脚本的方式可以简单的实现;
2.5、常用的限流工具API
- 限流工具类如Java的Semaphore,Guava的RateLimiter的非阻塞API方法如tryAquire,达到限制计数阈值后直接拒绝返回,也可以实现熔断降级的功能;
- 而线程池的最大线程数的配置也可以作为限制瞬时并发数(任务队列长度设置为0,最大线程数就是瞬时最大并发数)的实现工具;但是线程池可能会被不同的业务,不同端口共用的,不能精细的控制每个业务端口的流量,所以线程池的控制粒度不够细。
- 而流量控制API则可以有更好的控制粒度。
- Java的Semaphore是没有什么算法实现的,就是直接的一个信号量的控制。但是限制时间窗口内平均速率却是有多种算法实现的。
RateLimiter 与 Semaphore区别
RateLimiter限制的是速率,默认的create方法的时间单位是每秒
Semaphore限制的是并发数
Semaphore
- 用 Semaphore 实现简单连接池,对比『享元模式』下的实现(用wait notify),性能和可读性显然更好,
- 注意下面的实现中线程数和数据库连接数是相等的
@Slf4j(topic = "limit")
public class LimitPool {
// 1. 连接池大小
private final int poolSize;
// 2. 连接对象数组
private Connection[] connections;
// 3. 连接状态数组 0 表示空闲, 1 表示繁忙
private AtomicIntegerArray states;
private Semaphore semaphore;
//3. 获取数据库连接对象
// Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/db3", "root", "root");
// 4. 构造方法初始化
public LimitPool(int poolSize) throws SQLException {
this.poolSize = poolSize;
// 让许可数与资源数一致
this.semaphore = new Semaphore(poolSize);
this.connections = new Connection[poolSize];
this.states = new AtomicIntegerArray(new int[poolSize]);
for (int i = 0; i < poolSize; i++) {
connections[i] = DriverManager.getConnection(String.valueOf(i++));
}
}
// 5. 借连接
public Connection borrow() {// t1, t2, t3
// 获取许可
try {
semaphore.acquire(); // 没有许可的线程,在此等待
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int i = 0; i < poolSize; i++) {
// 获取空闲连接
if(states.get(i) == 0) {
if (states.compareAndSet(i, 0, 1)) {
log.debug("borrow {}", connections[i]);
return connections[i];
}
}
}
// 不会执行到这里
return null;
}
// 6. 归还连接
public void free(Connection conn) {
for (int i = 0; i < poolSize; i++) {
if (connections[i] == conn) {
states.set(i, 0);
log.debug("free {}", conn);
semaphore.release();
break;
}
}
}
}
RateLimiter
Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法实现流量限制,使用十分方便。
@Slf4j(topic = "rateLimit")
public class LimitedTest {
@Resource
private NettyTcpClient client;
private RateLimiter pushLimiter;
@PostConstruct
public void initLimiter() {
pushLimiter = RateLimiter.create(50);
}
public JSONArray push(JSONArray jsonArray) throws Exception {
if (jsonArray.isEmpty()) {
return new JSONArray();
}
orderLimiter.acquire(jsonArray.size());
return client.sendMsg(jsonArray.toString);
}
}
/**
* Created by wenrong on 2019/11/22.
*/
@Component
public class NettyTcpClient {
private static final Logger log = LoggerFactory.getLogger(NettyTcpClient.class);
@Value(("${netty.tcp.server.host}"))
String HOST;
@Value("${netty.tcp.server.port}")
int PORT;
@Autowired
ClientChannelInitializer clientChannelInitializer;
//与服务端建立连接后得到的通道对象
private Channel channel;
/**
* 初始化 `Bootstrap` 客户端引导程序
* @return
*/
private final Bootstrap getBootstrap() {
Bootstrap b = new Bootstrap();
EventLoopGroup group = new NioEventLoopGroup();
b.group(group)
.channel(NioSocketChannel.class)//通道连接者
.handler(clientChannelInitializer)//通道处理者
.option(ChannelOption.SO_KEEPALIVE, true);//心跳报活
return b;
}
/**
* 建立连接,获取连接通道对象
* @return
*/
public void connect() {
ChannelFuture channelFuture = getBootstrap().connect(HOST, PORT).syncUninterruptibly();
if (channelFuture != null && channelFuture.isSuccess()) {
channel = channelFuture.channel();
log.info("connect tcp server host = {}, port = {} success", HOST, PORT);
} else {
log.error("connect tcp server host = {}, port = {} fail", HOST, PORT);
}
}
/**
* 向服务器发送消息
* @param msg
* @throws Exception
*/
public void sendMsg(Object msg) throws Exception {
if (channel != null) {
channel.writeAndFlush(msg).sync();
} else {
log.warn("消息发送失败,连接尚未建立!");
}
}
}
三、源码解读
RateLimiter基于令牌桶算法,它的核心思想主要有:
- 响应本次请求之后,动态计算下一次可以服务的时间,如果下一次请求在这个时间之前则需要进行等待。SmoothRateLimiter 类中的 nextFreeTicketMicros 属性表示下一次可以响应的时间。例如,如果我们设置QPS为1,本次请求处理完之后,那么下一次最早的能够响应请求的时间一秒钟之后。
- RateLimiter 的子类 SmoothBursty 支持处理突发流量请求,例如,我们设置QPS为1,在十秒钟之内没有请求,那么令牌桶中会有10个(假设设置的最大令牌数大于10)空闲令牌,如果下一次请求是 acquire(20) ,则不需要等待20秒钟,因为令牌桶中已经有10个空闲的令牌。SmoothRateLimiter 类中的 storedPermits 就是用来表示当前令牌桶中的空闲令牌数。
- RateLimiter 子类 SmoothWarmingUp 不同于 SmoothBursty ,它存在一个“热身”的概念。它将 storedPermits 分成两个区间值:[0, thresholdPermits) 和 [thresholdPermits, maxPermits]。当请求进来时,如果当前系统处于"cold"的状态,从 [thresholdPermits, maxPermits] 区间去拿令牌,所需要等待的时间会长于从区间 [0, thresholdPermits) 拿相同令牌所需要等待的时间。当请求增多,storedPermits 减少到 thresholdPermits 以下时,此时拿令牌所需要等待的时间趋于稳定。这也就是所谓“热身”的过程。
3.1、RateLimiter的UML结构,以及重要的属性
RateLimiter主要的类的类图如下所示:
RateLimiter 是一个抽象类,SmoothRateLimiter 继承自 RateLimiter,不过 SmoothRateLimiter 仍然是一个抽象类,SmoothBursty 和 SmoothWarmingUp 才是具体的实现类。
RateLimiter只有两个属性:
// 用于计时,RateLimiter 把实例化的时间设置为 0 值,后续都是取相对时间,用微秒表示。
private final SleepingStopwatch stopwatch;
// 用来做互斥锁,RateLimiter 依赖于 synchronized 来控制并发
private volatile Object mutexDoNotUseDirectly;
SmoothRateLimiter中几个属性的含义
/**
* The currently stored permits.
* 当前还有多少 permits 没有被使用,被存下来的 permits 数量
*/
double storedPermits;
/**
* The maximum number of stored permits.
* 最大允许缓存的 permits 数量,也就是 storedPermits 能达到的最大值
*/
double maxPermits;
/**
* The interval between two unit requests, at our stable rate. E.g., a stable rate of 5 permits
* per second has a stable interval of 200ms.
* 稳定的频率间隔,每隔多少时间产生一个 permit,
* 比如我们构造方法中设置每秒 5 个,也就是每隔 200ms 一个,这里单位是微秒,也就是 200,000 个
*/
double stableIntervalMicros;
/**
* 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.
* 下一次请求可以获取令牌的起始时间
* 由于RateLimiter允许预消费,上次请求预消费令牌后
* 下次请求需要等待相应的时间到nextFreeTicketMicros时刻才可以获取令牌
* 下一次可以获取 permits 的时间,这个时间是相对 RateLimiter 的构造时间的,是一个相对时间
*/
private long nextFreeTicketMicros = 0L; // could be either in the past or future
nextFreeTicketMicros 是一个很关键的属性。每次获取 permits 的时候,先拿 storedPermits 的值,因为它是存货,如果够,storedPermits 减去相应的值就可以了,如果不够,那么还需要将 nextFreeTicketMicros 较当前时间往后推,表示预占了接下来多少时间的量了。那么下一个请求来的时候,如果还没到 nextFreeTicketMicros 这个时间点,需要 sleep 到这个点再返回;
3.2、逐层方法解析
由于有多层实现,所以这里会从RateLimiter的属性、方法,并向下深入解析SmoothRateLimiter的属性和方法:
Guava有两种限流模式,一种为稳定模式(SmoothBursty:令牌生成速度恒定),一种为渐进模式(SmoothWarmingUp:令牌生成速度缓慢提升直到维持在一个稳定值)
create方法
public static RateLimiter create(double permitsPerSecond) {
/*
* The default RateLimiter configuration can save the unused permits of up to one second. This
* is to avoid unnecessary stalls in situations like this: A RateLimiter of 1qps, and 4 threads,
* all calling acquire() at these moments:
*
* T0 at 0 seconds
* T1 at 1.05 seconds
* T2 at 2 seconds
* T3 at 3 seconds
*
* Due to the slight delay of T1, T2 would have to sleep till 2.05 seconds, and T3 would also
* have to sleep till 3.05 seconds.
*/
return create(SleepingStopwatch.createFromSystemTimer(), permitsPerSecond);
}
@VisibleForTesting
static RateLimiter create(SleepingStopwatch stopwatch, double permitsPerSecond) {
RateLimiter rateLimiter = new SmoothBursty(stopwatch, 1.0 /* maxBurstSeconds */);
rateLimiter.setRate(permitsPerSecond);
return rateLimiter;
}
public static RateLimiter create(double permitsPerSecond, long warmupPeriod, TimeUnit unit) {
checkArgument(warmupPeriod >= 0, "warmupPeriod must not be negative: %s", warmupPeriod);
return create(
SleepingStopwatch.createFromSystemTimer(), permitsPerSecond, warmupPeriod, unit, 3.0);
}
@VisibleForTesting
static RateLimiter create(
SleepingStopwatch stopwatch,
double permitsPerSecond,
long warmupPeriod,
TimeUnit unit,
double coldFactor) {
RateLimiter rateLimiter = new SmoothWarmingUp(stopwatch, warmupPeriod, unit, coldFactor);
rateLimiter.setRate(permitsPerSecond);
return rateLimiter;
}
从重载的方法顺序,来看默认是创建SmoothBursty实现类;SmoothBursty的机制要简单些;那就继续按照顺序往下解析方法。
setRate
继续看 setRate 分析
public final void setRate(double permitsPerSecond) {
// 检查表达式是否有效
checkArgument(
permitsPerSecond > 0.0 && !Double.isNaN(permitsPerSecond), "rate must be positive");
// synchronized 控制并发
synchronized (mutex()) {
doSetRate(permitsPerSecond, stopwatch.readMicros());
}
}
// SmoothRateLimiter 中doSetRate
final void doSetRate(double permitsPerSecond, long nowMicros) {
// 同步
resync(nowMicros);
// 计算属性 stableIntervalMicros
double stableIntervalMicros = SECONDS.toMicros(1L) / permitsPerSecond;
this.stableIntervalMicros = stableIntervalMicros;
// SmoothRateLimiter里面的doSetRate也是抽象方法,由两个下级实现类具体实现,这里先按下不表
doSetRate(permitsPerSecond, stableIntervalMicros);
}
// 比较经典的双检模式,锁外用volatile修饰,保证可见性
private Object mutex() {
Object mutex = mutexDoNotUseDirectly;
if (mutex == null) {
synchronized (this) {
mutex = mutexDoNotUseDirectly;
if (mutex == null) {
mutexDoNotUseDirectly = mutex = new Object();
}
}
}
return mutex;
}
SmoothRateLimiter里面的doSetRate也是抽象方法,由两个下级实现类具体实现,这里先按下不表,后面再具体区分两种策略对应的两个实现类的具体实现;
可以注意到在create初始化的就需要调用setRate,设置qps,doSetRate中一开始调用resync方法来同步和初始化storedPermits 和 nextFreeTicketMicros,记录nextFreeTicketMicros为当前时间(当然需要当前时间晚于上一次的nextFreeTicketMicros,否则同步失败)
当我们设置了 qps 之后呢?需要计算某一段时间系统能够生成的令牌数目,那么怎么计算呢?一种方式是开启一个后台任务去做,但是这样代价未免有点大。RateLimiter 中采取的是惰性计算方式:在每次请求进来的时候先去计算上次请求和本次请求之间应该生成多少个令牌。这里也暂时按下不表,后面解析acquire等方法,再说。
resync
resync 用来更新 storedPermits 和 nextFreeTicketMicros, 避免长时间不调用 acquire 导致不准确。
void resync(long nowMicros) {
// nextFreeTicket 已经过过期了,比如很长时间没有再次调用 limiter.acquire()
// 需要将 nextFreeTicket 设置为当前时间,重新计算 storedPermits
if (nowMicros > nextFreeTicketMicros) {
// 默认的coolDownIntervalMicros 直接返回了 stableIntervalMicros, 也就是生产一个 permits 的时间长度
// 计算一下, 这段时间产生了多少 permits
double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
// 如果超过了 maxtPermits, 则使用 maxPermits
storedPermits = min(maxPermits, storedPermits + newPermits);
// 将 nextFreeTicketMicros 更新为现在
nextFreeTicketMicros = nowMicros;
}
}
核心逻辑就是 (nowMicros-nextFreeTicketMicros)/stableIntervalMicros。当前时间大于 nextFreeTicketMicros时进行刷新,否则直接返回。
acquire
@CanIgnoreReturnValue
public double acquire() {
return acquire(1);
}
@CanIgnoreReturnValue
public double acquire(int permits) {
long microsToWait = reserve(permits);
stopwatch.sleepMicrosUninterruptibly(microsToWait);
return 1.0 * microsToWait / SECONDS.toMicros(1L);
}
reserve
reserve 预定 permits:
final long reserve(int permits) {
checkPermits(permits);
synchronized (mutex()) {
return reserveAndGetWaitLength(permits, stopwatch.readMicros());
}
}
final long reserveAndGetWaitLength(int permits, long nowMicros) {
// 返回 nextFreeTicketMicros
long momentAvailable = reserveEarliestAvailable(permits, nowMicros);
// 计算时长
return max(momentAvailable - nowMicros, 0);
}
/**
* 该函数用于获取requiredPermits个令牌,并返回需要等待到的时间点
* 其中,storedPermitsToSpend为桶中可以消费的令牌数,freshPermits为还需要的(需要补充的)令牌数,
* 根据该值计算需要等待的时间,追加并更新到nextFreeTicketMicros
*/
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
// 这里做一次同步,更新 storedPermits 和 nextFreeTicketMicros (如果需要)
resync(nowMicros);
// 返回值就是 nextFreeTicketMicros,注意刚刚已经做了 resync 了,此时它是最新的当前时间nowMicros
long returnValue = nextFreeTicketMicros;
// storedPermits 中可以使用多少个 permits
double storedPermitsToSpend = min(requiredPermits, this.storedPermits);
// storedPermits 中不够的部分
double freshPermits = requiredPermits - storedPermitsToSpend;
// 为了这个不够的部分,需要等待多久时间
long waitMicros =
storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
+ (long) (freshPermits * stableIntervalMicros);
// 将 nextFreeTicketMicros 往后推waitMicros 单位为微秒,让nextFreeTicketMicros晚于当前时间,下次调用
// acquire的时候,当前时间可能早于nextFreeTicketMicros,resync方法更新失败,
// nextFreeTicketMicros还是上一次设置的晚于当前的时间的时间点,并返回给momentAvailable,那么
// reserveAndGetWaitLength()返回的结果就大于0,线程需要休眠等待。
this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros);
// storedPermits 减去被拿走的部分
this.storedPermits -= storedPermitsToSpend;
return returnValue;
}
reserve其实就是计算线程需要休眠(Java 中线程sleep方法,TIMED_WAITING)的时间,递进调用,重点就在reserveEarliestAvailable方法。
reserveEarliestAvailable
reserveEarliestAvailable是刷新令牌数和下次获取令牌时间 nextFreeTicketMicros的关键函数。它有三个步骤:
- 调用 resync函数增加令牌数;
- 计算预支付令牌后,下次获取令牌,所需额外等待的时间;
- 更新下次获取令牌时间 nextFreeTicketMicros和存储令牌数 storedPermits。
- 根据令牌桶算法,桶中的令牌是持续生成存放的,有请求时需要先从桶中拿到令牌才能开始执行,谁来持续生成令牌存放呢?
- 一种解法是,开启一个定时任务,由定时任务持续生成令牌。这样的问题在于会极大的消耗系统资源,如,某接口需要分别对每个用户做访问频率限制,假设系统中存在6W用户,则至多需要开启6W个定时任务来维持每个桶中的令牌数,这样的开销是巨大的。
- 另一种解法则是延迟计算,如上resync函数。该函数会在每次获取令牌之前调用,其实现思路为,若当前时间晚于nextFreeTicketMicros(当前时间晚于上一次的nextFreeTicketMicros,否则就获取令牌失败,并且两个重要变量更新失败),则计算该段时间内可以生成多少令牌,将生成的令牌加入令牌桶中并更新数据,同时更新nextFreeTicketMicros。这样一来,只需要在获取令牌时计算一次即可。
storedPermitsToWaitTime
对于storedPermits的使用,RateLimiter存在两种策略,二者区别主要体现在使用storedPermits时候需要等待的时间。这个逻辑由storedPermitsToWaitTime函数实现:
abstract long storedPermitsToWaitTime(double storedPermits, double permitsToTake);
存在两种策略就是为了应对我们上面讲到的,存在资源使用不足大致分为两种情况:
- 资源确实使用不足,这些剩余的资源我们私海可以使用的;
- 提供资源的服务过去还没准备好,比如服务刚启动等;
为此,RateLimiter实际上由两种实现策略,其实现分别见SmoothBursty和SmoothWarmingUp。二者主要的区别就是storedPermitsToWaitTime实现以及maxPermits数量的计算。
同样的情况还有coolDownIntervalMicros(),这些都放到后面分开解析两种策略的却别。
从 reserve 的流程可看到,获取 permits 的时候,其实是获取了两部分,一部分来自于存量 storedPermits,存量不够的话,另一部分来自于预占未来的 freshPermits。
在了解以上概念后,再回头看就非常容易理解RateLimiter暴露出来的接口:acquire(),acquire函数主要用于获取permits个令牌,并计算需要等待多长时间,进而挂起等待,并将该值返回。
tryAcquire
同样的道理,tryAcquire就比较好理解了。其实就相当于acquire的非阻塞版,也就是判断出如果当前时间,加上timeOut后还是早于nextFreeTicketMicros,直接返回失败,线程也不休眠了、
public boolean tryAcquire(int permits) {
return tryAcquire(permits, 0, MICROSECONDS);
}
public boolean tryAcquire() {
return tryAcquire(1, 0, MICROSECONDS);
}
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用于判断timeout时间内是否可以获取令牌
private boolean canAcquire(long nowMicros, long timeoutMicros) {
return queryEarliestAvailable(nowMicros) - timeoutMicros <= nowMicros;
}
@Override
final long queryEarliestAvailable(long nowMicros) {
return nextFreeTicketMicros;
}
tryAcquire函数可以尝试在timeout时间内获取令牌,如果可以则挂起等待相应时间并返回true,否则立即返回false,canAcquire用于判断timeout时间内是否可以获取令牌。
3.3、下层策略区分,以及原理详述
平滑突发限流和平滑预热限流,上面很多抽象方法,在两个不同子实现类中实现,这是两种策略抽象体现。
3.3.1、平滑突发限流
看SmoothBursty的构造函数
SmoothBursty(SleepingStopwatch stopwatch, double maxBurstSeconds) {
super(stopwatch);
this.maxBurstSeconds = maxBurstSeconds; // 最大存储maxBurstSeconds秒生成的令牌
}
// 设置好了stableIntervalMicros、storedPermits和nextFreeTicketMicros, doSetRate 的实现:
void doSetRate(double permitsPerSecond, double stableIntervalMicros) {
double oldMaxPermits = this.maxPermits;
// 这里计算了新的 maxPermits 为 1 秒产生的 permits
// 原来的值是初始化的, 现在要重新调整频率, 所以需要重新计算 maxPermits
maxPermits = maxBurstSeconds * permitsPerSecond;
if (oldMaxPermits == Double.POSITIVE_INFINITY) {
// if we don't special-case this, we would get storedPermits == NaN, below
storedPermits = maxPermits;
} else {
// maxPermits 来说,是重新计算,而对于 storedPermits 来说,是做等比例的缩放
storedPermits =
(oldMaxPermits == 0.0)
? 0.0 // initial state
: storedPermits * maxPermits / oldMaxPermits;
}
}
@Override
long storedPermitsToWaitTime(double storedPermits, double permitsToTake) {
// 这部分固定返回 0
return 0L;
}
// 直接返回上一层实现类稳定的间隔频率
@Override
double coolDownIntervalMicros() {
return stableIntervalMicros;
}
桶中可存放的最大令牌数由maxBurstSeconds计算而来,其含义为最大存储maxBurstSeconds秒生成的令牌。该参数的作用在于,可以更为灵活地控制流量。如,某些接口限制为300次/20秒,某些接口限制为50次/45秒等。
下面举个例子,其实也是出现在第一个create重载方法的注释那里的例子,一个简单的使用示意图及解释,下面示例一个QPS=4的SmoothBursty:
t=1(时间开始点),这时候storedPermits=0,请求1个令牌,等待时间=0;
t=2,这时候storedPermits=3,请求3个令牌,等待时间=0;
t=3,这时候storedPermits=4,请求10个令牌,等待时间=0,超前使用了2个令牌;
t=4,这时候storedPermits=0,请求1个令牌,等待时间=0.5;
t=3,虽然此时只过去了两个周期stableIntervalMicros,只要当前时刻晚于nextFreeTicketMicros,就可以预支取令牌,虽然此时的时刻仅仅只大于两个周期,根据resync()的机制,就可以当做当前的storePermits是原来4个+ 当前的4个 = 8个,所以请求10个令牌,只需要预支取2个令牌就够了。
3.3.2、平滑预热限流
RateLimiter的 SmoothWarmingUp是带有预热期的平滑限流,它启动后会有一段预热期,逐步将分发频率提升到配置的速率。 比如下面代码中的例子,创建一个平均分发令牌速率为2,预热期为3秒。由于设置了预热时间是3秒,令牌桶一开始并不会0.5秒发一个令牌,而是形成一个平滑线性下降的坡度,频率越来越高,在3秒钟之内达到原本设置的频率,以后就以固定的频率输出。这种功能适合系统刚启动需要一点时间来“热身”的场景。
static final class SmoothWarmingUp extends SmoothRateLimiter {
private final long warmupPeriodMicros;
/**
* The slope of the line from the stable interval (when permits == 0), to the cold interval
* (when permits == maxPermits)
*/
private double slope;
private double thresholdPermits;
private double coldFactor;
SmoothWarmingUp(
SleepingStopwatch stopwatch, long warmupPeriod, TimeUnit timeUnit, double coldFactor) {
super(stopwatch);
this.warmupPeriodMicros = timeUnit.toMicros(warmupPeriod);
this.coldFactor = coldFactor;
}
@Override
void doSetRate(double permitsPerSecond, double stableIntervalMicros) {
double oldMaxPermits = maxPermits;
double coldIntervalMicros = stableIntervalMicros * coldFactor;
thresholdPermits = 0.5 * warmupPeriodMicros / stableIntervalMicros;
maxPermits =
thresholdPermits + 2.0 * warmupPeriodMicros / (stableIntervalMicros + coldIntervalMicros);
slope = (coldIntervalMicros - stableIntervalMicros) / (maxPermits - thresholdPermits);
if (oldMaxPermits == Double.POSITIVE_INFINITY) {
// if we don't special-case this, we would get storedPermits == NaN, below
storedPermits = 0.0;
} else {
storedPermits =
(oldMaxPermits == 0.0)
? maxPermits // initial state is cold
: storedPermits * maxPermits / oldMaxPermits;
}
}
// SmoothWarmingUp,等待时间就是计算上图中梯形或者正方形的面积。
@Override
long storedPermitsToWaitTime(double storedPermits, double permitsToTake) {
// 当前permits超出阈值的部分
double availablePermitsAboveThreshold = storedPermits - thresholdPermits;
long micros = 0;
// measuring the integral on the right part of the function (the climbing line)
// 如果当前存储的令牌数超出thresholdPermits
if (availablePermitsAboveThreshold > 0.0) {
// 在阈值右侧并且需要被消耗的令牌数量
double permitsAboveThresholdToTake = min(availablePermitsAboveThreshold, permitsToTake);
// TODO(cpovirk): Figure out a good name for this variable.
double length = permitsToTime(availablePermitsAboveThreshold)
+ permitsToTime(availablePermitsAboveThreshold - permitsAboveThresholdToTake);
/**
* 梯形的面积
*
* 高 * (顶 * 底) / 2
*
* 高是 permitsAboveThresholdToTake 也就是右侧需要消费的令牌数
* 底 较长 permitsToTime(availablePermitsAboveThreshold)
* 顶 较短 permitsToTime(availablePermitsAboveThreshold - permitsAboveThresholdToTake)
*/
micros = (long) (permitsAboveThresholdToTake * length / 2.0);
// 减去已经获取的在阈值右侧的令牌数
permitsToTake -= permitsAboveThresholdToTake;
}
// measuring the integral on the left part of the function (the horizontal line)
// 平稳时期的面积,正好是长乘宽
micros += (stableIntervalMicros * permitsToTake);
return micros;
}
private double permitsToTime(double permits) {
return stableIntervalMicros + permits * slope;
}
@Override
double coolDownIntervalMicros() {
// 每秒增加的令牌数为 warmup时间/maxPermits. 这样的话,在warmuptime时间内,就就增张的令牌数量
// 为 maxPermits
return warmupPeriodMicros / maxPermits;
}
}
maxPermits等于热身(warmup)期间能产生的令牌数,比如QPS=4,warmup为2秒,则maxPermits=8.halfPermits为maxPermits的一半。
maxPermits=8,halfPermits=4,和SmoothBursty相同的请求序列:
- t=0,这时候storedPermits=8,请求1个令牌,使用1个storedPermits消耗时间=1×(0.75+0.625)/2=0.6875秒;
- t=1,这时候storedPermits=8,请求3个令牌,使用3个storedPermits消耗时间=3×(0.75+0.375)/2=1.6875秒(注意已经超过1秒了,意味着下次产生新Permit时间为2.6875);
- t=2,这时候storedPermits=5,请求10个令牌,使用5个storedPermits消耗时间=1×(0.375+0.25)/2+4*0.25=1.3125秒,再加上额外请求的5个新产生的Permit需要消耗=5*0.25=1.25秒,即总共需要耗时2.5625秒,则下一次产生新的Permit时间为2.6875+2.5625=5.25,注意当前请求私海2.6875才返回的,之前一直阻塞;
- t=3,因为前一个请求阻塞到2.6875,实际这个请求3.6875才到达RateLimiter,请求1个令牌,storedPermits=0,下一次产生新Permit时间为5.25,因此总共需要等待5.25-3.6875=1.5625秒;
SmoothWarmingUp实现预热缓冲的关键在于其分发令牌的速率会随时间和令牌数而改变,速率会先慢后快。表现形式如下图所示,令牌刷新的时间间隔由长逐渐变短。等存储令牌数从maxPermits到达thresholdPermits时,发放令牌的时间价格也由coldInterval降低到了正常的stableInterval。
3.4、RateLimiter设计原理
- 考虑一下RateLimiter是如何设计的,并且为什么要这样设计.
- RateLimiter的主要功能就是提供一个稳定的速率,实现方式就是通过限制请求流入的速度,比如计算请求等待合适的时间阈值.
- 实现QPS速率的最简单的方式就是记住上一次请求的最后授权时间,然后保证1/QPS秒内不允许请求进入.比如QPS=5,如果我们保证最后一个被授权请求之后的200ms的时间内没有请求被授权,那么我们就达到了预期的速率.如果一个请求现在过来但是最后一个被授权请求是在100ms之前,那么我们就要求当前这个请求等待100ms.按照这个思路,请求15个新令牌(许可证)就需要3秒.
设计原理
- 首先先讲一下令牌桶的原理,每隔一段时间生产一个令牌放入桶里,请求在执行时需要拿到令牌才可以执行,如果拿不到令牌将等待令牌产生,一个生产者,多个消费者。
- 但是这样的令牌桶有一个问题,如果CPU负载过高,生产令牌的线程没有获取到时间片生产令牌,那么限制的流量将会比设定值更低。
- 可能是出于这个原因,guava并没有这样做,而是一个惰性生产令牌,每次请求令牌时,通过当前时间和下次产生令牌时间的差值计算出现在有多少个令牌,如果当前时间比发放时间大,会获得令牌,并且会生成令牌存储。如果令牌不够,则让线程sleep,并且将下次令牌产生时间更新成当前时间+sleep时间
四、总结
限流的目的是通过对并发访问/请求进行限速或者一个时间窗口内的的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务或进行流量整形。
使用 Semaphore 限流,在访问高峰期时,让请求线程阻塞,高峰期过去再释放许可,当然它只适合限制单机线程数量,并且仅是限制瞬时并发线程数,而不是限制资源数(例如连接数,对比 Tomcat LimitLatch 的实现)
Semaphore vs LimitLatch
Semaphore可以实现公平锁和非公平锁,LimitLatch不行
Semaphore一次可以获取多个信号量,LimitLatch每次调用AtomicLong的incrementAndGet获取资源,每次只能获取一个许可
- Semaphore有公平实现和非公平实现,使用AQS的state来维护设定的信号量,LimitLatch使用AtomicLong来维护指定阈值来限制并发数。
- Semaphore一次可以获取多个信号量,对资源管理的粒度更加细腻,LimitLatch每次调用AtomicLong的incrementAndGet获取资源,典型的应用场景是对连接数,访问量的限制。
- LimitLatch提供reset方法来重置AtomicLong的值为0.
- Semaphore设定初始值然后每次获取信号量递减,LimitLatch是从0开始每次递增直到设定阈值。
- 两者使用场景相似,都用于资源的并发数控制,Semaphore粒度更细,基本能使用LimitLatch,也可用Semaphore替代。
更多资源分享,请关注我的公众号:搜索或扫码 砥砺code