【限流02】限流算法实战篇 - 手撸一个单机版Http接口通用限流框架

本文将从需求的背景、需求分析、框架设计、框架实现几个层面一步一步去实现一个单机版的Http接口通用限流框架。

一、限流框架分析

1、需求背景

微服务系统中,我们开发的接口可能会提供给很多不同的系统去调用,如果调用方处理不当(如:秒杀场景下的流量突增) 导致接口的请求数突增,这些请求与正常请求会去竞争系统的线程资源,最终可能导致正常请求因分配不到线程资源而出现大量接口超时的现象。

对于这种问题的解决思路是,作为接口提供方,我们需要限制每个接口调用方的调用频率,避免出现某个接口调用次数频率过快导致线程资源耗尽的问题。
【限流02】限流算法实战篇 - 手撸一个单机版Http接口通用限流框架_第1张图片

2、需求分析

(1)功能性需求

为了完成一个通用的限流框架,大概的执行步骤如下:

  • 限流框架启动,读取并加载限流规则;
  • 收到调用方请求,根据接口读取配置的限流规则,判断是否会被限流;
    【限流02】限流算法实战篇 - 手撸一个单机版Http接口通用限流框架_第2张图片

(2)非功能性需求

作为一个通用的限流框架,非功能性需求通常需要考虑到框架的易用性、容错性、性能、扩展性、灵活性

  • 易用性:对于使用者来说,第三方框架一般都希望能够尽量简单、易接入。因此,在限流规则配置方面,我们希望能够支持xml/yaml/properties等文件配置、也可以支持分布式配置源;对于限流接口,我们希望能够拿来即用;对于限流算法,我们希望能够提供多种限流算法,如固定时间窗口、滑动时间窗口、分布式限流算法等;因为现在大部分都是基于Spring开发的框架,所以我们希望限流框架能够很方便地集成使用到Spring框架中去;
  • 扩展性、灵活性:我们需要考虑到框架的扩展性,能够灵活支持各种限流算法、以及自定义的限流算法;对于限流规则,我们希望支持不同格式(Json、Yaml、Xml等)、不同数据源(本地配置或ZK等配置)的限流规则配置方式;
  • 性能:每个接口在调用之前都要被检查是否限流,这会增加接口请求的响应时间,因此我们需要尽可能减少限流框架本身对接口的响应时间的影响;
  • 容错性:接入限流框架的目的是为了提高系统的可用性和稳定性,所以不能因为限流框架的异常导致影响到服务本身的可用性。

二、限流框架设计

框架设计这块我们主要工作是划分模块、对模块进行设计。通用限流框架我们主要分成限流规则、限流算法、限流模式、集成使用4个模块来设计。

1、限流规则

框架需要定义好限流规则的语法格式,包括调用方标识、需要限流的接口、限流的阈值、时间粒度、限流算法、限流模式等元素。
为了实现简单点,本期我们不考虑限流算法配置、限流模式配置等,需要限制调用方app1在一分钟内,调用接口 /v1/user 的次数不能超过100次的限流配置示例如下:

configs:
- appId: app1
  limits:
  - api: /v1/user
    limit: 100
    unit: 60

对于文件格式,我们支持YAML/XML/JSON等格式;
对于数据源,我们支持本地配置,也支持其他的配置中心数据源,如Nacos。

2、限流算法

常见的限流算法有:固定时间窗口限流算法、滑动时间窗口限流算法、令牌桶限流算法、漏桶限流算法等。
基本思路就是:在一定时间内统计某个应用调用某个接口的次数,当调用次数超过阈值,则限流。
默认情况下,我们使用固定时间窗口限流算法。但是为了方便扩展,我们需要预先做好设计预留好扩展点,方便今后开发其他限流算法。

3、限流模式

限流模式分为单机限流和集群限流。
单机限流指的是针对某个服务的单个实例的访问次数进行限制;集群限流是对某个服务的多个实例调用总次数进行限流。
单机限流和集群限流的区别在于接口访问计数器的实现。单机限流只需要在单个实例中维护自己的接口请求计数器,而集群限流需要管理所有的实例计数器,这就需要一个第三方存储(如:Redis)来存储每个实例的接口访问次数。

4、集成使用

因为大部分接口调用方、提供方都是基于Spring框架实现的,所以我们可以开发一个类似于Mybatis-Spring类库,方便在使用Spring框架的项目中集成使用限流框架。
除了以上3个模块,我们还需要考虑到框架的容错性、性能等方面。对于集群限流,为了避免因为限流框架自身响应时间过长影响接口的响应时间,我们可以基于Redis去开发实现集群限流框架;对于限流框架自身可能抛出的异常,我们需要区别对待,如框架代码异常,我们直接抛出,同时不能影响接口使用。

