掘金 后端 ( ) • 2024-04-22 17:34

设计限流接口的意义

在后端的日常开发中,对于某一些重要接口,例如文件上传接口、调用第三方服务的接口等,我们不希望这些接口被用户发送大量请求。特别地,当我们调用第三方服务的接口被用户恶意刷流量时,可能会造成我们的损失。这个时候就需要用到限流接口的设计。

目前常见的限流方式有:全局限流、对某个接口继续限流、IP限流和账户限流等。本文将介绍如何通过Spring AOP + Redisson给自己的项目加上一个限流功能

前置知识

了解常见的限流算法

目前常见的限流算法包括:

  • 固定窗口算法:给定一个时间单位,将其作为一个时间窗口,在每个窗口内部设置一个计数器。
    • 优点:容易实现。
    • 缺点:不精准。
  • 滑动窗口算法:切换小的时间片,时间窗口定期滑动并将相应位置计数清零,在窗口内部设置一个计数器。
    • 优点:简单,精度高
    • 缺点:动态调整困难,对突发流量敏感。
  • 漏桶限流算法:漏桶中的请求以固定速率发放请求,如果漏桶没有满,有请求到达时放入漏桶中,如果漏桶已经满,则请求会被丢弃。
  • 令牌桶限流算法:参考Google的Guava包的实现。

本文重点在于如何通过Redisson客户端在业务上实现限流算法,对于限流算法的原理并没有深入讨论,也并不是要带大家手写限流算法,仅仅是列出常见的算法。如果读者需要了解更多关于限流算法的知识,可以看看下方的参考文章。

Redisson客户端

Redisson是开源的Java Redis客户端,它支持分布式和多线程,易于使用,采用了异步和NIO的技术,并提供了集群模式和主从复制模式,是一个功能强大、易于使用、高性能和可扩展的Redis客户端。

本文的限流功能就是基于Redisson提供的Ratelimiter类进行实现,官方文档介绍如下:6. Distributed objects · redisson/redisson Wiki (github.com),下面是官方对Ralimiter的解释:

Redis based distributed RateLimiter object for Java restricts the total rate of calls either from all threads regardless of Redisson instance or from all threads working with the same Redisson instance. Doesn't guarantee fairness.

文章篇幅原因,这里不讲解如何配置Redisson,大家可以参考这个开源项目进行Redisson的配置,大致只需要下面几个步骤:

  • 引入Redisson的Maven依赖
  • 添加Properties类,设置一些常用配置
  • 创建一个Config类,注入RedissonAutoConfigurationCustomizer自动配置类。参考:Ruoyi-Vue-Plus - (gitee.com)

Redisson 是如何实现分布式限流的? Redisson的RateLimiter底层是通过Lua脚本来操作Redis实现(见下图),原理比较复杂,这里也不去深究其原理了,感兴趣的读者可以去阅读源码理解。这里给读者推荐一篇华为云技术团队写得文章:详解Redisson分布式限流的实现原理 - 掘金 (juejin.cn)

image.png

强大的Spring Spel表达式

在本次设计中,Spring Spel主要用于从方法中拿到请求参数,将请求参数作为CacheKey的一部分。这样可以实现对相同请求参数级别的限流控制

Spring Spel表达式全称“Spring Expression Lanaguage”,缩写为SpEL,能在运行时构建表达式、存取对象属性、调用对象方法等。Spring Spel表达式给静态的Java语言增加了动态的功能。

SpEL支持的表达式

  • 基本表达式
  • 类相关表达式
  • 集合相关表达式
  • 模板表达式

SpEL求表达式一般分四步

  • 构造解析器ExpressionParser,用于将字符串表达式解析为表达式对象
  • 解析字符串表达式:parser.parseExpression(),得到expression
  • 构造上下文:new Context对象,表达式对象执行的环节,可以定义变量、自定义函数、提供类型转换等。
  • 根据上下文得到表达式运算后的值:expression.getValue(context)

简单的Demo

public static void main(String[] args) {  
    SpelExpressionParser parser = new SpelExpressionParser();  
    Expression expression = parser.parseExpression("('Hello '+'SpEL').concat(#end)");  
    StandardEvaluationContext context = new StandardEvaluationContext();  
    context.setVariable("end","!");  
    String value = expression.getValue(context, String.class);  
    Console.log("value:{}",value);  
}

结果:value:Hello SpEL!

更多细致的内容可以查看这篇文章SpEL这么香的功能都没有使用过,还敢说玩转Spring? - 掘金 (juejin.cn),本文在这里介绍Spel只是为了方便读者理解后面的代码。

动手实践

