介紹
面對高併發的三大利器:限流、降級和緩存。這裏就來談談限流。
爲什麼需要限流?當服務接口的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); } }
漏桶
待續
令牌桶
待續