Spring Boot 整合Redis使用Lua脚本实现限流

news2024/12/27 10:28:55

目录

    • 一、简介
    • 二、maven依赖
    • 三、编码实现
      • 3.1、配置文件
      • 3.2、配置类
      • 3.3、注解类
      • 3.4、切面类
      • 3.5、lua脚本
      • 3.6、自定义异常和全局异常
      • 3.7、控制层
    • 四、验证
    • 4.1、单用户限流
    • 4.2、接口限流
    • 结语

一、简介

  本篇文章主要来讲Spring Boot 整合Redis使用Lua脚本实现限流,实现限流有多种方式,我们今天主要讲使用Lua脚本。

  为什么我们使用Lua脚本来限流?因为Lua脚本具有原子性,那为什么lua脚本具有原子性?简单来说,因为Redis使用相同的Lua解释器来运行所有命令,Redis保证脚本以原子方式执行:在执行脚本时,不会执行其他脚本或Redis命令。因此从所有其他客户端的角度来看,脚本的效果要么仍然不可见,要么已经执行完成了。

二、maven依赖

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.6.0</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>

    <groupId>com.alian</groupId>
    <artifactId>redis-limit-lua</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>redisCache</name>
    <description>redis-limit-lua</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
        <project.package.directory>target</project.package.directory>
        <java.version>1.8</java.version>
        <!--com.fasterxml.jackson 版本-->
        <jackson.version>2.9.10</jackson.version>
        <!--lombok 版本-->
        <lombok.version>1.16.14</lombok.version>
        <!--阿里巴巴fastjson 版本-->
        <fastjson.version>1.2.68</fastjson.version>
        <!--junit 版本-->
        <junit.version>4.12</junit.version>
    </properties>

    <dependencies>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

		<!--aop-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <!--redis依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>

        <!--用于序列化-->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>${jackson.version}</version>
        </dependency>

        <!--java 8时间序列化-->
        <dependency>
            <groupId>com.fasterxml.jackson.datatype</groupId>
            <artifactId>jackson-datatype-jsr310</artifactId>
            <version>${jackson.version}</version>
        </dependency>

        <!--JSON-->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>${fastjson.version}</version>
        </dependency>

        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.12.0</version>
        </dependency>

        <!--日志输出-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
        </dependency>

    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

三、编码实现

3.1、配置文件

application.properties

# 端口
server.port=8090
# 上下文路径
server.servlet.context-path=/rateLimit

# Redis数据库索引(默认为0)
spring.redis.database=0
# Redis服务器地址
spring.redis.host=127.0.0.1
# Redis服务器连接端口
spring.redis.port=6379
# Redis服务器连接密码(默认为空)
spring.redis.password=123456
# 连接池最大连接数(使用负值表示没有限制)
spring.redis.jedis.pool.max-active=20
# 连接池中的最小空闲连接
spring.redis.jedis.pool.min-idle=10
# 连接池中的最大空闲连接
spring.redis.jedis.pool.max-idle=10
# 连接池最大阻塞等待时间(使用负值表示没有限制)
spring.redis.jedis.pool.max-wait=20000
# 读时间(毫秒)
spring.redis.timeout=10000
# 连接超时时间(毫秒)
spring.redis.connect-timeout=10000

3.2、配置类

RedisConfiguration.java

package com.alian.redisLimit.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.scripting.support.ResourceScriptSource;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

@Slf4j
@Configuration
@EnableCaching
public class RedisConfiguration {

    @Bean
    public RedisScript<Boolean> redisRequestRateLimiterScript() {
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(
                new ClassPathResource("META-INF/scripts/request_rate_limiter.lua")));
        redisScript.setResultType(Boolean.class);
        return redisScript;
    }

    /**
     * redis配置
     *
     * @param redisConnectionFactory
     * @return
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
        // 实例化redisTemplate
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        //设置连接工厂
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        // key采用String的序列化
        redisTemplate.setKeySerializer(keySerializer());
        // value采用jackson序列化
        redisTemplate.setValueSerializer(valueSerializer());
        // Hash key采用String的序列化
        redisTemplate.setHashKeySerializer(keySerializer());
        // Hash value采用jackson序列化
        redisTemplate.setHashValueSerializer(valueSerializer());
        //执行函数,初始化RedisTemplate
        redisTemplate.afterPropertiesSet();
        return redisTemplate;
    }

    /**
     * key类型采用String序列化
     *
     * @return
     */
    private RedisSerializer<String> keySerializer() {
        return new StringRedisSerializer();
    }

    /**
     * value采用JSON序列化
     *
     * @return
     */
    private RedisSerializer<Object> valueSerializer() {
        //设置jackson序列化
        Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
        //设置序列化对象
        jackson2JsonRedisSerializer.setObjectMapper(getMapper());
        return jackson2JsonRedisSerializer;
    }


    /**
     * 使用com.fasterxml.jackson.databind.ObjectMapper
     * 对数据进行处理包括java8里的时间
     *
     * @return
     */
    private ObjectMapper getMapper() {
        ObjectMapper mapper = new ObjectMapper();
        //设置可见性
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        //默认键入对象
        mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
        //设置Java 8 时间序列化
        JavaTimeModule timeModule = new JavaTimeModule();
        timeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        timeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
        timeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
        timeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
        timeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
        timeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
        //禁用把时间转为时间戳
        mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
        mapper.registerModule(timeModule);
        return mapper;
    }

}

