介绍
面对高并发的三大利器:限流、降级和缓存。这里就来谈谈限流。
为什么需要限流?当服务接口的QPS或者并发量超过了接口的承受能力时,可能会导致接口处理变慢,更为严重的,会导致应用崩溃。因此,需要对接口进行限流保护,不单单保护接口自身的应用,也保护接口所依赖的第三方资源。
限流的本质是对突发流量进行整形,从而实现保护系统的目的。
分类
按照限流类型来说,可分为并发数限流和QPS限流。
并发数限流:限制同一时刻的最大并发请求数量;
QPS限流:限制一段时间内的的请求数量,也就是说在一定的时间窗口内进行限制。如固定窗口限流、滑动窗口限流、漏桶和令牌桶。
按照作用的范围来说,可分为单机限流和分布式限流。
单机限流:针对单台机器进行限流;
分布式限流:针对集群进行限流,需要通过公共服务或者中间件(redis)来存储数据;
原理和代码实现
并发数限流
定义:限制同一时刻的并发数。缺点:只能在一个时间点对并发数进行限制,无法做到在时间段内的限流。这里分别采用原子类和Semaphore实现。
-
原子类实现的核心代码如下:
public class AtomicCountRateLimiter extends AbstractCountRateLimiter{ /** * 允许的最大并发数 */ private Long maxPermits; /** * 计数器 */ private AtomicLong count = new AtomicLong(); public AtomicCountRateLimiter(long maxPermits) { this.maxPermits = maxPermits; } /** * 获取许可 * @return true:正常,不会被限流 */ @Override public boolean tryAcquire() { if(count.incrementAndGet() > maxPermits) { return false; } else { return true; } } /** * 释放许可 */ @Override public void release() { count.decrementAndGet(); } }
-
Semaphore实现的核心代码如下:
public class SemaphoreCountRateLimiter extends AbstractCountRateLimiter{ /** * 允许的最大并发数 */ private Integer maxPermits; private Semaphore semaphore; public SemaphoreCountRateLimiter(int maxPermits) { this.maxPermits = maxPermits; semaphore = new Semaphore(maxPermits); } @Override public boolean tryAcquire() { return semaphore.tryAcquire(); } @Override public void release() { semaphore.release(); } }
固定时间窗口限流
并发数限流存在 无法针对时间段内的请求数(如QPS) 进行限制的缺点,因此可以通过固定窗口完成时间段内的限流。
-
代码实现如下:
维护原子变量reqCount表示当前请求数,时间戳lastVisitedTime表示上一次请求的时间。当来了新的请求后,判断当前时间戳和lastVisitedTime是否相差大于1s,是则重置reqCount并更新lastVisitedTime为当前的时间戳,否则直接进行请求数+1;
public class WindowsRateLimiter implements RateLimiter{ private AtomicLong reqCount; private Long maxPermits; private long lastVisitedTime = System.currentTimeMillis(); public WindowsRateLimiter(Long maxPermits) { this.maxPermits = maxPermits; reqCount = new AtomicLong(); } /** * 是否获得许可, 即不会被限流 * @return true 通过 */ @Override public synchronized boolean tryAcquire() { long now = System.currentTimeMillis(); if(now - lastVisitedTime > 1000) { reqCount.getAndSet(0); lastVisitedTime = now; } reqCount.incrementAndGet(); return reqCount.longValue() <= maxPermits; } }
-
缺点:固定窗口无法解决相邻时间的边界问题。比如限制最大的QPS为100,第一秒的后500ms来了100个请求,第二秒的前500ms来了100个请求,那么由第一秒的后500ms和第二秒的前500ms组成的1s内,最大的QPS已经为200了,但是没有进行限制。基于这个问题,引出了滑动时间窗口算法。
滑动时间窗口限流
-
基本思想:将1s进行拆分为10个时间段组成,每个时间段是100ms。当收到一个请求时,会在对应的时间段内对请求进行累加。维护一个定时任务,每隔100ms移动当前窗口的位置,并从总的计数中减去窗口中的计数。
-
解决了相邻时间的边界问题,因为并不是直接把当前请求数直接清0,而是淘汰掉最早的100ms内的计数,最终始终维护了1s内的时间窗口的计数。
-
问题
- 需要为每个窗口维护一个定时任务,浪费资源
- 窗口划分的问题,如果划分的太粗,依然会有边界问题,划分太细,定时任务需要按照毫秒或者微妙执行,资源消耗高,同时精度也越高
-
核心代码实现如下:
public class SlidingWindowRateLimiter implements RateLimiter { /** * 允许的最大请求数 */ private Long maxPermits; /** * 滑动窗口数组 */ private AtomicLong[] slidingWindows; /** * 窗口大小 */ private int windowSize; /** * 当前的请求数量 */ private AtomicLong reqCount = new AtomicLong(); /** * 当前处于滑动窗口的位置 */ private volatile int index; private ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(1); public SlidingWindowRateLimiter(long maxPermits, int windowSize) { this.maxPermits = maxPermits; this.windowSize = windowSize; slidingWindows = new AtomicLong[windowSize]; for (int i = 0; i < windowSize; i++) { slidingWindows[i] = new AtomicLong(); } index = 0; init(); } /** * 获得许可 * * @return true: 正常通过, false: 被限流 */ @Override public boolean tryAcquire() { slidingWindows[index].incrementAndGet(); reqCount.incrementAndGet(); return !isOverLimit(); } /** * 是否被限流 * * @return true:被限流 */ private boolean isOverLimit() { return reqCount.get() > maxPermits; } /** * 定时移动窗口位置, reqCount并不是直接清零, 所以不会有边界问题 */ private void init() { if(1000 % windowSize != 0) { throw new IllegalArgumentException("windowSize必须能够被1000整除"); } long period = 1000 / windowSize; scheduledExecutor.scheduleAtFixedRate(new Runnable() { @Override public void run() { index = (index + 1) % windowSize; long val = slidingWindows[index].getAndSet(0); reqCount.addAndGet(-val); } }, 0, period, TimeUnit.MILLISECONDS); } }
漏桶
待续
令牌桶
待续