應用限流常用方案及項目實戰

在高併發系統中我們通常需要考慮當請求量過大時,如果進行限流、降級,這裏我們討論下常用的限流方案,最後給出合理的實例

常用限流算法

  • 計數器法
  • 滑動窗口法
  • 漏桶算法
  • 令牌桶算法

計數器法

計數器法是實現起來最簡單的一種算法。其思路是,比如比如我們規定某個接口在一分鐘之內只能處理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();
    }
}

這樣就完成了限流的功能了

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章