相比我们之前整合redis,就是多了如下配置:

    @Bean
    public RedisScript<Boolean> redisRequestRateLimiterScript() {
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptSource(new ResourceScriptSource(
                new ClassPathResource("META-INF/scripts/request_rate_limiter.lua")));
        redisScript.setResultType(Boolean.class);
        return redisScript;
    }
  • 实例化默认的DefaultRedisScript
  • 设置Lua 脚本的路径(一般放到resources/META-INF/scripts/ 目录下)
  • 设置Lua 脚本执行返回的结果类型,脚本返回的类型要和这里返回的结果类型要一致,本文返回的结果是布尔值,所以结果是 Boolean.class,如果你Lua执行后返回的结果是字符串,数字或者多个对象(元组),则设置对应的结果类型:String.classLong.classList.class

3.3、注解类

RateLimiters.java

package com.alian.redisLimit.annotate;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiters {

    RateLimiter[] value();
}

上面就是一个复合注解。

RateLimiter.java

package com.alian.redisLimit.annotate;

import java.lang.annotation.*;

@Documented
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimiter {

    /**
     * Spel表达式
     */
    String [] keys() default {};

    /**
     * 令牌桶的容量,默认300
     */
    int capacity() default 300;

    /**
     * 生成令牌的速度,默认每秒100个
     */
    int rate() default 100;

    /**
     * 拒绝请求时的提示信息
     */
    String showPromptMsg() default "服务器繁忙,请稍候再试";
    
}

  自定义注解也没有什么好说的,主要是定义了:

  • key的名称,用于Redis锁的键
  • 令牌桶的容量,默认300
  • 每秒生成令牌的速度,默认每秒100个
  • 限流时返回给前端的提示信息

3.4、切面类

RateLimiterAspectHandler.java

package com.alian.redisLimit.aop;

import com.alian.redisLimit.annotate.RateLimiter;
import com.alian.redisLimit.annotate.RateLimiters;
import com.alian.redisLimit.exception.RateLimiterException;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;

import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;

@Slf4j
@Component
@Aspect
public class RateLimiterAspectHandler {

    @Autowired
    private RedisScript<Boolean> redisScript;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private RateLimiterKeyProvider keyProvider;

    @Around(value = "@annotation(rateLimiter)", argNames = "point,rateLimiter")
    public Object around(ProceedingJoinPoint point, RateLimiter rateLimiter) throws Throwable {
        isAllow(point, rateLimiter);
        return point.proceed();
    }

    @Around(value = " @annotation(rateLimiters)", argNames = "point,rateLimiters")
    public Object around(ProceedingJoinPoint point, RateLimiters rateLimiters) throws Throwable {
        RateLimiter[] limiters = rateLimiters.value();
        for (RateLimiter rateLimiter : limiters) {
            isAllow(point, rateLimiter);
        }
        return point.proceed();
    }

    private void isAllow(ProceedingJoinPoint point, RateLimiter rateLimiter) {
        // 获取key
        String key = keyProvider.getKey(point, rateLimiter);
        // 类路径+方法,然后计算md5
        String uniqueKey = getUniqueKey((MethodSignature) point.getSignature());
        // key名称
        key = StringUtils.isNotBlank(key) ? uniqueKey + "." + key : uniqueKey;
        // 拼接成最后的Redis的键,传入需要操作的key到lua脚本中
        List<String> operateKeys = getOperateKeys(key);
        // 执行lua脚本
        Boolean allowed = this.redisTemplate.execute(redisScript, operateKeys, rateLimiter.capacity(), rateLimiter.rate(), Instant.now().getEpochSecond(), 1);
        log.info("rateLimiter {}, result is {}", key, allowed);
        if (Boolean.FALSE.equals(allowed)) {
            log.warn("触发限流,key is : {} ", key);
            throw new RateLimiterException(rateLimiter.showPromptMsg());
        }
    }

    private String getUniqueKey(MethodSignature signature) {
        String format = String.format("%s.%s", signature.getDeclaringTypeName(), signature.getMethod().getName());
        return DigestUtils.md5DigestAsHex(format.getBytes(StandardCharsets.UTF_8));
    }

