在高併發系統中我們通常需要考慮當請求量過大時,如果進行限流、降級,這裏我們討論下常用的限流方案,最後給出合理的實例
常用限流算法
- 計數器法
- 滑動窗口法
- 漏桶算法
- 令牌桶算法
計數器法
計數器法是實現起來最簡單的一種算法。其思路是,比如比如我們規定某個接口在一分鐘之內只能處理100個請求,那麼每次有請求進來的時候我們按每分鐘進行計數,當請求大於100個的時候就拒絕請求,如果到了第二分鐘則重新從0開始計數,代碼示例如下
//固定map大小爲5,超出最大數量時拋棄較早的元素
static Map<String, AtomicInteger> cache = Collections.synchronizedMap(new LinkedHashMap<String, AtomicInteger>() {
@Override
protected boolean removeEldestEntry(final Map.Entry eldest) {
return size() > 5;
}
});
public static boolean hasGrant() {
String currentMinute = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm"));
AtomicInteger integer = cache.get(currentMinute);
if (integer == null) {
integer = new AtomicInteger();
cache.put(currentMinute,integer);
}else{
integer.incrementAndGet();
}
if(integer.get() > 100){
return false;
}else{
return true;
}
}
這樣實現雖然簡單,但有臨界的問題,比如在某分鐘的最後一秒、及下一份的第一秒瞬時有大量的請求,此時系統請求量會突然暴增,超過我們限制的每分鐘100個請求的初衷,因此有了第二種方案
滑動窗口法
在滑動窗口法中,我們對時間進行跟細力度的劃分,如把一分鐘分爲6個時間窗口,每格代表10s,每隔各自都有自己獨立的計數器,然後每隔10s窗口會滑動一格,這樣就解決了臨界的問題
由此可見,我們對每隔時間窗口劃分的越精細,限流的統計就會越精確,且計數器法是滑動窗口的一種只有一個窗口的實現
代碼代碼示例如下:
public class SlidingWindowLimiter {
//循環隊列,就是裝多個窗口用,數量是windowSize的2倍
private AtomicInteger[] timeSlices;
//隊列的總長度
private int timeSliceSize;
//每個時間片的時長,以毫秒爲單位
private int timeMillisPerSlice;
//共有多少個時間片(即窗口長度
private int windowSize;
//在一個完整窗口期內允許通過的最大閾值
private int threshold;
//該滑窗的起始創建時間,也就是第一個數據
private long beginTimestamp;
//最後一個數據的時間戳
private long lastAddTimestamp;
public SlidingWindowLimiter(int timeMillisPerSlice, int windowSize, int threshold) {
this.timeMillisPerSlice = timeMillisPerSlice;
this.windowSize = windowSize;
this.threshold = threshold;
// 保證存儲在至少兩個window,也就是讓窗口滑動起來
this.timeSliceSize = windowSize * 2;
reset();
}
private void reset() {
beginTimestamp = System.currentTimeMillis();
//窗口個數
AtomicInteger[] localTimeSlices = new AtomicInteger[timeSliceSize];
for (int i = 0; i < timeSliceSize; i++) {
localTimeSlices[i] = new AtomicInteger(0);
}
timeSlices = localTimeSlices;
}
private void clearFromIndex(int index) {
for (int i = 1; i <= windowSize; i++) {
int j = index + i;
if (j >= windowSize * 2) {
j -= windowSize * 2;
}
timeSlices[j].set(0);
}
}
//計算時間窗口的索引
private int locationIndex() {
long now = System.currentTimeMillis();
//如果當前的key已經超出一整個時間片了,那麼就直接初始化就行了,不用去計算了
if (now - lastAddTimestamp > timeMillisPerSlice * windowSize) {
reset();
}
return (int) (((now - beginTimestamp) / timeMillisPerSlice) % timeSliceSize);
}
public boolean hasGrant() {
int index = locationIndex();
//然後清空自己前面windowSize到2*windowSize之間的數據格的數據,
//如當前index爲5時,就清空6、7、8、1。然後把2、3、4、5的加起來就是該窗口內的總和
clearFromIndex(index);
int sum = 0;
// 在當前時間片裏繼續+1
sum += timeSlices[index].incrementAndGet();
//加上前面幾個時間片
for (int i = 1; i < windowSize; i++) {
sum += timeSlices[(index - i + timeSliceSize) % timeSliceSize].get();
}
lastAddTimestamp = System.currentTimeMillis();
return sum >= threshold;
}
private void print() {
for (AtomicInteger integer : timeSlices) {
System.out.print(integer + "-");
}
}
public static void main(String[] args) {
//1秒一個時間片,窗口共6個,即6秒允許8個請求
SlidingWindowLimiter limiter = new SlidingWindowLimiter(1000, 6, 8);
for (int i = 0; i < 100; i++) {
System.out.println(limiter.hasGrant());
limiter.print();
System.out.println("--------------------------");
try {
TimeUnit.MILLISECONDS.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
漏桶算法
漏桶算法的思路就是有一個固定的桶,桶裏面的水會以固定的速度流出去;當流入的水(即請求)沒有超過桶的容量時,請求正常,當桶滿了即被限流
漏桶算法限制了請求的速度,會讓一個藉口勻速的處理請求,所以不會有臨界的情況,適用於接口處理速度有限的情況
代碼示例如下
//桶容量,也就是說當處理速度有限時,我們能夠接受多少請求,大於這個值的請求則直接拒絕
public AtomicInteger capacity = new AtomicInteger(10);
//當前緩存的請求數量
public AtomicInteger water = new AtomicInteger(0);
//請求處理速度,結合下方 10/1000,可以理解爲1秒允許10個請求
public int rate = 10;
//最後一次請求時間
public long lastTime = System.currentTimeMillis();
public boolean hasGrant() {
long now = System.currentTimeMillis();
//計算當前水量
water = new AtomicInteger(Math.max(0, (int) (water.get() - (now - lastTime) * rate / 1000)));
lastTime = now;
if (capacity.get() - water.get() < 1) {
// 若桶滿,則拒絕
return false;
} else {
// 還有容量
water.incrementAndGet();
return true;
}
}
令牌桶算法
令牌桶算法的思路是,首先我們有一個固定容量的桶用於存放令牌(token),一開始桶是空的,token會以固定的速度往桶內填充,知道桶滿了,多餘的token會被丟棄;有請求時會嘗試從桶內獲取token,獲取失敗則被拒絕
這種算法稍微有點複雜,實現可以參考guava包中的RateLimiter類的實現
項目實戰
上面介紹了集中常用的算法,下面講一下在實際項目中,我們如何做限流降級
首先定義一個註解
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiterAnno {
/**
* 每秒允許的請求數量
* @return
*/
double permitsPerSecond() default 30;
/**
* 超時時間,單位毫秒
* 如果N秒內不能成功訪問,則進入失敗邏輯
* @return
*/
long timeout() default 2L;
}
此註解可以加在我們想要限流的方法上,如
@RateLimiterAnno(permitsPerSecond = 0.1)
@RequestMapping(value = "/adinfo/bonusRule")
public List bonusRule() {
//do something
return new ArrayList();
}
然後我們定義一個切面用於限流
@Slf4j
@Aspect
@Component
public class RateLimiterAdvice {
private Map<String, RateLimiter> rateLimiterMap = new ConcurrentHashMap<>();
@Around(value = "execution(* com.kevindai.core.controller.*Controller.*(..))")
public Object rateLimit(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method method = methodSignature.getMethod();
RateLimiterAnno limiter = method.getAnnotation(RateLimiterAnno.class);
if (limiter != null) {
RequestAttributes ra = RequestContextHolder.getRequestAttributes();
ServletRequestAttributes sra = (ServletRequestAttributes) ra;
HttpServletRequest request = sra.getRequest();
String requestURI = request.getRequestURI();
String contentType = request.getContentType();
String path = requestURI + "_" + contentType;
RateLimiter rateLimiter = null;
if (rateLimiterMap.containsKey(path)) {
rateLimiter = rateLimiterMap.get(path);
} else {
rateLimiter = RateLimiter.create(limiter.permitsPerSecond());
rateLimiterMap.put(path, rateLimiter);
}
boolean tryAcquire = rateLimiter.tryAcquire(limiter.timeout(), TimeUnit.MILLISECONDS);
if (tryAcquire) {
return joinPoint.proceed();
} else {
log.warn("method {} cann't accept so many request", requestURI);
throw new SystemBussyException(ErrorCode.SYSTEM_BUSY);
}
}
return joinPoint.proceed();
}
}
這樣就完成了限流的功能了