提醒注意

本文不是个人测试,而是已经应用在线上,自定义限流组件代码。可以在线上推广使用。无需惧怕量级。

采用了计数器+滑动窗口配合实现, 借鉴了著名的 Sentinel 滑动窗口思想:

redis滑动窗口限流 java redis lua限流_redis滑动窗口限流 java

需求描述

按照 每小时,每分钟,每秒 维度 进行分布式限流。

 

效果展示

首先来看下压测jmeter配置:

redis滑动窗口限流 java redis lua限流_限流_02

每秒200个线程访问限流接口,无限循环下去。

后台设置的接口限流条件为: 5次/每秒 ,代码:

redis滑动窗口限流 java redis lua限流_redis_03

观察后台输出情况:

redis滑动窗口限流 java redis lua限流_限流_04

这里我抽查了几个, 发现每秒确实是 5次成功拿锁。 证明我们的限流代码没有问题。

 

Redis内存占用情况:

redis滑动窗口限流 java redis lua限流_redis_05

空间占用只有一个zset集合,而且个数为5个,也就是每秒通过的个数,也就是说如果 设置每秒100w个请求通过, 那么zset将有100w大。我相信你不会这么设置!!!!!

 

当没有请求访问时,1秒后,zset集合将清空(不要担心会永占内存):

redis滑动窗口限流 java redis lua限流_spring_06

代码讲解

 

实现思想:

《 计数器限流 + 滑动窗口 》

当限流条件是每小时,每分钟 时,由于精度不是很大,可以采用计数器限流,优点:代码简单,redis内存只占用一个 string 类型的key。

当限流条件是每秒时。 由于可能会产生瞬时,double流量进入问题,采用滑动窗口实现。

 

撸代码

首先定义一个限流接口:

/***
 * 
 * title: 分布式限流器
 *
 * @author HadLuo
 * @date 2021-5-28 9:45:41
 */
public interface ClusterRateLimiter {

    /***
     * 
     * title: 返回0 表示 限流住了,否则返回剩余次数
     *
     * @param key:限流标识
     * @param limitCount:1个单位时间内允许通过的最大次数
     * @return
     * @author HadLuo 2021-5-28 9:48:45
     */
    public Long acquire(String key, Integer limitCount);

    /***
     * 
     * title: 返回0 表示 限流住了,否则返回剩余次数
     *
     * @param key:限流标识
     * @param limitCount:单位时间内允许通过的最大次数
     * @param interval:单位时间
     * @return
     * @author HadLuo 2021-5-28 9:48:45
     */
    public Long acquire(String key, Integer limitCount, Integer interval);
}

计数器实现类:

package com.uc.framework.redis.limit;

import java.util.Collections;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.util.StringUtils;

/***
 * 
 * title: Redis + Lua 限流实现 计数器限流
 *
 * @author HadLuo
 * @date 2021-5-28 9:41:19
 */
public class RedisLuaOfCountedLimiter implements InitializingBean, ClusterRateLimiter {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    DefaultRedisScript<Long> script;

    @Override
    public void afterPropertiesSet() throws Exception {
        // 加载 lua
        script = new DefaultRedisScript<>();
        script.setScriptSource(new ResourceScriptSource(new ClassPathResource("counter_limit.lua")));
        script.setResultType(Long.class);

    }

    @Override
    public Long acquire(String key, Integer limitCount, Integer interval) {
        if (StringUtils.isEmpty(key) || limitCount <= 0 || interval <= 0) {
            return 0L;
        }
        return (Long)redisTemplate.execute(script, Collections.singletonList(getKey(key)), limitCount + "",
            interval + "");
    }

    private String getKey(String key) {
        return "cluster:limit:" + key;
    }

    @Override
    public Long acquire(String key, Integer limitCount) {
        throw new UnsupportedOperationException();
    }
}

滑动窗口实现类:

package com.uc.framework.redis.limit;

import java.util.Collections;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.scripting.support.ResourceScriptSource;
import org.springframework.util.StringUtils;

/***
 * 
 * title: Redis + Lua 限流实现 滑动窗口限流
 *
 * @author HadLuo
 * @date 2021-5-28 9:41:19
 */
public class RedisLuaOfSlidingLimiter implements InitializingBean, ClusterRateLimiter {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    DefaultRedisScript<Long> script;

    @Override
    public void afterPropertiesSet() throws Exception {
        // 加载 lua
        script = new DefaultRedisScript<>();
        script.setScriptSource(new ResourceScriptSource(new ClassPathResource("sliding_limit.lua")));
        script.setResultType(Long.class);

    }

    @Override
    public Long acquire(String key, Integer limitCount) {
        if (StringUtils.isEmpty(key) || limitCount <= 0) {
            return 0L;
        }
        // 1秒 = 1 000 000 000纳秒
        final long windowSize = 1000000000L;
        // 1000000000
        Long ret = (Long)redisTemplate.execute(script, Collections.singletonList(getKey(key)), System.nanoTime() + "",
            windowSize + "", limitCount + "");
        return ret;
    }

