微服务就是将复杂的大应用拆分成小的应用,这样做的好处是各个应用之间独立开发、测试、上线,互不影响。但是服务拆分之后,带来的问题也很多,我们需要保障微服务的正常运行,就需要进行服务治理。常用手段有:鉴权、限流、降级、熔断等。
其中,限流是指对某个接口的调用频率进行限制,防止接口调用频率过快导致线程资源被耗尽从而导致整个系统响应变慢。限流在很多业务中都有应用,比如:秒杀、双11。当用户请求量过大时,就会拒绝后续接口请求,保障系统的稳定性。
接口限流的实现思路是:统计某个时间段内的接口调用次数,当调用次数超过设置的阈值时,就进行限流限制接口访问。
常见的限流算法有:固定时间窗口算法、滑动时间窗口算法、令牌桶算法、漏桶算法等,下面我们将一一介绍每种算法的实现思路和代码实现。
固定时间窗口限流算法的思路就是:确定一段时间段,在该时间段内统计接口的调用次数,来判断是否限流。
实现步骤如下:
选定一个时间起点,当接口请求到来时,
示意图如下:
(图片来源:https://time.geekbang.org/column/article/80388?utm_term=zeusNGLWQ&utm_source=xiangqingye&utm_medium=geektime&utm_campaign=end&utm_content=xiangqingyelink1104,下图同上)
这种限流算法的缺点是:无法应对两个时间窗口临界时间内的突发流量。
如下图:假设要求每秒钟接口请求次数不超过100,在第1s时间窗口内接口请求次数为100,但是都集中在最后10ms;第2s时间窗口内接口请求次数也为100,也都集中在最后10ms内;两个时间窗口请求次数都小于100,满足要求。但是在两个10ms内接口请求次数=200 > 100。如果这个次数不是200,是2000万,可能就会导致系统崩溃。
public class FixedWindowRateLimitAlg implements RateLimitAlg {
// ms
private static final long LOCK_EXPIRE_TIME = 200L;
private Stopwatch stopWatch;
// 限流计数器
private AtomicInteger counter = new AtomicInteger(0);
private final int limit;
private Lock lock = new ReentrantLock();
public FixedWindowRateLimitAlg(int limit) {
this(limit, Stopwatch.createStarted());
}
public FixedWindowRateLimitAlg(int limit, Stopwatch stopWatch) {
this.limit = limit;
this.stopWatch = stopWatch;
}
@Override
public boolean tryAcquire() throws InterruptedException {
int currentCount = counter.incrementAndGet();
// 未达到限流
if (currentCount < limit) {
return true;
}
// 使用固定时间窗口统计当前窗口请求数
// 请求到来时,加锁进行计数器统计工作
try {
if (lock.tryLock(LOCK_EXPIRE_TIME, TimeUnit.MILLISECONDS)) {
// 如果超过这个时间窗口, 则计数器counter归零, stopWatch, 窗口进入下一个窗口
if (stopWatch.elapsed(TimeUnit.MILLISECONDS) > TimeUnit.SECONDS.toMillis(1)) {
counter.set(0);
stopWatch.reset();
}
// 不超过, 则当前时间窗口内的计数器counter+1
currentCount = counter.incrementAndGet();
return currentCount < limit;
}
} catch (InterruptedException e) {
System.out.println("tryAcquire() wait lock too long:" + LOCK_EXPIRE_TIME + " ms");
throw new InterruptedException("tryAcquire() wait lock too long:" + LOCK_EXPIRE_TIME + " ms");
} finally {
lock.unlock();
}
// 出现异常 不能影响接口正常请求
return true;
}
}
固定时间窗口限流算法无法处理两个时间窗口临界值流量突增的情况。为了解决这个问题,我们可以稍微优化下固定时间窗口限流算法,通过限制任意时间窗口内(比如:1S)接口请求数都不超过某个阈值,这个优化后的算法就叫做滑动时间窗口限流算法。
滑动时间窗口限流算法将一个大的时间窗口分成粒度更小的时间窗口,每个子窗口独立统计次数。每经过一个子窗口的时间,整体窗口就向右滑动一格。
如上图所示,假设要求每分钟通过次数不超过100次,将1分钟分成6个10s的单元格。
第一个图中假设最后1个10s内(序号:6)通过请求次数为100次,第二个图中假设第1个10s (序号:7)内请求次数也通过100次。由于是滑动窗口,第一个窗口向右移动一格后,在第二个滑动窗口内,序号6、7两者加起来的请求次数为200>100,所以限流,从而解决了固定时间窗口无法处理两个窗口临界值的问题。
虽然滑动时间窗口算法可以保证任意时间窗口内接口请求次数不超过阈值,但是仍然无法避免更细粒度流量突增的场景,比如在某个10s内的单元格内流量突增无法立即被限流。同时,使用滑动窗口算法时,流量曲线如下,无法达到平滑过渡的效果,无法控制流量速度
可以使用循环队列来实现滑动时间窗口限流算法:
假设限流规则是任意1s内,接口请求数不超过N次。
创建一个N+1 (循环队列本身会浪费一个存储单元,所以是N+1)的循环队列,用来记录1S内的请求。
当有新的请求到来时,
- 将与该请求的时间间隔超过1s的请求从队列中移除(移动head指针);
- 再看循环队列中是否有空闲位置,如果有,则把新请求存储在队列尾部(tail指针所在位置,同时移动tail指针);
- 如果循环队列尾部没有空闲位置,说明这个1s内的请求次数已经超过限流次数N,拒绝后续服务。
假设1S内请求次数不能超过6次,整个队列分成(6+1)个单元格。
/**
* @author: wanggenshen
* @date: 2020/6/29 21:56.
* @description: 循环队列实现滑动窗口限流算法
*/
public class SlidingWindowLimiter {
private int windowSize;
private CircularQueue queue;
public SlidingWindowLimiter(int windowSize) {
this.windowSize = windowSize;
queue = new CircularQueue(windowSize);
}
public boolean tryAcquire(long now) {
// 判断是否有间隔1s的请求, 有则移除队列
while (queue.prevNode() != -1 && now - queue.prevNode() > 1000) {
System.out.println("超过1S间隔, 移除超过间隔的节点: " + queue.prevNode() + "当前时间: " + now + ", 间隔: " + (now - queue.prevNode()));
queue.dequeue();
}
// 队列已满, 拒绝访问
if (queue.isFull()) {
System.out.println("队列已满, now: " + now);
return false;
}
queue.enqueue(now);
return true;
}
static class CircularQueue {
/**
* 每次请求的时间戳
*/
private long[] timeQueue;
/**
* 队列大小
*/
private int size;
/**
* 头指针
*/
private int headIndex;
/**
* 尾指针
*/
private int tailIndex;
public CircularQueue(int size) {
// 循环队列尾部指针多占用一个单元格
timeQueue = new long[size + 1];
this.size = size + 1;
}
/**
* 入队
*/
public void enqueue (long timestamp) {
// 队列已满
if (isFull()) {
throw new RuntimeException("Exceed queue size.");
}
timeQueue[tailIndex] = timestamp;
tailIndex = (tailIndex + 1) % size;
}
/**
* 出队
*
* @return
*/
public long dequeue () {
// 队列为空
if (isEmpty()) {
return -1;
}
long timestamp = timeQueue[headIndex];
headIndex = (headIndex + 1) % size;
return timestamp;
}
public long prevNode() {
// 队列为空
if (isEmpty()) {
return -1;
}
return timeQueue[headIndex];
}
public boolean isFull() {
return (tailIndex + 1) % size == headIndex;
}
public boolean isEmpty() {
return tailIndex == headIndex;
}
}
}
实际上,当请求数超过阈值时,我们不希望后续流量被全部限流,而是希望将流量控制在一定速度内。
漏桶算法就是基于流控来控制流量。
如下图所示,调用方请求比作是水龙头出的水,水桶出的水是比作是接口提供方处理的请求。当水龙头出水速度大于桶里的水流出速度(类似接口调用请求频率过快),水直接溢出(类似请求直接被限流)。通过这种方法不仅能保证流量不会超过阈值,同时保证接口的请求数以稳定的速度去处理。
漏桶算法的优点在于能够控制接口提供方的接口被匀速处理;缺点在于设置的速率不当会影响接口处理的效率。
伪代码如下:
/**
* @author: wanggenshen
* @date: 2020/6/29 21:00.
* @description: 漏桶限流算法
*/
public class LeakyBucketLimiter {
/**
* 桶内剩余的水
*/
private long left;
/**
* 桶的容量
*/
private long capacity;
/**
* 一桶水漏完的时间
*/
private long duration;
/**
* 桶漏水的速率, capacity = duration*velocity
*/
private double velocity;
/**
* 上一次成功放入水桶的时间
*/
private long lastUpdateTime;
public boolean acquire() {
long now = System.currentTimeMillis();
// 剩余的水量 - 桶匀速漏出去的水
left = Math.max(0, left - (long)((now - lastUpdateTime) * velocity));
// 当前水桶再加一单位水没有溢出, 则可以继续访问
if (left++ <= capacity) {
lastUpdateTime = now;
return true;
} else {
return false;
}
}
}
令牌桶算法的实现原理是:
以恒定速率生成令牌放进令牌桶,令牌桶满了的时候就丢弃不再放入令牌桶;
如果想要处理请求,就需要从令牌桶中取一个令牌。能取出令牌则去处理请求;没有令牌则拒绝请求。
令牌桶算法与漏桶算法很类似,最主要的区别在于:
漏桶算法输入速率不定,但是输出速率恒定;令牌桶算法输出速率可以根据流量大小进行调整;
从接口处理者的角度看,漏桶算法只能以固定频率去处理请求(比如每秒只能处理1个请求,如果此时来了10个请求,漏桶需要花10s处理完);而令牌桶算法可以处理突发流量,比如来了20个请求,如果令牌桶中有>=20个令牌,那么处理者就可以一下子全部处理这20个请求;
/**
* @author: wanggenshen
* @date: 2020/6/29 21:00.
* @description: 令牌桶限流算法
*/
public class TokenBucketLimiter {
/**
* 令牌桶桶内剩余的令牌
*/
private long left;
/**
* 令牌桶的容量
*/
private long capacity;
/**
* 一桶水漏完的时间
*/
private long duration;
/**
* 令牌桶生产令牌的速率, capacity = duration*velocity
*/
private double velocity;
/**
* 上一次拿走令牌的时间
*/
private long lastUpdateTime;
public boolean acquire() {
long now = System.currentTimeMillis();
// 令牌桶余量 = 【上一次令牌桶剩余的令牌】+ 【(上一次拿走令牌到现在的时间段) * 每个单位时间生产令牌的速率 】
// 生产出的令牌 超过令牌桶的容量时, 则舍弃
left = Math.min(capacity, left + (long)((now - lastUpdateTime) * velocity));
// 若当前能够成功领取令牌, 则可以访问
if (left-- >= 0) {
lastUpdateTime = now;
return true;
} else {
return false;
}
}
}
生产环境下可以考虑使用Guava提供的令牌桶算法实现类: RateLimiter来进行限流,RateLimiter的实现是线程安全的。
生产环境下服务基本上分布式部署,那么在对服务进行限流时需要考虑到分布式限流。
最简单的做法是给每台应用服务器平均分配流控阈值,将分布式限流转换为单机限流。如总流量不超过1000次,那么5个服务实例,每个实例请求数不能超过200次。但是如果遇到流量不均匀(比如一台机器流量一直是10、另外几台> 200)、或者有一台宕机,那么另外几台平均下来就是250>200,这种做法不是很好。
常见的实现思路有两种:
一般使用中心化这种思路。
Sentinel提供了TokenServer,作为一个独立服务来统计总调用量、判断单个请求是否允许访问。应用服务器每次接收到请求后,都要与TokenServer进行一次通信,判断该次请求能否访问。
这种实现方式的好处是:由TokenServer集中管理每个服务实例的总调用量,服务实例不用关心请求的统计工作;
缺点是:非常依赖于TokenServer的性能,因为需要与其进行网络通信。同时需要关系TokenServer服务的单节点故障问题。
存储式流控是每个服务请求到来时,从第三方存储(如Redis、MySQL)读取接口请求数、然后再将请求数更新回缓存;
要设计一个高性能、高可靠性的分布式流控性能需要考虑网络通信、加锁同步等对性能带来的影响,同时也需要考虑分布式环境的可靠性。
参考:https://mp.weixin.qq.com/s/joP22Z8zblcDBAV1keSdJw