    private List<String> getOperateKeys(String id) {
        String tokenKey = "request_rate_limiter.{" + id + "}.token";
        String timestampKey = "request_rate_limiter.{" + id + "}.timestamp";
        return Arrays.asList(tokenKey, timestampKey);
    }

}
  • 切面是针对所有使用了@RateLimiter @RateLimiters 注解的方法
  • 首先是获取定义的key的值,设置了key就是针对特定一类限流,没设置就是针对整个接口限流
  • 获取一个方法的唯一值作为Redis中key的一部分,本文是获取类路径+方法名,然后计算md5值作为这个前缀
  • 拼接成最后的Redis的key,传到需要操作的Lua脚本中
  • 执行lua脚本,传入的key就是KEYS[] ,传入的参数就是ARGV[] ,下标从1开始取值,参数要注意类型
  • 如果未获取到则抛出异常(限流了),做一个全局异常捕获,统一返回处理

RateLimiterKeyProvider.java

package com.alian.redisLimit.aop;

import com.alian.redisLimit.annotate.RateLimiter;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;

@Slf4j
@Component
public class RateLimiterKeyProvider {

    private ParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();

    private ExpressionParser parser = new SpelExpressionParser();

    public String getKey(JoinPoint joinPoint, RateLimiter rateLimiter) {
        List<String> keyList = new ArrayList<>();
        Method method = getMethod(joinPoint);
        List<String> definitionKeys = getSpelDefinitionKey(rateLimiter.keys(), method, joinPoint.getArgs());
        keyList.addAll(definitionKeys);
        return StringUtils.collectionToDelimitedString(keyList,".","","");
    }

    private Method getMethod(JoinPoint joinPoint) {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        Method method = signature.getMethod();
        if (method.getDeclaringClass().isInterface()) {
            try {
                method = joinPoint.getTarget().getClass().getDeclaredMethod(signature.getName(),
                        method.getParameterTypes());
            } catch (Exception e) {
                log.error(null,e);
            }
        }
        return method;
    }

    private List<String> getSpelDefinitionKey(String[] definitionKeys, Method method, Object[] parameterValues) {
        List<String> definitionKeyList = new ArrayList<>();
        for (String definitionKey : definitionKeys) {
            if (definitionKey != null && !definitionKey.isEmpty()) {
                EvaluationContext context = new MethodBasedEvaluationContext(null, method, parameterValues, discoverer);
                String key = parser.parseExpression(definitionKey).getValue(context).toString();
                definitionKeyList.add(key);
            }
        }
        return definitionKeyList;
    }
}

3.5、lua脚本

request_rate_limiter.lua

-- 传入的要操作的key:tokenKey
local tokenKey = KEYS[1]
-- 传入的要操作的key:timestampKey
local timestampKey = KEYS[2]

-- 参数1:令牌桶的大小
local capacity = tonumber(ARGV[1])
-- 参数2:生成令牌的速度
local rate = tonumber(ARGV[2])
-- 参数3:当前时间的秒数
local nowTimestamp = tonumber(ARGV[3])
-- 参数4:请求令牌数
local requested = tonumber(ARGV[4])

-- redis.log(redis.LOG_NOTICE,"tokenKey:" .. tokenKey)
-- redis.log(redis.LOG_NOTICE,"timestampKey:" .. timestampKey)
-- redis.log(redis.LOG_NOTICE,"capacity:" .. capacity)
-- redis.log(redis.LOG_NOTICE,"rate:" .. rate)
-- redis.log(redis.LOG_NOTICE,"nowTimestamp:" .. nowTimestamp)
-- redis.log(redis.LOG_NOTICE,"requested:" .. requested)

-- 计算令牌桶填充时间,令牌桶的大小/生成令牌的速度
local fillTime = capacity / rate
-- 失效时间向下取整,采用两倍填充时间保证失效时间充足
local expireTime = math.floor(fillTime * 2)

-- 从redis获取上一次tokenKey的值,如果返回nil,则初始化令牌桶,结果转为数字
local lastToken = tonumber(redis.call("get", tokenKey) or capacity)
-- 从redis获取上一次timestampKey的值,如果返回nil,则时间设置为0,结果转为数字
local lastTimestamp = tonumber(redis.call("get", timestampKey) or 0)
-- 当前时间和最后一次获取的时间的差值:秒数取值范围是从0到expireTime 或者 当前时间值
local timeGaps = math.max(0, nowTimestamp - lastTimestamp)

-- redis.log(redis.LOG_NOTICE,"fillTime:" .. fillTime)
-- redis.log(redis.LOG_NOTICE,"expireTime:" .. expireTime)
-- redis.log(redis.LOG_NOTICE,"lastToken:" .. lastToken)
-- redis.log(redis.LOG_NOTICE,"lastTimestamp:" .. lastTimestamp)
-- redis.log(redis.LOG_NOTICE,"timeGaps:" .. timeGaps)