三、限流框架实现

1、最小原型(MVP)代码

在开发框架的时候,我们没必要想着完成框架所需要的所有功能,也没必要使用设计模式、原则去实现一个优秀的框架。第一版本的代码可以在不考虑代码设计和质量的情况下,完成所需要的功能。一个基本的限流框架所需要的功能如下:

  • 对于接口类型,只支持HTTP接口的限流,暂不支持RPC等其他类型的接口限流;
  • 对于限流规则,只支持本地文件配置,配置文件只支持YAML;
  • 对于限流算法,只支持固定时间窗口算法;
  • 对于限流模式,只支持单机限流。

整体类的实现如下:

框架入口: RateLimiter,读取、加载、解析限流配置,并提供限流接口;
限流算法: RateLimitAlg, 默认采用固定时间窗口限流算法;
限流规则: ApiLimit、AppRuleConfig、RateLimitRule、RuleConfig

具体代码如下:
RateLimitAlg.java:

public class RateLimiter {

    private RateLimitRule rule;
    // 每个API内存中存储限流计数器, key为 api:url
    private ConcurrentHashMap<String, RateLimitAlg> counters = new ConcurrentHashMap<>();

    public RateLimiter() {
        RuleConfig ruleConfig = loadFromYmlAsRuleConfig();
        Assert.isTrue(ruleConfig != null, "Load from yaml file, RuleConfig is null");
        this.rule = new RateLimitRule(ruleConfig);
    }


    /**
     * 判断接口是否限流
     *
     * @param appId
     * @param url
     * @return true: 不限流; false: 限流
     * @throws InterruptedException
     */
    public boolean limit(String appId, String url) throws InterruptedException {
        // 接口未配置限流, 直接返回
        ApiLimit apiLimit = rule.getApiLimit(appId, url);
        if (apiLimit == null) {
            return true;
        }

        String counterKey = appId + ":" + url;
        RateLimitAlg rateLimitAlg = counters.get(counterKey);
        if (rateLimitAlg == null) {
            // 没有计数器, 就构造一个
            RateLimitAlg rateLimitCounterNew = new RateLimitAlg(apiLimit.getLimit());
            RateLimitAlg rateLimitCounterOld = counters.putIfAbsent(counterKey, rateLimitCounterNew);
            if (rateLimitCounterOld == null) {
                rateLimitAlg = rateLimitCounterNew;
            }
        }

        // 固定窗口统计, 判断是否超过限流阈值
        return rateLimitAlg.tryAcquire();

    }