我们将分为几个步骤来设计这个限流功能:

  1. 设计自定义注解@RateLimiter,用于接口方法。通过这个注解,我们可以设置限流时间,限流次数,限流类型等信息。
  2. 设计AOP切面,对切点进行拦截和增强。我们的核心实现都在这个地方完成。
  3. 设计限流方案,将请求地址+xx+xx构成为唯一KEY并存入Redis,通过查询Redis中某个Key是否存在来达到限流目的。这里基于Redisson的RRateLimiter来实现。

自定义注解@RateLimiter

@RateLimiter注解作用于方法上,有以下参数:

  • key:限流key,我们用Spel表达式动态获取方法上的参数值,实现对同一种参数级别的限流。
  • time:限流时间,单位是秒
  • count:限流次数
  • limitType:限流类型(内含全局限流、IP限流、集群实例限流)
  • message:国际化消息(强烈推荐在项目中使用i18n进行国际化消息配置,没有也不影响
@Target(ElementType.METHOD)  
@Retention(RetentionPolicy.RUNTIME)  
@Documented  
public @interface RateLimiter {  
    /**  
     * 限流key,支持使用Spring el表达式来动态获取方法上的参数值  
     * 格式类似于  #code.id #{#code}  
     */    String key() default "";  
  
    /**  
     * 限流时间,单位秒  
     */  
    int time() default 60;  
  
    /**  
     * 限流次数  
     */  
    int count() default 100;  
  
    /**  
     * 限流类型  
     */  
    LimitType limitType() default LimitType.DEFAULT;  
  
    /**  
     * 提示消息 支持国际化 格式为 {code}  
     */    String message() default "{rate.limiter.message}";  
}

基于Redisson封装工具方法

在实际开发中,我们往往会基于Redisson封装一个工具类,在这个工具类中包含我们常见的对Redis的操作。这里,我们基于Redisson封装一个RedisUtils,实现对Redisson中RRateLimiter的封装。

rateLimiter限流方法

  • 参数:限流Key、限流类型、速率、间隔
  • 实现
    • 调用RedissonClientgetRateLimiter拿到rateLimiter
    • 使用rateLimiter的trySetRate初始化 RateLimiter 的状态并将配置存储到 Redis 服务器。
    • 通过trySetRate.tryAcquire()尝试获取通行许可,如果允许,会将通行证数量-1,并返回true,否则返回false
public class RedisUtils {  
  
    private static final RedissonClient CLIENT = SpringUtils.getBean(RedissonClient.class);  
  
    /**  
     * 限流  
     *  
     * @param key          限流key  
     * @param rateType     限流类型  
     * @param rate         速率  
     * @param rateInterval 速率间隔  
     * @return -1 表示失败  
     */  
    public static long rateLimiter(String key, RateType rateType, int rate, int rateInterval) {  
        RRateLimiter rateLimiter = CLIENT.getRateLimiter(key);  
        rateLimiter.trySetRate(rateType, rate, rateInterval, RateIntervalUnit.SECONDS);  
        if (rateLimiter.tryAcquire()) {  
            return rateLimiter.availablePermits();  
        } else {  
            return -1L;  
        }  
    }
}

定义切面:RateLimiterAspect

首先,实例化Spring Spel表达式相关的类,用于获取切点的方法参数。

  • Spel表达式解析器ExpressionParser
  • Spel解析模板ParserContext
  • Spel上下文EvaluationContext
  • 方法参数解析器ParamterNameDiscoverer

定义前置通知doBefore:拦截@RateLimiter注解

  • 第一步,从切点(注解)拿到限流时间time和访问次数限制count
  • 第二步,拼接CacheKey,即用于实现限流的Redis Key
    • 首先,通过切点的getSignature,拿到方法签名,再通过方法签名得到Method。
    • 然后,从注解的Key拿到Spel表达式,通过point.getArgs()拿到参数值,通过方法参数解析器传入method,拿到参数名字。
    • 接着,使用Spel上下文解析器将参数名和参数值设置到Spel上下文,调用Spel表达式解析器解析出参数值,用于拼接到CacheKey
    • 实例化一个StringBuilder,拼接上CacheName,请求路径,请求IP/请求实例(取决于注解上的配置),拼接上参数值,返回。
  • 第三步,设置限流类型。我们使用Redisson提供的RateType类进行设置,默认是OVERALL,表示全局限流。我们也可以根据注解上的限流类型,对RateType进行修改。
  • 第四步,使用RedisUtils的rateLimiter,传入Key、rateType、count、time,就会拿到一个number,表示剩余的令牌数量。
    • 如果通行证数量是-1,表示超过请求次数限制,抛出异常。
    • 否则打印日志,并直接放行。

完整代码如下:

@Slf4j  
@Aspect  
@Component  
public class RateLimiterAspect {  
  
    /**  
     * 定义spel表达式解析器  
     */  
    private final ExpressionParser parser = new SpelExpressionParser();  
    /**  
     * 定义spel解析模版  
     */  
    private final ParserContext parserContext = new TemplateParserContext();  
    /**  
     * 定义spel上下文对象进行解析  
     */  
    private final EvaluationContext context = new StandardEvaluationContext();  
    /**  
     * 方法参数解析器  
     */  
    private final ParameterNameDiscoverer pnd = new DefaultParameterNameDiscoverer();  
  
    @Before("@annotation(rateLimiter)")  
    public void doBefore(JoinPoint point, RateLimiter rateLimiter) throws Throwable {  
        int time = rateLimiter.time();  
        int count = rateLimiter.count();  
        String combineKey = getCombineKey(rateLimiter, point);  
        try {  
            RateType rateType = RateType.OVERALL;  
            if (rateLimiter.limitType() == LimitType.CLUSTER) {  
                rateType = RateType.PER_CLIENT;  
            }  
            long number = RedisUtils.rateLimiter(combineKey, rateType, count, time);  
            if (number == -1) {  
                String message = rateLimiter.message();  
                if (StringUtils.startsWith(message, "{") && StringUtils.endsWith(message, "}")) {  
                    message = MessageUtils.message(StringUtils.substring(message, 1, message.length() - 1));  
                }  
                throw new ServiceException(message);  
            }  
            log.info("限制令牌 => {}, 剩余令牌 => {}, 缓存key => '{}'", count, number, combineKey);  
        } catch (Exception e) {  
            if (e instanceof ServiceException) {  
                throw e;  
            } else {  
                throw new RuntimeException("服务器限流异常,请稍候再试");  
            }  
        }  
    }  
  
    public String getCombineKey(RateLimiter rateLimiter, JoinPoint point) {  
        String key = rateLimiter.key();  
        // 获取方法(通过方法签名来获取)  
        MethodSignature signature = (MethodSignature) point.getSignature();  
        Method method = signature.getMethod();  
        Class<?> targetClass = method.getDeclaringClass();  
        // 判断是否是spel格式  
        if (StringUtils.containsAny(key, "#")) {  
            // 获取参数值  
            Object[] args = point.getArgs();  
            // 获取方法上参数的名称  
            String[] parameterNames = pnd.getParameterNames(method);  
            if (ArrayUtil.isEmpty(parameterNames)) {  
                throw new ServiceException("限流key解析异常!请联系管理员!");  
            }  
            for (int i = 0; i < parameterNames.length; i++) {  
                context.setVariable(parameterNames[i], args[i]);  
            }  
            // 解析返回给key  
            try {  
                Expression expression;  
                if (StringUtils.startsWith(key, parserContext.getExpressionPrefix())  
                    && StringUtils.endsWith(key, parserContext.getExpressionSuffix())) {  
                    expression = parser.parseExpression(key, parserContext);  
                } else {  
                    expression = parser.parseExpression(key);  
                }  
                key = expression.getValue(context, String.class) + ":";  
            } catch (Exception e) {  
                throw new ServiceException("限流key解析异常!请联系管理员!");  
            }  
        }  
        StringBuilder stringBuffer = new StringBuilder(CacheConstants.RATE_LIMIT_KEY);  
        stringBuffer.append(ServletUtils.getRequest().getRequestURI()).append(":");  
        if (rateLimiter.limitType() == LimitType.IP) {  
            // 获取请求ip  
            stringBuffer.append(ServletUtils.getClientIP()).append(":");  
        } else if (rateLimiter.limitType() == LimitType.CLUSTER) {  
            // 获取客户端实例id  
            stringBuffer.append(RedisUtils.getClient().getId()).append(":");  
        }  
        return stringBuffer.append(key).toString();  
    }  
}

测试和使用

新建一个Controller,使用@RateLimiter进行测试。为了验证Spel解析器能够获取到方法参数的值,我们使用带key的注解。代码如下:

// 10s内只能访问两次
@GetMapping  
@RateLimiter(count = 2,time = 10,key = "#value" )  
public R<Void> test(String value){  
    return R.ok(value);  
}

我们通过Debug进行测试,发现组成的Key是由Cache前缀 + 请求地址 + 参数值组成,这就实现了更加细致的参数级别限流。

Pasted image 20240315185528.png

测试结果如下:10s内发送2次请求会成功。

Pasted image 20240315185551.png

如果超出2次请求,就会失败。

Pasted image 20240315185742.png

至此,我们就通过AOP技术和Redisson实现了一个限流功能,读者读到这里会发现,具体实现起来还是比较简单的,因为Redisson向我们隐藏了实现的细节。

参考文章

  1. 设计一个限流器:四种限流算法详解 - 掘金 (juejin.cn)
  2. RRateLimiter - redisson 3.27.2 javadoc
  3. Distributed objects · redisson/redisson Wiki (github.com)
  4. 详解Redisson分布式限流的实现原理 - 掘金 (juejin.cn)
  5. SpEL这么香的功能都没有使用过,还敢说玩转Spring? - 掘金 (juejin.cn)