-- 同1秒内的timeGaps的值都是0,令牌桶的数不会增加,直到扣减完,超过1秒的都会填充令牌
local filledToken = math.min(capacity, lastToken + (timeGaps * rate))
-- 新拿到的令牌值默认是填充后的filledToken
local newToken = filledToken
-- 令牌数大于等于请求令牌数说明可以获取到令牌
local allowed = filledToken >= requested

-- 如果可以拿到令牌,则令牌数扣减掉请求数,得到令牌值
if allowed
then
  newToken = filledToken - requested
end

-- redis.log(redis.LOG_NOTICE,"filledToken:" .. filledToken)
-- redis.log(redis.LOG_NOTICE,"allowed:" .. tostring(allowed))
-- redis.log(redis.LOG_NOTICE,"newToken:" .. newToken)

-- 通过redis设置tokenKey的值为newToken,失效时间为expireTime
redis.call("setex", tokenKey, expireTime, newToken)
-- 通过redis设置timestampKey的值为nowTimestamp,失效时间为expireTime
redis.call("setex", timestampKey, expireTime, nowTimestamp)

-- 返回结果:是否拿到令牌
return allowed

  我想我的脚本已经解释的很详细的,小伙伴可要认真看哦。顺便提一下,如果要打印Lua脚本的日志,则可以使用如下方式:

redis.log(redis.LOG_NOTICE,"filledToken:" .. filledToken)

  需要注意的是修改redis的配置文件的两个值:日志级别和日志文件

# Specify the server verbosity level.
# This can be one of:
# debug (a lot of information, useful for development/testing)
# verbose (many rarely useful info, but not a mess like the debug level)
# notice (moderately verbose, what you want in production probably)
# warning (only very important / critical messages are logged)
loglevel notice

# Specify the log file name. Also 'stdout' can be used to force
# Redis to log on the standard output.
logfile "C:/myProgram/Redis-x64-5.0.14.1/redis.log"

  redis.LOG_NOTICE 对应你配置的 loglevel,改完保存后,重启服务,windows环境记得带配置文件启动

C:\myProgram\Redis-x64-5.0.14.1>redis-server.exe redis.windows.conf

3.6、自定义异常和全局异常

RateLimiterException.java

package com.alian.redisLimit.exception;

public class RateLimiterException extends RuntimeException {

    public RateLimiterException(String message) {
        super(message);
    }

}

  自定义异常类,也没啥好说的,下面就是全局异常,为了省篇幅没有把所有的异常都列出来,小伙伴可以自行添加,主要是对我们RateLimiterException 进行处理。

GlobalExceptionHandler.java

package com.alian.redisLimit.exception;

import com.alian.redisLimit.dto.ApiResponseDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

import javax.servlet.http.HttpServletRequest;

@Slf4j
@Component
@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public ApiResponseDto<?> handle(HttpRequestMethodNotSupportedException exception, HttpServletRequest request) {
        return logWarn(request.getRequestURI() + " " + exception.getMessage(), null, ApiResponseDto.errRequestMethod("请求方法错误"));
    }

    @ExceptionHandler
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public ApiResponseDto handle(MissingServletRequestParameterException exception) {
        return logWarn(exception.getMessage(), null, ApiResponseDto.errParam("参数错误"));
    }

    @ExceptionHandler
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public ApiResponseDto handle(RateLimiterException exception) {
        return ApiResponseDto.fail(exception.getMessage());
    }

    @ExceptionHandler
    @ResponseBody
    @ResponseStatus(HttpStatus.OK)
    public ApiResponseDto handle(Exception exception) {
        log.info("异常类:{}", exception.getClass().getCanonicalName());
        return logError(null, exception, ApiResponseDto.exception("系统异常"));
    }

    private static ApiResponseDto logWarn(String msg, Exception e, ApiResponseDto responseDto) {
        long timestamp = responseDto.getTimestamp();
        String m = "timestamp is " + timestamp;
        if (msg != null) {
            m += ", " + msg;
        }
        if (e == null) {
            log.warn(m);
        } else {
            log.warn(m, e);
        }
        return responseDto;
    }

    private static ApiResponseDto logError(String msg, Exception e, ApiResponseDto responseDto) {
        long timestamp = responseDto.getTimestamp();
        String m = "timestamp is " + timestamp;
        if (msg != null) {
            m += ", " + msg;
        }
        log.error(m, e);
        return responseDto;
    }

}

对应的统一返回封装如下:

ApiResponseDto.java

package com.alian.redisLimit.dto;

import lombok.*;
import lombok.experimental.Accessors;

@Setter
@Getter
@Accessors(chain = true)
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = "content")
public class ApiResponseDto<T> {