    private RuleConfig loadFromYmlAsRuleConfig() {
        InputStream in = null;
        RuleConfig ruleConfig = null;
        try {
            in = this.getClass().getResourceAsStream("/sentinel-rule.yml");
            if (in != null) {
                Yaml yaml = new Yaml();
                ruleConfig = yaml.loadAs(in, RuleConfig.class);
                return ruleConfig;
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if (in != null) {
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }

        return  null;
    }
}

RateLimitAlg.java:

public class RateLimitAlg {

    // ms
    private static final long LOCK_EXPIRE_TIME = 200L;

    private Stopwatch stopWatch;
    // 限流计数器
    private AtomicInteger counter = new AtomicInteger(0);
    private final int limit;
    private Lock lock = new ReentrantLock();

    public RateLimitAlg(int limit) {
        this(limit, Stopwatch.createStarted());
    }

    public RateLimitAlg(int limit, Stopwatch stopWatch) {
        this.limit = limit;
        this.stopWatch = stopWatch;
    }

    public boolean tryAcquire() throws InterruptedException {
        int currentCount = counter.incrementAndGet();
        // 未达到限流
        if (currentCount < limit) {
            return true;
        }

        // 使用固定时间窗口统计当前窗口请求数
        // 请求到来时,加锁进行计数器统计工作
        try {
            if (lock.tryLock(LOCK_EXPIRE_TIME, TimeUnit.MILLISECONDS)) {
                // 如果超过这个时间窗口, 则计数器counter归零, stopWatch, 窗口进入下一个窗口
                if (stopWatch.elapsed(TimeUnit.MILLISECONDS) > TimeUnit.SECONDS.toMillis(1)) {
                    counter.set(0);
                    stopWatch.reset();
                }

                // 不超过, 则当前时间窗口内的计数器counter+1
                currentCount = counter.incrementAndGet();
                return currentCount < limit;
            }
        } catch (InterruptedException e) {
            System.out.println("tryAcquire() wait lock too long:" + LOCK_EXPIRE_TIME + " ms");
            throw new InterruptedException("tryAcquire() wait lock too long:" + LOCK_EXPIRE_TIME + " ms");
        } finally {
            lock.unlock();
        }

        // 出现异常 不能影响接口正常请求
        return true;
    }
}

限流规则:ApiLimit、AppRuleConfig、RuleConfig

public class ApiLimit {
    private static final int DEFAULT_UNIT_SECONDS = 1;

    private String api;
    private int limit;
    private int unit = DEFAULT_UNIT_SECONDS;

    // ...
}

public class AppRuleConfig {
    private String appId;
    private List<ApiLimit> limits;
		
  // ...
}

public class RuleConfig {
    private List<AppRuleConfig> configs;
		
  	// ...
}


RateLimitRule 提供了快速查询限流规则的方法:

/**
 * @author: wanggenshen
 * @date: 2020/6/23 00:12.
 * @description: 支持快速查询 ApiLimit
 *
 * TODO:
 * (1) 精准匹配优化: 二分查找算法优化
 * (2) 支持前缀匹配: 使用Trie树实现
 * (3) 支持模糊匹配: 实现难度较高
 */
public class RateLimitRule {

    /**
     * key : appId + api, value: limit
     */
    private HashMap<String, ApiLimit> map = new HashMap();

    public RateLimitRule(RuleConfig ruleConfig) {

        List<AppRuleConfig> configs = ruleConfig.getConfigs();
        configs.stream().forEach(appRuleConfig -> {
            String appId = appRuleConfig.getAppId();
            List<ApiLimit> apiLimitList = appRuleConfig.getLimits();
            apiLimitList.stream().forEach(apiLimit -> {
                String key = appId + ":" + apiLimit.getApi();
                map.put(key, apiLimit);
            });

        });

    }

    public ApiLimit getApiLimit(String appId, String api) {
        String key = appId + ":" + api;
        return map.get(key);
    }
}

测试:

(1) 首先配置限流规则:

configs:
- appId: app1
  limits:
  - api: /v1/user
    limit: 5
    unit: 60
  - api: /v1/order
    limit: 4
    unit: 60
- appId: app2
  limits:
  - api: /v1/login
    limit: 7
    unit: 60

(2) 测试:

public static void main(String[] args) {
        RateLimiter rateLimiter = new RateLimiter();
        try {
            for (int i = 0; i < 10; i++) {
                boolean b = rateLimiter.limit("app1", "/v1/user");
                System.out.println("/v1/user接口限流结果: " + b);
            }

            System.out.println("=====");

            for (int i = 0; i < 10; i++) {
                boolean b = rateLimiter.limit("app1", "/v1/order");
                System.out.println("/v1/order接口限流结果: " + b);
            }

            System.out.println("=====");
            for (int i = 0; i < 10; i++) {
                boolean b = rateLimiter.limit("app2", "/v1/login");
                System.out.println("/v1/login接口限流结果:" + b);
            }
        } catch (Exception e) {

        }
    }

测试结果如下,跟配置的规则相同:
【限流02】限流算法实战篇 - 手撸一个单机版Http接口通用限流框架_第3张图片

2、优化与重构V2版本

在实现了框架的功能之后,我们需要从Code Reviewer的角度,结合SOLID、DRY、KISS、基于接口而非实现编程、高内聚松耦合、编码规范等去分析代码的设计和实现在可读性、扩展性等方面有没有优化的点。

(1)代码可读性

代码可读性这块我们需要重点关注 目录设计是否合理、模块划分是否清晰、代码结构是否高内聚低耦合、是否符合编码规范这几点。
由于代码较少,所以以上几点都相对较为满足,即可读性较好。

(2)代码扩展性

扩展性主要是要遵循基于接口而非实现的编程思想,具有接口抽象意识。
RateLimitAlg 类只实现了固定时间窗口限流算法,如果我们需要使用其他的限流算法,就需要重写原代码,所以需要提供更加抽象的算法接口;
RateLimitRule 类只实现了简单的查询配置规则的接口,需要提供更加抽象的接口来支持二分查找等优化后的查找算法;

除此之外,入口类RateLimiter只提供规则的加载与接口限流,需要将文件的读取抽离开来。

优化后的代码见链接: 优化后的代码链接

总结

本文从 限流框架的分析、设计、实现3个层面,一步一步实现一个单机版Http接口限流框架。当然,实现出来的框架很粗糙,离生产环境使用还是有一定距离的,但是提供了一种通用框架实现的思路,帮助我们更好地去理解限流算法的思路、通用框架的实现过程。

你可能感兴趣的:(Java进阶,限流框架,java)