众所周知,谈到高并发系统开发(比如双十一、秒杀活动、12306春运等)的时候,我们通常会使用三种方式来优化保护我们的系统,分别是缓存、降级和限流。
抛开缓存和降级,这次主要说一下限流。那么什么叫「限流」呢?
我们平时也经常提到各种「限流」,普通生活中比如坐地铁或者去游乐场的时候,为了限制每一班次或者每一游戏的人数,会进行限流排队。
所以顾名思义,「限流」就是限制流量,技术上来说通过对并发访问或者请求进行限速或者一个时间窗口内的请求进行限速来保护系统。在系统达到了限制的临界点,就可以采用拒绝服务、排队、或者等待的方式来保护现有系统,不至于因为大量请求导致系统发生雪崩现象。
常见的限流方式很多,从类型上可以分为QPS限流及并发数限流。
计数器算法是限流算法里最简单也是最容易实现的一种算法。主要用来限制一定时间内的总并发数,常用的地方比如数据库连接池、线程池、秒杀的并发数;计数器限流只要一定时间内的总请求数超过设定的阀值则进行限流。
比如我们规定,对于A接口来说,我们1分钟的访问次数不能超过100个。那么我们可以这么做:在一开 始的时候,我们可以设置一个计数器counter,每当一个请求过来的时候,counter就加1,如果counter的值大于100并且该请求与第一个请求的间隔时间还在1分钟之内,那么说明请求数过多;如果该请求与第一个请求的间隔时间大于1分钟,且counter的值还在限流范围内,那么就重置 counter。
缺点:这个算法虽然简单粗暴,但是有一个十分致命的问题,那就是临界问题。假设有一个恶意用户,他在0:59时,瞬间发送了100个请求,并且1:00又瞬间发送了100个请求,那么其实这个用户在 1秒里面,瞬间发送了200个请求。我们刚才规定的是1分钟最多100个请求,也就是每秒钟最多1.7个请求,用户通过在时间窗口的重置节点处突发请求, 可以瞬间超过我们的速率限制。用户有可能通过算法的这个漏洞,瞬间压垮我们的应用。
解决方案:我们能看出上面说的这类问题是由于精度的原因导致出现的问题,所有可以使用滑动窗口,滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
令牌桶我们可以理解成是一个存放固定容量令牌的桶。所有的请求在处理之前都需要拿到一个可用的令牌才会被处理。
根据限流大小,设置按照一定的速率往桶里添加令牌。桶设置最大的放置令牌限制,当桶满时、新添加的令牌就被丢弃。
请求是否被处理要看桶中令牌是否足够,当令牌数减为零时则拒绝新的请求。请求达到后首先要获取令牌桶中的令牌,拿着令牌才可以进行其他的业务逻辑,处理完业务逻辑之后,将令牌直接删除。
所以令牌桶允许一定程度突发流量,只要有令牌就可以处理,支持一次拿多个令牌。令牌桶中装的是令牌。
漏桶算法其实也很简单,顾名思义我们可以理解成一个固定容量的漏桶。然后请求就是一个注水漏水过程,往桶中以一定速率流出水(请求),以任意速率流入水(请求),当流入的水(请求)超过漏桶本身的容量时,则新流入的水(请求)自然就被丢弃了。
所以漏桶是一个具有固定容量、固定流出速率的队列,漏桶限制的是请求的流出速率。漏桶中装的是请求。漏桶算法保证了整体的速率。
当前主流的使用还是令牌桶或者漏桶偏多。
但是由上面来看可以知道,令牌桶的特色就是以“恒定”的速率创建令牌,访问的请求获取令牌的速率是“不确定”的,当前剩的有多少令牌就发多少,没有令牌了就只能等着。
而漏桶的特色是以“恒定”的速率处理请求,但是请求流入桶内的速率是“不确定”的。处理不完的自然就被抛弃了。
从这两个算法的特点来看,漏桶的天然特性决定了它是不会发生突发流量的。如果系统设置了100QPS的限制,无论QPS是100、1000、还是10000,系统永远以100进行处理;而令牌桶则不一样,它的特性是预留了一定量的令牌,如果出现突发流量的时候,在短时间内可以消耗所有的令牌,对于突发流量的处理效率是比漏桶要高的,当然同时对后台系统的压力也会增加。
所以如果需要处理突然的大流量时,使用令牌桶更加适用;如果只需要保证系统单位时间流量或者更注重系统的稳定性,那么选择漏桶算法肯定没错。
并发数限流主要是针对单机限流的,简单理解起来就是限制了同一时刻的并发数,实际生产环境使用到的场景可能没有那么多。
简单来说,如果不考虑线程安全的话,使用一个int变量就可以实现。下面是一个限制了100并发数的例子。
private static final int REQ_LIMIT_LIMIT = 100;
private static int nowReq = 0;
public static void currentLimit() {
if (nowReq >= REQ_LIMIT_LIMIT) {
return;
}
nowReq++;
//调用接口
try {
invokeFunc();
} finally {
nowReq--;
}
}
当前如果直接这么使用,多个请求过来的时候,nowReq自增自减的时候肯定会有线程安全问题,所以我们需要加锁或者使用Atomic原子类。这次我们可以使用AtomicInteger自旋实现。
private static final int REQ_LIMIT_LIMIT = 100;
private static AtomicInteger nowReq = new AtomicInteger(0);
public static void currentLimit() {
while (true) {
int currentReq = nowReq.get();
if (currentReq >= REQ_LIMIT_LIMIT) {
return;
}
if (nowReq.compareAndSet(currentReq, currentReq + 1)) {
break;
}
}
//调用接口
try {
invokeFunc();
} finally {
nowReq.decrementAndGet();
}
}
这里是加了一个cas自旋,现在勉强能实现我们的功能需求了,不过要是熟悉JUC包的同学肯定会说,这个不是Semaphore做的事吗?
平时有看JUC包或者了解AQS组件的同学,应该对Semaophore不会太陌生。先回顾下AQS的定义
AQS 是java.util.concurrent.locks 包下面的一个类,它提供了一个FIFO队列,可以看成是一个用来实现同步锁以及其他涉及到同步功能的核心组件,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,就比如 ReentrantLock、Semaphore、CountDownLatch、CycleBarrier、CyclicBarrier等,其他的诸如 ReentrantReadWriteLock,SynchronousQueue,FutureTask 等等皆是基于 AQS 的。当然,我们自己也能利用 AQS 非常轻松容易地构造出符合我们自己需求的同步器。
CountDownLatch (倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行。
任务分N个子线程执行,state就初始化为N,N个线程并行执行,每个线程执行完之后 countDown() 一次,state 就会CAS减1,当N子线程全部执行完毕,state = 0,hui unpark() 主调动线程,主调用线程就会从await()函数返回,继续之后的动作。
CyclicBarrier(循环栅栏): CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和 CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。
Semaphore(信号量)-允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源。
在AQS组件中我们平时一般是用来进行并发调用,所以CountDownLatch和CyclicBarrier的使用场景较多。
而上面说到我们可以通过Semaphore进行限流,这是一种通过限制并发数进行达到限流的目的。Semaphore(信号量)并不是 Java 语言特有的,信号量模型属于并发领域中一个重要的编程模型,几乎所有的支持并发的编程语言都有。模型如下图所示:
可以看出整个信号量模型是比较简单的:一个计数器、一个等待队列、三个方法。
计数器:记录当前还可以运行多少个资源访问资源。
队列:待访问资源的线程
三个方法:
init():初始化计数器的值,可就是允许多少线程同时访问资源。
up():计数器加1,有线程归还资源时,如果计数器的值大于或者等于 0 时,从等待队列中唤醒一个线程。
down():计数器减 1,有线程占用资源时,如果此时计数器的值小于 0 ,线程将被阻塞。
这三个方法都是原子性的,由实现方保证原子性。例如在 Java 语言中,JUC 包下的 Semaphore 实现了信号量模型,所以 Semaphore 保证了这三个方法的原子性。
Semaphore 基于 AQS 接口实现信号量模型的。利用了一个 int的state 来表示状态,通过类似 acquire(down方法) 和 release(up方法) 的方式来操纵状态。
Semaphore 内部有一个计数器在初始化之后,就可以调用 acquire方法,获取信号量,这时计数器将会减 1。如果此时计数器值小于 0,则会将当前线程阻塞,并且加入到等待队列,否则当前线程继续执行;执行结束之后,调用 release方法,释放信号量,计数器将会加 1。那如果此时计数器值的小于或等于0,则会唤醒的等待队列一个线程,然后将其移出队列。
并发流量通过 Semaphore进行限流,只有拿到信号量才能继续执行,保证后端资源访问数总是在安全范围。
在 Semaphore 类中,实现了两种信号量:公平的信号量和非公平的信号量,公平的信号量就是大家排好队,先到先进,非公平的信号量就是不一定先到先进,允许插队。非公平的信号量效率会高一些,所以默认使用的是非公平信号量。
Semaphore limit = new Semaphore(5);
ExecutorService threadPool = new ThreadPoolExecutor(10, 10, 1,
TimeUnit.SECONDS, new LinkedBlockingQueue<>(), new ThreadFactoryBuilder()
.setNameFormat("Thread-%d")
.build());
for (int i = 0; i < 10; i++) {
threadPool.execute(() -> {
try {
limit.acquire();
System.out.println(Thread.currentThread().getName() + " START");
Thread.sleep(new Random().nextInt(50));
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + " END");
limit.release();
}
});
}
threadPool.shutdown();
其中Semaphore的acquire和release方法是需要成对出现的,否则会导致程序假死。
输出结果如下图,可以看到同一时刻最多只有5个线程在执行任务,所以就实现了限流:
但是不是说这种方式就很完美了呢,也不是,Semaphore限流虽然很方便,但是也有自己的缺陷。
@Component
public class LimitInterceptor extends HandlerInterceptorAdapter {
Semaphore concurrencyLimit;
public LimitInterceptor() {
this.concurrencyLimit = new Semaphore(5);
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
concurrencyLimit.acquire();
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
concurrencyLimit.release();
}
}
如上面代码所示,我们在SpringMVC的拦截器中使用了限流器,请求进来的时候会经过拦截器先执行preHandle方法,这里调用了获取到的信号量方法。
然后再请求逻辑完成之后拦截器又调用了afterCompletion方法释放了信号量。完成了请求的限流。
我们写一个接口,内部逻辑是休眠100ms,模拟内部接口的耗时。
@RestController
public class WelcomeController {
@GetMapping("/api/v1/hello")
@SneakyThrows
public String hello() {
Thread.sleep(100L);
return "hello";
}
}
然后使用Jmeter同时发起500并发请求对接口进行压测,压测结果如图:
看到虽然请求内部逻辑耗时只有100s,但是整体压测的接口平响达到了2.4s,90分位的平响达到了4s!
随着并发数的增大,接口平响也增加得越来越大。所以如果在生产环境使用Semaphore限流器会随着请求数的增大接口的平响会越来越长,引起大规模的接口超时。
原因其实大家应该也猜到了Semaphore的acquire方法阻塞了线程,因为获取不到足够的信号量,默认Semaphore还是采用非公平锁,在高并发请求下如果线程竞争资源激烈,部分运气比较好的线程耗时还行,有些运气不好的线程可能会一直被阻塞,直到拿到信号量才能继续执行。
很多场景下,我们对于请求如果超出限流的当然还是直接拒绝而不是一直阻塞等待,所以可以使用Semaphore的tryAcquire方法,这个方法再获取不到信号量的时候就直接返回了false。
@Component
public class LimitInterceptor extends HandlerInterceptorAdapter {
Semaphore concurrencyLimit;
public LimitInterceptor() {
this.concurrencyLimit = new Semaphore(5);
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
if (!concurrencyLimit.tryAcquire()) {
response.getWriter().println("ERROR");
return false;
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
Object handler, Exception ex) throws Exception {
concurrencyLimit.release();
}
}
这次再使用jmeter模拟高并发请求,可以看到同样是500并发,这次的平响就只有390ms左右了,当然大量的请求应该是直接就报false退出了:
所以我们如果需要快速实现一个单机简易限流器,那么使用Semaphore再好不过,JUC下面还是有很多类是值得我们学习借鉴的。但是用的时候一定也要考虑场景,到底是选择堵塞式的acquire方法,还是快速失败的tryAcquire方法。胡乱选择可能会给系统带来大的灾难。
这篇的重点是介绍JUC包的Semaphore(信号量)类,不过由于平时使用的场景比较少,CountDownLatch和CyclicBarrier才是平时使用的主力。所以连带着介绍了下限流,整体来说限流就分为QPS限流及并发数限流这两大类。平时因为涉及到我们的系统还会有调用其他的mysql、redis等第三方服务或者第三方接口,所以一般还是使用QPS限流。
另外如果你要说并发数限流这种只能是单机,局限性太大,其实也不是,如果没有第三方服务的依赖,分布式的系统也可以选择Semaphore,毕竟只是一个全局变量的计数器限制,换成redis也是能用的,还简单快捷了许多对吧。