    /** 成功 */
    public static String CODE_SUCCESS="0000";
    /** 失败 */
    public static String CODE_FAIL="1000";
    /** 系统异常 */
    public static String CODE_EXCEPTION="1001";
    /** 签名错误 */
    public static String CODE_ERR_SIGN="1002";
    /** 参数错误 */
    public static String CODE_ERR_PARAM="1003";
    /** 业务异常 */
    public static String CODE_BIZ_ERR="1004";
    /** 查询无数据,使用明确的参数(如id)进行查询时未找到记录时返回此错误码 */
    public static String CODE_NO_DATA="1005";
    /** 错误的请求方法 */
    public static String CODE_ERR_REQUEST_METHOD="1006";
    /** 错误的请求内容类型 */
    public static String CODE_ERR_CONTENT_TYPE="1007";
    /** 系统繁忙 */
    public static String CODE_SYS_BUSY="1008";
    /** 显示提示 */
    public static String CODE_SHOW_TIP="1009";
    /** 根据bizCode进行处理 */
    public static String CODE_DEAL_BIZ_CODE="1012";
    /** 未找到请求 */
    public static String CODE_NOT_FOUND_CODE="1013";

    public final static ApiResponseDto SUCCESS=new ApiResponseDto();


    private String code =CODE_SUCCESS;

    /** 状态说明 */
    private String msg ="success";

    /** 请求是否成功 */
    public boolean isSuccess(){
        return CODE_SUCCESS.equals(code);
    }

    /** 结果内容 */
    private T content;

    /** 时间戳 */
    private long timestamp=System.currentTimeMillis();

    /** 业务状态码,由业务接口定义 */
    private String bizCode;

    /** 业务状态说明 */
    private String bizMsg;

    public ApiResponseDto(T content) {
        this.content=content;
    }

    public static <T> ApiResponseDto<T> success(){
        return SUCCESS;
    }

    public static <T> ApiResponseDto<T> success(T content){
        return new ApiResponseDto<T>(content);
    }

    public static <T> ApiResponseDto<T> fail(String msg){
        ApiResponseDto<T> response = new ApiResponseDto<>();
        response.setCode(CODE_FAIL);
        response.setMsg(msg);
        return response;
    }

    public static <T> ApiResponseDto<T> exception(String msg){
        ApiResponseDto<T> response = new ApiResponseDto<>();
        response.setCode(CODE_EXCEPTION);
        response.setMsg(msg);
        return response;
    }

    public static <T> ApiResponseDto<T> errSign(String msg){
        ApiResponseDto<T> response = new ApiResponseDto<>();
        response.setCode(CODE_ERR_SIGN);
        response.setMsg(msg);
        return response;
    }

    public static <T> ApiResponseDto<T> errParam(String msg){
        ApiResponseDto<T> response = new ApiResponseDto<>();
        response.setCode(CODE_ERR_PARAM);
        response.setMsg(msg);
        return response;
    }

    public static <T> ApiResponseDto<T> bizErr(String msg){
        ApiResponseDto<T> response = new ApiResponseDto<>();
        response.setCode(CODE_BIZ_ERR);
        response.setMsg(msg);
        return response;
    }

    public static <T> ApiResponseDto<T> notFound(String msg){
        ApiResponseDto<T> response = new ApiResponseDto<>();
        response.setCode(CODE_NOT_FOUND_CODE);
        response.setMsg(msg);
        return response;
    }

    public static <T> ApiResponseDto<T> noData(String msg){
        ApiResponseDto<T> response = new ApiResponseDto<>();
        response.setCode(CODE_NO_DATA);
        response.setMsg(msg);
        return response;
    }
    public static <T> ApiResponseDto<T>  errRequestMethod(String msg){
        ApiResponseDto<T> response = new ApiResponseDto<>();
        response.setCode(CODE_ERR_REQUEST_METHOD);
        response.setMsg(msg);
        return response;
    }
    public static <T> ApiResponseDto<T> errContentType(){
        ApiResponseDto<T> response = new ApiResponseDto<>();
        response.setCode(CODE_ERR_CONTENT_TYPE);
        response.setMsg("错误的请求内容类型");
        return response;
    }
    public static <T> ApiResponseDto<T> sysBusy(){
        ApiResponseDto<T> response = new ApiResponseDto<>();
        response.setCode(CODE_SYS_BUSY);
        response.setMsg("系统繁忙");
        return response;
    }
    public static <T> ApiResponseDto<T>  showTip(String tip){
        ApiResponseDto<T> response = new ApiResponseDto<>();
        response.setCode(CODE_SHOW_TIP);
        response.setMsg(tip);
        return response;
    }

    public ApiResponseDto<T> bizInfo(String bizCode,String bizMsg){
        this.code=bizCode;
        this.msg=bizMsg;
        return this;
    }

    public static <T> ApiResponseDto<T>  dealBizCode(String bizCode,String bizMsg,T content){
        ApiResponseDto<T> response = new ApiResponseDto<>(content);
        response.setCode(CODE_DEAL_BIZ_CODE);
        response.setMsg("根据bizCode进行处理");
        response.setBizCode(bizCode);
        response.setBizMsg(bizMsg);
        return response;
    }
}

3.7、控制层

UserController.java

package com.alian.redisLimit.controller;

