应用限流常用方案及项目实战

在高并发系统中我们通常需要考虑当请求量过大时,如果进行限流、降级,这里我们讨论下常用的限流方案,最后给出合理的实例

常用限流算法

  • 计数器法
  • 滑动窗口法
  • 漏桶算法
  • 令牌桶算法

计数器法

计数器法是实现起来最简单的一种算法。其思路是,比如比如我们规定某个接口在一分钟之内只能处理100个请求,那么每次有请求进来的时候我们按每分钟进行计数,当请求大于100个的时候就拒绝请求,如果到了第二分钟则重新从0开始计数,代码示例如下

    //固定map大小为5,超出最大数量时抛弃较早的元素
    static Map cache = Collections.synchronizedMap(new LinkedHashMap() {
        @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 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();
    }
}

这样就完成了限流的功能了

你可能感兴趣的:(个人笔记,java,spring,springboot,限流,实战)