    private String getKey(String key) {
        return "cluster:limit:" + key;
    }

    @Override
    public Long acquire(String key, Integer limitCount, Integer interval) {
        throw new UnsupportedOperationException();
    }
}

Context环境,用来构造上面两个类:

package com.uc.framework.redis.limit;

import com.uc.framework.App;

public final class LimiterContext {

    public enum Typer {
        Second, Minute, Hour, Day
    }

    private static volatile ClusterRateLimiter counter, sliding;

    private static final Object LockOfSliding = new Object();
    private static final Object LockOfCounter = new Object();

    public static boolean acquire(Typer typer, String key, int limitCount) {
        if (typer == Typer.Second) {
            // 精度高,走滑动窗口
            if (sliding == null) {
                synchronized (LockOfSliding) {
                    if (sliding == null) {
                     // 由于RedisLuaOfSlidingLimiter里面注入了RedisTemplate ,所以要注入到spring
                        sliding = App.registerBeanDefinition(RedisLuaOfSlidingLimiter.class);
                    }
                }
            }
            long ret = sliding.acquire(key, limitCount);
            return ret > 0;
        }
        // 按 每分钟, 每小时 ,每天 限流 走计数器, 不耗资源
        if (counter == null) {
            synchronized (LockOfCounter) {
                if (counter == null) {
                    counter = App.registerBeanDefinition(RedisLuaOfCountedLimiter.class);
                }
            }
        }
        int segment;
        if (typer == Typer.Minute) {
            segment = 60; // 分
        } else if (typer == Typer.Hour) {
            segment = 60 * 60; // 时
        } else {
            segment = 24 * 60 * 60; // 天
        }
        long ret = counter.acquire(key, limitCount, segment);
        return ret > 0;
    }
}

我们是判断秒级限流,就用RedisLuaOfSlidingLimiter滑动窗口实现,否则 就是 RedisLuaOfCountedLimiter计数器实现,两个类都使用动态注册到spring。

App代码:

package com.uc.framework;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.beans.factory.support.GenericBeanDefinition;
import org.springframework.core.ResolvableType;
import org.springframework.stereotype.Component;

import com.google.common.base.Preconditions;

/**
 * App spring 句柄方便获取
 * 
 * @author HadLuo
 * @date 2020-9-2 11:15:54
 */
@Component
public class App implements BeanFactoryAware {

    private static BeanFactory beanFactory;

    public static BeanFactory getBeanFactory() {
        return beanFactory;
    }

    @Override
    public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
        Preconditions.checkNotNull(beanFactory);
        App.beanFactory = beanFactory;
    }

    public static Object getBean(String name) throws BeansException {
        // TODO Auto-generated method stub
        return beanFactory.getBean(name);
    }
   // -----------自己实现 其它方法


  

    /***
     * 
     * title: 动态注入spring
     *
     * @param clazz
     * @author HadLuo 2021-5-31 10:42:24
     * @param <T>
     */
    public static <T> T registerBeanDefinition(Class<T> clazz) {
        BeanDefinition beanDefinition = new GenericBeanDefinition();
        beanDefinition.setBeanClassName(clazz.getName());
        ((DefaultListableBeanFactory)beanFactory).registerBeanDefinition(clazz.getName(), beanDefinition);
        return beanFactory.getBean(clazz);
    }

}

RedisTemplate 和 App配置:

@Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<String, Object>();
        template.setConnectionFactory(factory);
        StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
        // key采用String的序列化方式
        template.setKeySerializer(stringRedisSerializer);
        // hash的key也采用String的序列化方式
        template.setHashKeySerializer(stringRedisSerializer);
        // value序列化方式采用jackson
        template.setValueSerializer(stringRedisSerializer);
        // hash的value序列化方式采用jackson
        template.setHashValueSerializer(stringRedisSerializer);
        // template.afterPropertiesSet();
        return template;

    }

    @Bean
    public App app() {
        return new App();
    }

接下来 就只有两个lua脚本了。放在环境resource目录下即可:

计数器counter_limit.lua :

local identify = tostring(KEYS[1])
local expireSeconds = tonumber(ARGV[2])
local limitTimes = tonumber(ARGV[1])
- 当前次数值
local times = redis.call("GET", identify)
- 不为nil, lua里面nil判断要用false
if times ~= false then
  times = tonumber(times)
  if times >= limitTimes then  - 大于限流次数,返回限流了
    return 0
  else
    redis.call("INCR", identify)  - 增加次数
    return 1
  end
end

-- 不存在的话,设置为1并设置过期时间
local flag = redis.call("SETEX", identify, expireSeconds, 1)

return 1

 

 

滑动窗口lua脚本 , 比较重要。