import com.alian.redisLimit.annotate.RateLimiter;
import com.alian.redisLimit.annotate.RateLimiters;
import com.alian.redisLimit.dto.ApiResponseDto;
import com.alian.redisLimit.dto.UserDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;

@Slf4j
@RequestMapping("/user")
@RestController
public class UserController {

    private static Map<String, UserDto> map = new HashMap<String, UserDto>() {{
        put("BAT001", new UserDto("BAT001", "梁南生", 27, "研发部", 18000.0, LocalDateTime.of(2020, 5, 20, 9, 0, 0)));
        put("BAT002", new UserDto("BAT002", "包雅馨", 25, "财务部", 8800.0, LocalDateTime.of(2016, 11, 10, 8, 30, 0)));
        put("BAT003", new UserDto("BAT003", "罗考聪", 35, "测试部", 6400.0, LocalDateTime.of(2017, 3, 20, 14, 0, 0)));
    }};

    @RateLimiters(value = {
            @RateLimiter(keys = "#id", capacity = 1, rate = 1, showPromptMsg = "您查询太快了,请稍后再试"),
            @RateLimiter(capacity = 5, rate = 2, showPromptMsg = "系统繁忙,请稍后再试")
    })
    @RequestMapping("/findById/{id}")
    public ApiResponseDto<UserDto> findById(@PathVariable("id") String id) {
        UserDto userDto = map.getOrDefault(id, null);
        if (userDto != null) {
            return ApiResponseDto.success(userDto);
        }
        return ApiResponseDto.noData("未查询到数据");
    }

}

  简单模拟根据用户编号查询用户的接口,关键是我们使用注解@RateLimiter 的方法可以做限流,看是否能达到我们的要求。这里有两层意思:

  • 一个用户每秒最多支持1次请求,每秒最多生成1个令牌
  • 整个接口每秒最多支持5次请求,每秒最多生成2个令牌(生产根据需求调整即可)

四、验证

4.1、单用户限流

  我这里就采用压力测试工具 jmeter 进行一个简单的压测了:因为我们代码暂时写的令牌容量是1个请求,每秒最多生成1个令牌。我们模拟1个用户5秒内请求10个次(不是那么精准),看看会有多少触发限流。 jmeter 设置如下图:

在这里插入图片描述

后台结果:

21:17:05 956 INFO [http-nio-8090-exec-1]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2.BAT001, result is true
21:17:05 958 INFO [http-nio-8090-exec-1]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2, result is true
21:17:05 984 INFO [http-nio-8090-exec-2]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2.BAT001, result is false
21:17:05 984 WARN [http-nio-8090-exec-2]:触发限流,key is : 33cd75b80483ce52ca96e58699ae97a2.BAT001 
21:17:06 486 INFO [http-nio-8090-exec-3]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2.BAT001, result is true
21:17:06 486 INFO [http-nio-8090-exec-3]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2, result is true
21:17:06 993 INFO [http-nio-8090-exec-5]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2.BAT001, result is false
21:17:06 993 WARN [http-nio-8090-exec-5]:触发限流,key is : 33cd75b80483ce52ca96e58699ae97a2.BAT001 
21:17:07 483 INFO [http-nio-8090-exec-7]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2.BAT001, result is true
21:17:07 483 INFO [http-nio-8090-exec-7]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2, result is true
21:17:07 977 INFO [http-nio-8090-exec-9]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2.BAT001, result is false
21:17:07 977 WARN [http-nio-8090-exec-9]:触发限流,key is : 33cd75b80483ce52ca96e58699ae97a2.BAT001 
21:17:08 474 INFO [http-nio-8090-exec-2]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2.BAT001, result is true
21:17:08 474 INFO [http-nio-8090-exec-2]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2, result is true
21:17:08 983 INFO [http-nio-8090-exec-3]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2.BAT001, result is false
21:17:08 983 WARN [http-nio-8090-exec-3]:触发限流,key is : 33cd75b80483ce52ca96e58699ae97a2.BAT001 
21:17:09 490 INFO [http-nio-8090-exec-5]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2.BAT001, result is true
21:17:09 493 INFO [http-nio-8090-exec-5]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2, result is true
21:17:09 983 INFO [http-nio-8090-exec-7]:rateLimiter 33cd75b80483ce52ca96e58699ae97a2.BAT001, result is false
21:17:09 983 WARN [http-nio-8090-exec-7]:触发限流,key is : 33cd75b80483ce52ca96e58699ae97a2.BAT001 

jmeter结果:

  在这里插入图片描述
  从上面的结果我们看到10个请求5秒内,有5个通过,5个限流了。

4.2、接口限流

  当我们的用户是满足上面,一秒请求一次的情况下,假设有10个用户并发请求到我们系统了,同样会触发限流。不过,你可以不要认为1秒内10个用户发10个请求,一定是前面5个能请求通过,后面5个不通过,如果是同一秒内,那么结果是这样的,如果是跨秒,比如像下面这样(10个请求还是在一秒内):

