【整理】限流算法及实现解析
大流量杀手是造成故障的一大主因。限流是处理大流量的一种常见手段。
在“技术思考框架:抽象-思路-考量-优化-细节” 一文中提出了一个基于技术体系结构的“抽象-思路-考量-优化-细节”的技术思考框架。本文应用这一框架来思考和分析限流问题。
抽象
[T, R, limit]: 在指定 T 时间间隔内,将流量处理速率 R 限制在一个可接受的范围 limit 内。通常 T 为 1s。
考量
- 稳定性:应用不会因为大流量而崩溃,无法提供服务。考虑应用的正常承载能力与大流量的差距,采用合适措施来消解差距带来的负面影响。
- 正常服务:对于正常范围内的流量,可以正常响应和正常体验;不会因为大流量而导致系统阻塞,难以响应正常流量范围的请求或者服务体验变差。
- 极端情况:高并发流量往往是极短时间范围内的尖峰流量,几秒或几分钟左右。限流要能应对尖峰流量。
- 误差范围:工程上通常不考虑精确值,而是考虑误差范围,因为应用服务能力的 QPS 也在一个范围内波动,而不是一个精确值。
- 返回策略:当被限流时,采取的策略;返回 false 还是直接阻塞应用(线程/进程)。
- 多余流量:超出限流范围的流量的处理策略,丢弃、延迟处理或者其它。
思路
计数器算法
指定时间内计数,计数达到了就限流。可以使用原子类实现。需要一个定时器在指定时间点将计数器清零。实现简单,适用流量分布比较均衡的情形。在时间临界点,无法实现限流。
滑动窗口算法
切分时间窗格,分而治之。可解决临界大并发问题。切分窗口足够细,才能做到精确控制限流值。能解决时间临界点的限流,但无法应对尖峰流量。
漏桶算法
桶的容量恒定,以固定速率流出。如果流入数量超过桶的容量,则丢弃请求。桶可以用有界队列实现。由于处理速率恒定,可控制出口,用于削锋和平滑流量。若有尖峰流量,会导致大量请求被丢弃。可以用“漏桶+缓冲区”的组合,多出来的就放在缓冲区里,待漏桶处理完后再放入。
令牌桶算法
获得令牌作为业务可执行的许可,桶作为限流的容器。以固定速率增加令牌值,直到令牌桶的最大值。获取令牌则可执行,获取不到则阻塞或直接返回。使用一个有界阻塞队列保存令牌,一个定时任务用等速率生成令牌放入队列,访问量进入系统时,从队列获取令牌再进入系统。令牌桶可以做到最大流量控制,不过不能做到固定流速输出。
优化
- 平滑过渡:应用流量往往不是均匀的,有时空闲有时繁忙。应用从空闲到繁忙的过程中,服务器资源预热需要一定的准备时间,需要平滑过渡,避免直接遭遇大流量的侵袭而崩溃。这是 WarmUp 方式限流的来源。
实现
com.google.common.util.concurrent 包下的 RateLimiter 是一个限流器,一般用于瞬时大流量场景下对请求流量进行指定速率的限制,将流量速率控制在应用能够处理的范围内,保持应用的稳定性。RateLimiter 接受每秒 N 个请求,N 是可配置的。RateLimiter 的思路是:
- 由于要满足在指定时间内的速率限制要求,请求来临时间点及请求申请许可数的不确定性, RateLimiter 要做的是对过去请求及所消耗时间的建模。
- 基本逻辑:如果 QPS = 5, 那么申请一个许可的每两个相邻的请求相隔 200ms ( stableIntervalMicros )。如果已经够了,则要立即处理;如果不够,则要等待满足 200ms 再处理。申请 N 个许可的当前请求与最近一个请求应该相隔 N * 200ms 。
- “Past underutilization” :空闲时段的许可建模,使用 storedPermits 来表达。Past underutilization 意味着过去一段时间请求量非常少,利用率低,且服务器可能需要预热(比如建立连接、加载缓存)处理。storedPermits 用来表示在过去一段空闲时间内储存的令牌数(可以立即使用)。对于每一次申请许可的请求,RateLimiter 从两个地方获取许可:1. storedPermits ; 2. fresh permits 。
- 对于每一次申请 permits 的请求,总是需要根据上一次计算得到的 nextFreeTicketMicros 来计算此次需要等待的时间 microsToNextFreeTicket (delay of current request) ,若大于 0 则要休眠。
RateLimiter 支持两种方式:带热身的 warmUp 方式和支持突发流量的 Bursty 方式。默认 Bursty。
- RateLimiter 的计算是以微秒 toMicros 为单位的。限流计算主要是关于时间和数量的。RateLimiter 在初始化时会启动一个计时器,所有的时间都是相对于启动时刻点的相对纳秒数。
- resync: 如果当前请求姗姗来迟(当前时间点迟于上一个请求计算得到的 nextFreeTicketMicros ),则计算可以储存的令牌数 storedPermits。这个方法完成了空闲时间建模。
- reserveNextTicket:核心方法。根据上一个请求计算的 nextFreeTicketMicros 来计算当前请求需要延迟的时间以及下一个请求的必须等待的时间点 nextFreeTicketMicros。由于 RateLimiter 采用的是预消费模式,因此,每一个当前请求会受到上一个请求的执行时间的影响。
- storedPermitsToWaitTime:从 storedPermits 获取所需要的 permitsToTake 需要等待的时间。对于 Bursty 来说是 0, 对于 warmUp 来说是通过函数计算的(从 halfMaxPermits 到 maxPermits 的一条倾斜直线,斜率 slope = 2*stableIntervalMicros/halfMaxPermits )。这是两种限流方式的最主要区别。
- maxPermits: Bursty 通过指定秒数来指定最大 maxPermits, maxPermits = secs * permitsPerSecond; 而 warmUp 通过热身时间来指定最大 maxPermits, maxPermits = warmUpTime * permitsPerSecond.
- RateLimiter 支持并发。使用了 synchronized(object) 的方式,通过减少持锁逻辑增大并发度。
RateLimiter 在获取不到许可时,会调用 sleep 使线程进入可中断的休眠状态。
考虑到有些读者暂时无法获取代码,代码量也不多,这里将 RateLimiter 核心实现代码拷贝如下。在 “Java RateLimiter” 可以找到更多关于限流的实现。Github 是个好东东。
public boolean tryAcquire(int permits, long timeout, TimeUnit unit) {
long timeoutMicros = unit.toMicros(timeout);
checkPermits(permits);
long microsToWait;
synchronized (mutex) {
long nowMicros = readSafeMicros();
if (nextFreeTicketMicros > nowMicros + timeoutMicros) {
return false;
} else {
microsToWait = reserveNextTicket(permits, nowMicros);
}
}
ticker.sleepMicrosUninterruptibly(microsToWait);
return true;
}
private static void checkPermits(int permits) {
Preconditions.checkArgument(permits > 0, "Requested permits must be positive");
}
private void resync(long nowMicros) {
// if nextFreeTicket is in the past, resync to now
if (nowMicros > nextFreeTicketMicros) {
storedPermits = Math.min(maxPermits,
storedPermits + (nowMicros - nextFreeTicketMicros) / stableIntervalMicros);
nextFreeTicketMicros = nowMicros;
}
}
private long reserveNextTicket(double requiredPermits, long nowMicros) {
resync(nowMicros);
long microsToNextFreeTicket = nextFreeTicketMicros - nowMicros;
double storedPermitsToSpend = Math.min(requiredPermits, this.storedPermits);
double freshPermits = requiredPermits - storedPermitsToSpend;
long waitMicros = storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
+ (long) (freshPermits * stableIntervalMicros);
this.nextFreeTicketMicros = nextFreeTicketMicros + waitMicros;
this.storedPermits -= storedPermitsToSpend;
return microsToNextFreeTicket;
}
// warmUp 方式的 storedPermitsToWaitTime ; Bursty 方式直接返回 0。
@Override
long storedPermitsToWaitTime(double storedPermits, double permitsToTake) {
double availablePermitsAboveHalf = storedPermits - halfPermits;
long micros = 0;
// measuring the integral on the right part of the function (the climbing line)
if (availablePermitsAboveHalf > 0.0) {
double permitsAboveHalfToTake = Math.min(availablePermitsAboveHalf, permitsToTake);
micros = (long) (permitsAboveHalfToTake * (permitsToTime(availablePermitsAboveHalf)
+ permitsToTime(availablePermitsAboveHalf - permitsAboveHalfToTake)) / 2.0);
permitsToTake -= permitsAboveHalfToTake;
}
// measuring the integral on the left part of the function (the horizontal line)
micros += (stableIntervalMicros * permitsToTake);
return micros;
}
使用
public class RateLimiterTest {
private static Log log = LogFactory.getLog(RateLimiterTest.class);
public static void main(String[]args) {
log.info("-----------testBurstyRateLimiter---------");
testBurstyRateLimiter();
log.info("-----------testWarmupRateLimiter---------");
testWarmupRateLimiter();
}
public static void testBurstyRateLimiter() {
RateLimiter rateLimiter = RateLimiter.create(5);
for (int i=0; i<20; i++) {
rateLimiter.acquire();
String info = String.format("%d*%d=%d",i,i, i*i);
log.info(info);
}
RateLimiter rateLimiter2 = RateLimiter.create(5);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
// do nothing
}
for (int i=0; i<20; i++) {
rateLimiter2.acquire();
String info = String.format("second %d*%d=%d",i,i, i*i);
log.info(info);
}
}
public static void testWarmupRateLimiter() {
RateLimiter rateLimiter = RateLimiter.create(5, 10L, TimeUnit.SECONDS);
for (int i=0; i<20; i++) {
rateLimiter.acquire();
String info = String.format("%d*%d=%d",i,i, i*i);
log.info(info);
}
RateLimiter rateLimiter2 = RateLimiter.create(5, 10L, TimeUnit.SECONDS);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
// do nothing
}
for (int i=0; i<20; i++) {
rateLimiter2.acquire();
String info = String.format("second %d*%d=%d",i,i, i*i);
log.info(info);
}
}
}