使用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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | /** * @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配置
1 2 3 4 5 6 7 8 9 | #单机模式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的值的过期时间,业务中表示限流的单位时间。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 | /** * @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脚本进行限流的操作。脚本内容如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | --获取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
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构