SpringMVC 简单限流方案设计

一、概念

限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。

常用的限流算法有三种:计数器法漏桶算法令牌桶算法

计数器法是限流算法中最简单的一种算法,我们维护一个时间窗口比如 100s,设定阈值 10000 次,维护一个计数器,每次有新的请求过来,计数器加 1。这时候判断,如果计数器的值小于限流值,并且与上一次请求的时间间隔还在 100 秒内,允许请求通过,否则拒绝请求;如果超出了时间间隔,要将计数器清零。

漏桶算法的思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水,当水流入速度过大会直接溢出,可以看出漏桶算法能强行限制数据的传输速率。

令牌桶算法的原理是系统会以一个恒定的速度往桶里放入令牌,而如果请求需要被处理,则需要先从桶里获取一个令牌,当桶里没有令牌可取时,则拒绝服务。

不同限流算法的比较

计数器法 实现比较简单,特别适合集群情况下使用,但是它有一个很大的缺点,就是对临界流量不友好,限流不够平滑。假设这样一个场景,我们限制用户一分钟下单不超过 10 万次,现在在两个时间窗口的交汇点,前后一秒钟内,分别发送 10 万次请求。也就是说,窗口切换的这两秒钟内,系统接收了 20 万下单请求,这个峰值可能会超过系统阈值,影响服务稳定性。对计数器算法的优化,可以使用滑动窗口算法实现。

漏桶算法令牌桶算法,漏桶算法提供了比较严格的限流,令牌桶算法在限流之外,允许一定程度的突发流量。在实际开发中,我们并不需要这么精准地对流量进行控制,所以令牌桶算法的应用更多一些。至于为什么令牌桶算法可以允许一定的突发流量,可以参考知乎的这个回答:https://www.zhihu.com/question/299625415

如果我们设置的流量峰值是 permitsPerSecond=N,也就是每秒钟的请求量,计数器算法会出现 2N 的流量,漏桶算法会始终限制N的流量,而令牌桶算法允许大于 N,但不会达到 2N 这么高的峰值。

二、应用

使用 AtomicInteger 的计数器法实现

public class CounterLimiter {

    //初始时间
    private static long startTime = System.currentTimeMillis();
    //初始计数值
    private static final AtomicInteger ZERO = new AtomicInteger(0);
    //时间窗口限制
    private static final int interval = 10000;
    //限制通过请求
    private static int limit = 100;
    //请求计数
    private AtomicInteger requestCount = ZERO;

    //获取限流
    public boolean tryAcquire() {
        long now = System.currentTimeMillis();
        //在时间窗口内
        if (now < startTime + interval) {
            //判断是否超过最大请求
            if (requestCount.get() < limit) {
                requestCount.incrementAndGet();
                return true;
            }
            return false;
        } else {
            //超时重置
            requestCount = ZERO;
            startTime = now;
            return true;
        }
    }
} 

使用 RateLimiter 的令牌桶算法实现

Google 开源工具包 Guava 提供了限流工具类 RateLimiter,该类基于令牌桶算法来完成限流,非常易于使用。RateLimiter api 可以查看并发编程网 Guava RateLimiter 的介绍。

我们用 MVC 的拦截器 + Guava RateLimiter 实现我们的限流方案:

@Slf4j
public class RequestLimitInterceptor extends HandlerInterceptorAdapter implements BeanPostProcessor {

    private static final Integer GLOBAL_RATE_LIMITER = 10;

    private static Map<PatternsRequestCondition, RateLimiter> URL_RATE_MAP;

    private Properties urlProperties;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (URL_RATE_MAP != null) {
            String lookupPath = new UrlPathHelper().getLookupPathForRequest(request);
            for (PatternsRequestCondition patternsRequestCondition : URL_RATE_MAP.keySet()) {
                //使用spring DispatcherServlet的匹配器PatternsRequestCondition进行匹配
                //spring 3.x 版本
                //Set<String> matches = patternsRequestCondition.getMatchingCondition(request).getPatterns();
                //spring 4.x 版本
                List<String> matches = patternsRequestCondition.getMatchingPatterns(lookupPath);
                if (CollectionUtils.isEmpty(matches)){
                    continue;
                }
                //尝试获取令牌
                if (!URL_RATE_MAP.get(patternsRequestCondition).tryAcquire(1000, TimeUnit.MILLISECONDS)) {
                    log.info(" 请求'{}'匹配到 mathes {},超过限流速率,获取令牌失败。", lookupPath, Joiner.on(",").join(patternsRequestCondition.getPatterns()));
                    return false;
                }
                log.info(" 请求'{}'匹配到 mathes {} ,成功获取令牌,进入请求。", lookupPath, Joiner.on(",").join(patternsRequestCondition.getPatterns()));
            }
        }
        return super.preHandle(request, response, handler);
    }

    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
        return bean;
    }

    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
        if (RequestMappingHandlerMapping.class.isAssignableFrom(bean.getClass())) {
            if (URL_RATE_MAP == null) {
                URL_RATE_MAP = new ConcurrentHashMap<>(16);
            }
            log.info("we get all the controllers's methods and assign it to urlRateMap");
            RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping) bean;
            Map<RequestMappingInfo, HandlerMethod> handlerMethods = requestMappingHandlerMapping.getHandlerMethods();
            for (RequestMappingInfo mappingInfo : handlerMethods.keySet()) {
                PatternsRequestCondition requestCondition = mappingInfo.getPatternsCondition();
                // 默认的 url 限流方案设定
                URL_RATE_MAP.put(requestCondition, RateLimiter.create(GLOBAL_RATE_LIMITER));
            }
            // 自定义的限流方案设定
            if (urlProperties != null) {
                for (String urlPatterns : urlProperties.stringPropertyNames()) {
                    String limit = urlProperties.getProperty(urlPatterns);
                    if (!limit.matches("^-?\\d+$")){
                        log.error("the value {} for url patterns {} is not a number ,please check it ", limit, urlPatterns);
                    }
                    URL_RATE_MAP.put(new PatternsRequestCondition(urlPatterns), RateLimiter.create(Integer.parseInt(limit)));
                }
            }
        }
        return bean;
    }

    /**
     * 限流的 URL与限流值的 K/V 值
     *
     * @param urlProperties
     */
    public void setUrlProperties(Properties urlProperties) {
        this.urlProperties = urlProperties;
    }
}
@Configuration
public class WebConfig extends WebMvcConfigurerAdapter {

    @Bean
    public RequestLimitInterceptor requestLimitInterceptor(){
        RequestLimitInterceptor limitInterceptor = new RequestLimitInterceptor();
        // 设置自定义的 url 限流方案
        Properties properties = new Properties();
        properties.setProperty("/admin/**", "10");
        limitInterceptor.setUrlProperties(properties);
        return limitInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 限流方案
        registry.addInterceptor(requestLimitInterceptor());
    }
}

tips: 这边自定义限流列表 urlProperties 的方案不太合理,可以考虑放在配置中心(Nacos、Spring Cloud Config 等)去动态的更新需要限流的 url。

参考博文:

  1. https://blog.csdn.net/Lili429/article/details/79236819
  2. https://blog.csdn.net/valleychen1111/article/details/78038366
posted @ 2019-08-01 13:13  JMCui  阅读(2183)  评论(0编辑  收藏  举报