开始时间
前段时间2023-03-02 16:30:20 5002023-03-02 16:30:20 999
后段时间2023-03-02 16:30:21 0002023-03-02 16:30:21 500

  则前段时间请求数可以从0到10,我们分别列举下情况(假设当前秒内令牌桶数是5,每秒最多填充2个令牌):

前段请求数(个)后段请求数(个)前段通过数(个)后段通过数(个)限流数(个)
010055
19154
28253
37343
46433
55523
64523
73523
82523
91514
100505

  从这里可以看到限流可能与请求是否跨秒和请求数总容量和填充速率配置有关。大家也看的我写的每秒最多填充2个令牌,如果超过1秒但是还没有超时,那么一定要弄懂这句,这句就是核心Lua代码。

-- 同1秒内的timeGaps的值都是0,令牌桶的数不会增加,直到扣减完,超过1秒的都会填充令牌
local filledToken = math.min(capacity, lastToken + (timeGaps * rate))

结语

  从上面大家就发现这个组合注解 @RateLimiters 的强大了,因为它可以同时解决单用户限流和多用户限流。可能小伙伴觉得这个不灵活,令牌的控制都写死在接口了(比如:令牌桶数,令牌生成速率),不能随时调整,这个还不简单?你可以把自定义注解的值都通过系统配置,然后缓存到redis中,然后AOP中处理@RateLimiter 通过缓存或接口获取即可。我们主要是要了解这个限流的思路,实际中一般都会把这个AOP写成一个自定义的Starter,供其他的项目引入依赖使用。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/385668.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Lsof命令介绍

LSOF&#xff08;List Open Files&#xff09;是一款功能强大的开源工具&#xff0c;用于列出当前系统上打开的文件和进程。该工具可以帮助系统管理员和开发人员快速查找正在使用某个文件的进程&#xff0c;以及在系统上使用磁盘空间最多的进程。 本文将介绍LSOF的基本用法和常…

计算机网络自检

1 计网体系结构 因特网结构&#xff1a; 计网三个组成成分&#xff1a; 工作方式-其中2个部分&#xff1a; 功能-两个子网&#xff1a; 5个XAN分别是&#xff1a; 传输技术&#xff0c;两者的主要区别&#xff1a; 4种基本网络拓扑结构&#xff1a; 3种交换技术&#xff1a; 协…

南卡NEO骨传导首发新机,超前无线充设计,树立行业标杆!!!

​3月2号&#xff0c;更专业的骨传导运动耳机——南卡&#xff0c;发布了以轻运动为全新方向系列的南卡NEO&#xff0c;通过迭代升级的声学技术进一步的优化了音质&#xff0c;打造更强一代的音质体验。 音质全新升级&#xff0c;分”響“全新体验 一直以来南卡专业的实验团队…

计算机网络第5章(运输层)学习笔记

❤ 作者主页&#xff1a;欢迎来到我的技术博客&#x1f60e; ❀ 个人介绍&#xff1a;大家好&#xff0c;本人热衷于Java后端开发&#xff0c;欢迎来交流学习哦&#xff01;(&#xffe3;▽&#xffe3;)~* &#x1f34a; 如果文章对您有帮助&#xff0c;记得关注、点赞、收藏、…

linux环境创建anaconda虚拟环境安装tensorflow-gpu版本

linux环境创建anaconda虚拟环境安装tensorflow-gpu版本1.找到相应版本2.下载步骤2.1选择下载版本2.2 创建虚拟环境2.3 进入虚拟环境2.5 更新三个包2.6 安装tensorflow和keras2.7 验证是否安装成功2.8 检验GPU是否可用2.9 测试代码3.成功&#xff0c;终于成功了&#xff01;&…

spring-boot rabbitmq整合

文章请参考&#xff1a;Springboot 整合RabbitMq &#xff0c;用心看完这一篇就够了 mven依赖 <dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId></depende…

张大大直播爆火?跨境电商直播能从中学到什么

最近这个张大大的直播间确实很火哈&#xff0c;连龙哥这个不怎么看娱乐新闻的也经常刷到。龙哥专门去看了几次张大大的这个直播&#xff0c;确实有点东西。每场都可以做到这么高流量着实是不容易。龙哥就凭借自己一些电商直播经验&#xff0c;给大家总结一下&#xff0c;我们可…

年薪20W软件测试工程师必备的6大技能(建议收藏)

软件测试 随着软件开发行业的日益发展&#xff0c;岗位需求量和行业薪资都不断增长&#xff0c;想要入行的人也是越来越多&#xff0c;但不知道从哪里下手&#xff0c;今天&#xff0c;就给大家分享一下&#xff0c;软件测试行业都有哪些必会的方法和技术知识点&#xff0c;作…

Java知识补充

