• 使用reids实现限流


    1.今天我们就基于Redis组件的特性,实现一个分布式限流组件,

    原理
    首先解释下为何采用Redis作为限流组件的核心。

    通俗地讲,假设一个用户(用IP判断)每秒访问某服务接口的次数不能超过10次,那么我们可以在Redis中创建一个键,并设置键的过期时间为60秒。

    当一个用户对此服务接口发起一次访问就把键值加1,在单位时间(此处为1s)内当键值增加到10的时候,就禁止访问服务接口。PS:在某种场景中添加访问时间间隔还是很有必要的。我们本次不考虑间隔时间,只关注单位时间内的访问次数。

    2. 开发核心

    2.1 基于Redis的incr及过期机制开发调用方便。

    2.2声明式Spring支持

    另外,在本次开发中,我们不通过简单的调用Redis的java类库API实现对Redis的incr操作。

    原因在于,我们要保证整个限流的操作是原子性的,如果用Java代码去做操作及判断,会有并发问题。这里我决定采用Lua脚本进行核心逻辑的定义。

    为何使用Lua
    在正式开发前,我简单介绍下对Redis的操作中,为何推荐使用Lua脚本。

    减少网络开销: 不使用 Lua 的代码需要向 Redis 发送多次请求, 而脚本只需一次即可, 减少网络传输;
    原子操作: Redis 将整个脚本作为一个原子执行, 无需担心并发, 也就无需事务;
    复用: 脚本会永久保存 Redis 中, 其他客户端可继续使用.
    Redis添加了对Lua的支持,能够很好的满足原子性、事务性的支持,让我们免去了很多的异常逻辑处理。对于Lua的语法不是本文的主要内容,感兴趣的可以自行查找资料。

    3. 正式开发

    3.1 引入依赖

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter</artifactId>
      <version>1.4.2.RELEASE</version>
    </dependency>

    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-redis</artifactId>
      <version>1.4.2.RELEASE</version>
    </dependency>

     3.2 新建一个Redis的配置类,命名为RedisCacheConfig,使用javaconfig形式注入RedisTemplate

    /**
     * @author wangbs
     * @version 1.0
     * @date 2019/12/17 1:15
     * @className RedisCacheConfig
     * @desc Redis配置
     */
    @Configuration
    public class RedisCacheConfig {
    
    
        private static final Logger LOGGER = LoggerFactory.getLogger(RedisCacheConfig.class);
    
        /**
         * 配置自定义序列化器
         * @return
         */
        @Bean
        public RedisCacheConfiguration redisCacheConfiguration() {
            return RedisCacheConfiguration
                    .defaultCacheConfig()
                    .serializeKeysWith(
                            RedisSerializationContext
                                    .SerializationPair
                                    .fromSerializer(new StringRedisSerializer()))
                    .serializeValuesWith(
                            RedisSerializationContext
                                    .SerializationPair
                                    .fromSerializer(new GenericJackson2JsonRedisSerializer()));
        }
    
        @Bean
        public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
            RedisTemplate<String, Object> template = new RedisTemplate<>();
            template.setConnectionFactory(factory);
    
            //使用Jackson2JsonRedisSerializer来序列化和反序列化redis的value值(默认使用JDK的序列化方式)
            Jackson2JsonRedisSerializer serializer = new Jackson2JsonRedisSerializer(Object.class);
    
            ObjectMapper mapper = new ObjectMapper();
            mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
            mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
            serializer.setObjectMapper(mapper);
    
            template.setValueSerializer(serializer);
            //使用StringRedisSerializer来序列化和反序列化redis的key值
            template.setKeySerializer(new StringRedisSerializer());
            template.afterPropertiesSet();
            LOGGER.info("Springboot RedisTemplate 加载完成");
            return template;
        }
    }
    

      3.3  调用方application.propertie需要增加Redis配置

     #单机模式redis
        spring.redis.host=127.0.0.1
        spring.redis.port=6379
        spring.redis.pool.maxActive=8
        spring.redis.pool.maxWait=-1
        spring.redis.pool.maxIdle=8
        spring.redis.pool.minIdle=0
        spring.redis.timeout=10000
        spring.redis.password=
    

      3.4 自定义限流使用的注解 RateLimiter

      该注解明确只用于方法,主要有三个属性。

      key--表示限流模块名,指定该值用于区分不同应用,不同场景,推荐格式为:应用名:模块名:ip:接口名:方法名
      limit--表示单位时间允许通过的请求数
      expire--incr的值的过期时间,业务中表示限流的单位时间。

    /**
     * @author wangbs
     * @version 1.0
     * @date 2019/12/16 1:25
     * @className RateLimiter
     * @desc 限流注解
     */
    
    //注解作用域
    //        ElementType.TYPE:允许被修饰的注解作用在类、接口和枚举上
    //        ElementType.FIELD:允许作用在属性字段上
    //        ElementType.METHOD:允许作用在方法上
    //        ElementType.PARAMETER:允许作用在方法参数上
    //        ElementType.CONSTRUCTOR:允许作用在构造器上
    //        ElementType.LOCAL_VARIABLE:允许作用在本地局部变量上
    //        ElementType.ANNOTATION_TYPE:允许作用在注解上
    //        ElementType.PACKAGE:允许作用在包上
    //
    //        注解的生命周期
    //        RetentionPolicy.SOURCE:当前注解编译期可见,不会写入 class 文件
    //	RetentionPolicy.CLASS:类加载阶段丢弃,会写入 class 文件
    //	RetentionPolicy.RUNTIME:永久保存,可以反射获取
    
    // 注解的作用域
    @Target(ElementType.METHOD)
    // 注解的生命周期
    @Retention(RetentionPolicy.RUNTIME)
    // 允许子类继承
    @Inherited
    // 生成javadoc的时候生成注解的信息
    @Documented
    public @interface RateLimiter {
    
        /**
         * 限流key
         * @return
         */
        String key() default "rate:limiter";
        /**
         * 单位时间限制通过请求数
         * @return
         */
        long limit() default 10;
    
        /**
         * 过期时间,单位秒
         * @return
         */
        long expire() default 1;
    
        /**
         * 限流提示语
         * @return
         */
        String message() default "false";
    }
    

    3.5 定义开发注解使用的切面,这里我们直接使用aspectj进行切面的开发

    /**
    * @author wangbs
    * @version 1.0
    * @date 2019/12/16 1:17
    * @className RateLimterHandler
    * @desc 限流处理器
    */
    @Aspect
    @Component
    public class RateLimterHandler {

    private static final Logger LOGGER = LoggerFactory.getLogger(RateLimterHandler.class);

    @Autowired
    RedisTemplate redisTemplate;

    private DefaultRedisScript<Long> getRedisScript;

    /**
         *@PostConstruct修饰的方法会在服务器加载Servlet的时候运行,并且只会被服务器执行一次。PostConstruct在构造函数之后执行,init()方法之前执行。
    * 这里是注入了RedisTemplate,使用其API进行Lua脚本的调用。
    *
    * init() 方法在应用启动时会初始化DefaultRedisScript,并加载Lua脚本,方便进行调用。
    *
    * PS: Lua脚本放置在classpath下,通过ClassPathResource进行加载。
    */
    @PostConstruct
    public void init() {
    getRedisScript = new DefaultRedisScript<>();
    getRedisScript.setResultType(Long.class);
    getRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("rateLimter.lua")));
    LOGGER.info("RateLimterHandler[分布式限流处理器]脚本加载完成");
    }

    /**
    * 这里我们定义了一个切点,表示只要注解了 @RateLimiter 的方法,均可以触发限流操作。
    */
    @Pointcut("@annotation(com.vx.servicehi.annotation.RateLimiter)")
    public void rateLimiter() {}

    /**
    * 这段代码的逻辑为,获取 @RateLimiter 注解配置的属性:key、limit、expire,并通过 redisTemplate.execute(RedisScript script, List keys, Object... args) 方法传递给Lua脚本进行限流相关操作,逻辑很清晰。
    *
    * 这里我们定义如果脚本返回状态为0则为触发限流,1表示正常请求。
    * @param proceedingJoinPoint
    * @param rateLimiter
    * @return
    * @throws Throwable
    */
    @Around("@annotation(rateLimiter)")
    public Object around(ProceedingJoinPoint proceedingJoinPoint, RateLimiter rateLimiter) throws Throwable {
    if (LOGGER.isDebugEnabled()) {
    LOGGER.debug("RateLimterHandler[分布式限流处理器]开始执行限流操作");
    }
    Signature signature = proceedingJoinPoint.getSignature();
    if (!(signature instanceof MethodSignature)) {
    throw new IllegalArgumentException("the Annotation @RateLimter must used on method!");
    }
    /**
    * 获取注解参数
    */
    // 限流模块key
    String limitKey = rateLimiter.key();
    if(StringUtils.isBlank(limitKey)){
    throw new NullPointerException();
    }
    // 限流阈值
    long limitTimes = rateLimiter.limit();
    // 限流超时时间
    long expireTime = rateLimiter.expire();
    if (LOGGER.isDebugEnabled()) {
    LOGGER.debug("RateLimterHandler[分布式限流处理器]参数值为-limitTimes={},limitTimeout={}", limitTimes, expireTime);
    }
    // 限流提示语
    String message = rateLimiter.message();
    if (StringUtils.isBlank(message)) {
    message = "false";
    }
    /**
    * 执行Lua脚本
    */
    List<String> keyList = new ArrayList();
    // 设置key值为注解中的值
    keyList.add(limitKey);
    /**
    * 调用脚本并执行
    */
    Long result = (Long) redisTemplate.execute(getRedisScript, keyList, expireTime, limitTimes);
    if (result == 0) {
    String msg = "由于超过单位时间=" + expireTime + "-允许的请求次数=" + limitTimes + "[触发限流]";
    LOGGER.debug(msg);
    return message;
    }
    if (LOGGER.isDebugEnabled()) {
    LOGGER.debug("RateLimterHandler[分布式限流处理器]限流执行结果-result={},请求[正常]响应", result);
    }
    return proceedingJoinPoint.proceed();
    }
    }

      3.6 Lua脚本 这里是我们整个限流操作的核心,通过执行一个Lua脚本进行限流的操作。脚本内容如下

    --获取KEY
    local key1 = KEYS[1]
    --给指定的key 值增加一,如果 key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作
    local val = redis.call('incr', key1)
    --以秒为单位返回 key 的剩余过期时间
    local ttl = redis.call('ttl', key1)
    
    --获取ARGV内的参数并打印
    local expire = ARGV[1]
    local times = ARGV[2]
    
    redis.log(redis.LOG_DEBUG,tostring(times))
    redis.log(redis.LOG_DEBUG,tostring(expire))
    
    redis.log(redis.LOG_NOTICE, "incr "..key1.." "..val);
    if val == 1 then
        redis.call('expire', key1, tonumber(expire))
    else
        if ttl == -1 then
    	--expire当key不存在或者不能为key设置生存时间时返回  0
            redis.call('expire', key1, tonumber(expire))
        end
    end
    
    if val > tonumber(times) then
        return 0
    end
    
    return 1
    

      

    逻辑很通俗,我简单介绍下。

    首先脚本获取Java代码中传递而来的要限流的模块的key,不同的模块key值一定不能相同,否则会覆盖!
    redis.call('incr', key1)对传入的key做incr操作,如果key首次生成,设置超时时间ARGV[1];(初始值为1)
    ttl是为防止某些key在未设置超时时间并长时间已经存在的情况下做的保护的判断;
    每次请求都会做+1操作,当限流的值val大于我们注解的阈值,则返回0表示已经超过请求限制,触发限流。否则为正常请求。
    当过期后,又是新的一轮循环,整个过程是一个原子性的操作,能够保证单位时间不会超过我们预设的请求阈值。

    3.7 测试


    这里我贴一下核心代码,我们定义一个接口,并注解 @RateLimiter(key = "ratedemo:1.0.0", limit = 5, expire = 100) 表示模块ratedemo:sendPayment:1.0.0 在100s内允许通过5个请求,这里的参数设置是为了方便看结果。实际中,我们通常会设置1s内允许通过的次数。

    @Controller
    public class TestController {

    private static final Logger LOGGER = LoggerFactory.getLogger(TestController.class);

    @ResponseBody
    @RequestMapping("ratelimiter")
    @RateLimiter(key = "ratedemo:1.0.0", limit = 5, expire = 100)
    public String sendPayment(HttpServletRequest request) throws Exception {

    return "正常请求";
    }

    }

     源码地址  https://github.com/wangbensen/common-parent

  • 相关阅读:
    C#中的Dictionary类,默认key是区分大小写的
    for循环的3个参数
    C#循环读取文件流,按行读取
    C#合并两个Dictionary的方法
    C#的Equals不区分大小写
    php的isset()和empty()区别
    css !important的作用
    mysql创建用户,并指定用户的权限(grant命令)
    解决安卓微信浏览器中location.reload 或者 location.href失效的问题
    【转】前端懒加载以及预加载
  • 原文地址:https://www.cnblogs.com/xiaowangbangzhu/p/13387243.html
Copyright © 2020-2023  润新知