ArrayList 继承结构 为什么要先继承AbstractList&#xff0c;而让AbstractList先实现List&#xff1f;而不是让ArrayList直接实现List&#xff1f; 这里是有一个思想&#xff0c;接口中全都是抽象的方法&#xff0c;而抽象类中可以有抽象方法&#xff0c;还可以有具体的实现方…

无聊小知识.03 Springboot starter配置自动提示

1、前言Springboot项目配置properties或yaml文件时候&#xff0c;会有很多spring相关的配置提示。这个是如何实现的&#xff1f;如果我们自己的配置属性&#xff0c;能否也自动提示&#xff1f;2、Springboot配置自动提示其实IDE是通过读取配置信息的元数据而实现自动提示的。S…

HCIE-Cloud Computing LAB备考第二步:逐题攻破--第一题:FusionCompute--合并小节

FusionCompute 三大要求&#xff1a;扩容对接共享存储windows版本的FC操作 FCWindows题目思维导图 扩容一台CNA节点&#xff0c;配置管理地址设置为&#xff1a;192.168.100.212。密码设置为&#xff1a;Cloud12#$。主机名设置为CNA2。【安装CNA 】【ctrlaltdel之后按esc选择…

双向链表+循环链表

循环链表双向链表 循环链表 循环链表是头尾相接的链表(即表中最后一个结点的指针域指向头结点&#xff0c;整个链表形成一个环)(circular linked list) **优点&#xff1a;**从表中任一结点出发均可访问全部结点 循环链表与单链表的主要差别当链表遍历时&#xff0c;判别当前…

精准测试对于覆盖率技术的全新诠释

对于白盒测试有深入研究的技术人员可能会问到&#xff0c;精准测试还是很多用到了覆盖率技术&#xff0c;这些本来不就是有开源的工具吗?下面我们来比较一下&#xff1a; 开源的覆盖率工具&#xff1a; 1、 将所有的测试产生的覆盖率混在一起&#xff0c;不具备快速定位缺陷与…

Pycharm补丁包使用教程

虽然社区版在大多情况下已经够用&#xff0c;但是有很多功能都是没有的&#xff0c;对照起一些教程之类的就很不方便 现在直接教一种简单中的简单的补丁包使用方法 我这里用的是 pycharm 19.2.6 注意右下角的configure 一般别的方法都是 打开&#xff0c;然后添加路径&#…

关于提高PX4抗风性

滚转角速率控制器&#xff1a;&#xff08;MC_ROLLRATE_P&#xff0c; MC_ROLLRATE_I&#xff0c; MC_ROLLRATE_D&#xff09; 滚转角速率控制器&#xff1a;&#xff08;MC_PITCHRATE_P&#xff0c; MC_PITCHRATE_I&#xff0c;MC_PITCHRATE_D&#xff09; 滚转角速率控制器…

php宝塔搭建部署实战CSM会议室预约系统源码

大家好啊&#xff0c;我是测评君&#xff0c;欢迎来到web测评。 本期给大家带来一套基于fastadmin开发的CSM会议室预约系统的源码。感兴趣的朋友可以自行下载学习。 技术架构 PHP7.2 nginx mysql5.7 JS CSS HTMLcnetos7以上 宝塔面板 文字搭建教程 下载源码&#xff0…

5年软测,女朋友跑了俩,2年外包感觉自己废了一半,怎么办?

17年毕业&#xff0c;校招毕业就进入一家软件公司&#xff0c;干了2年的点工&#xff0c;随后进入一家外包公司工作至今&#xff0c;安逸使人堕落不知进取&#xff0c;加之随着近年的环境不景气&#xff0c;谈了多年将要结婚的女朋友也因为我的心态和工资要跟我闹分手我想改变现…

nodejs安装和卸载超详细步骤

安装程序①下载完成后&#xff0c;双击安装包&#xff0c;开始安装&#xff0c;使用默认配置安装一直点next即可&#xff0c;安装路径默认在C:\Program Files下&#xff0c;也可以自定义修改②安装路径默认在C:\Program Files下面&#xff0c;也能够自定义修改&#xff0c;而后…

力扣-2020年最后一次登录

大家好&#xff0c;我是空空star&#xff0c;本篇带大家了解一道简单的力扣sql练习题。 文章目录前言一、题目&#xff1a;1890. 2020年最后一次登录二、解题1.正确示范①提交SQL运行结果2.正确示范②提交SQL运行结果3.正确示范③提交SQL运行结果4.正确示范④提交SQL运行结果5.…

ThreadLocal学会了这些,你也能和面试官扯皮了!

前言 我们都知道,在多线程环境下访问同一个共享变量,可能会出现线程安全的问题,为了保证线程安全,我们往往会在访问这个共享 变量的时候加锁,以达到同步的效果,如下图所示。 对共享变量加锁虽然能够保证线程的安全,但是却增加了开发人员对锁的使用技能,如果锁使用不当…