当前位置: 首页 > 科技观察

Redis做接口限流,注解的事!

时间:2023-03-14 21:43:34 科技观察

Redis除了缓存还可以做很多事情:分布式锁、限流、处理请求接口幂等。..太多了~今天想和小伙伴们聊聊用Redis处理接口限流的问题。这也是最近TienChin项目中与这个知识点相关的一个知识点,所以拿出来和大家聊聊这个话题。1、准备工作首先,我们创建一个SpringBoot项目,引入Web和Redis依赖。同时考虑到接口限流一般都是通过注解来标记,而注解是通过AOP来解析的,所以我们还需要添加AOP依赖。最终的依赖如下:弹簧框架。启动spring-boot-starter-weborg.springframework.bootspring-boot-starter-aop然后提前准备一个Redis实例。这里我们的项目配置好后,就可以直接配置Redis的基本信息了,如下:spring.redis.host=localhostspring.redis.port=6379spring.redis.password=123好了,准备工作就绪。2.限流注解接下来,我们创建一个限流注解。我们把限流分为两种情况:一种是对当前接口进行全局限流,比如1分钟内可以访问该接口100次。对某个IP地址进行限流,比如某个IP地址在1分钟内可以被访问100次。针对这两种情况,我们创建一个枚举类:publicenumLimitType{/***Defaultpolicyglobalcurrentlimit*/DEFAULT,/***currentlimitbasedonrequesterIP*/IP}接下来我们创建RateLimiter注解:@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)@Documentedpublic@interfaceRateLimiter{/***限流键*/Stringkey()default"rate_limit:";/***限流时间,单位秒*/inttime()default60;/***限流次数*/intcount()default100;/***限流类型*/LimitTypelimitType()defaultLimitType.DEFAULT;}first参数限流键只是一个前缀。以后完整的key将由前缀加上接口方法的全路径组成限流key。此密钥将存储在Redis中。其他三个参数很容易理解,就不多说了。好吧,以后需要在哪个接口限流,就在那个接口上添加注解,然后配置相关参数就可以了。@RateLimiter3。自定义RedisTemplate小伙伴都知道,在SpringBoot中,我们其实更习惯使用SpringDataRedis来操作Redis,但是默认的RedisTemplate有个小坑,就是使用了JdkSerializationRedisSerializer来进行序列化。没有注意到直接使用这个序列化工具存储在Redis上的key和value会莫名其妙的有一些前缀,使用命令读取的时候可能会出错。比如存储的时候,key是name,value是javaboy,但是在命令行操作的时候,却获取不到想要的数据。原因是保存到redis后名字前面有一些字符。继续使用RedisTemplate读取出来。获取名称我们使用Lua脚本对Redis进行限流。在使用Lua脚本的时候,会出现上面提到的情况,所以我们需要修改RedisTemplate的序列化方案。可能有些朋友会说为什么不用StringRedisTemplate呢?StringRedisTemplate不存在上述问题,但是其可以存储的数据类型不够丰富,这里暂不考虑。修改RedisTemplate的序列化方案,代码如下:redisTemplate.setConnectionFactory(connectionFactory);//使用Jackson2JsonRedisSerialize替换默认序列化(默认使用JDK序列化)Jackson2JsonRedisSerializerjackson2JsonRedisSerializer=newJackson2JsonRedisSerializer<>(Object.class);ObjectMapperom=newObjectMapper();om.setVisibility(PropertyAccessor.ALL,JsonAutoDetect.Visibility.ANY);om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);jackson2JsonRedisSerializer.setObjectMapper(om);redisTemplate.setKeySerializer(jackson2JsonRedisSerializer);redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);redisTemplate.setHashKeySerializer(千斤顶on2JsonRedisSerializer);redisTemplate.setHashValueSerializer(jackson2JsonRedisSerializer);返回redis模板;}}这个没什么好说的,我们俩都是用SpringBoot中默认的jackson序列化方式来解决的4.开发Lua脚本这其实是我之前做的vhr视频中提到的,可以实现Redis中的一些原子操作在Lua脚本的帮助下。调用Lua脚本,我们有两种不同的思路:在Redis服务器上定义Lua脚本,然后计算一个Hash值,在Java代码中,通过这个Hash值来锁定执行哪个Lua脚本。直接在Java代码中定义Lua脚本,然后发送到Redis服务器执行。SpringDataRedis也提供了操作Lua脚本的接口,还是比较方便的,所以我们这里采用第二种方案。我们在resources目录下新建一个lua文件夹,用来存放lua脚本。脚本内容如下:localkey=KEYS[1]localcount=tonumber(ARGV[1])localtime=tonumber(ARGV[2])localcurrent=redis.call('get',key)ifcurrentandtonumber(current)>count然后返回tonumber(current)endcurrent=redis.call('incr',key)iftonumber(current)==1thenredis.call('expire',key,time)endreturntonumber(current)这个脚本其实并不难,大概一看就知道是干什么用的。KEYS和ARGV是一会儿调用时传入的参数,tonumber是将字符串转成数字,redis.call是执行具体的redis命令。具体过程如下:首先获取传入的key以及当前limit和time时间的计数。通过get获取到这个key对应的值,这个值就是当前时间窗口内这个接口可以被访问的次数。如果是第一次访问,此时得到的结果是nil,否则得到的结果应该是一个数字,所以下一步就是判断,如果得到的结果是一个数字,并且这个数字还是大于count,then表示已经超过流量限制,可以直接返回查询结果。如果得到的结果是nil,说明是第一次访问。此时,当前key会加1,然后设置一个过期时间。最后返回加1后的值即可。其实这个Lua脚本很好理解。接下来,我们将这个Lua脚本加载到一个Bean中,如下所示:@BeanpublicDefaultRedisScriptlimitScript()redisScript.setScriptSource(newResourceScriptSource(newClassPathResource("lua/limit.lua")));redisScript.setResultType(Long.class);returnredisScript;}好的,我们的Lua脚本现在准备好了。5.注解解析接下来,我们需要自定义切面来解析注解。我们看一下切面的定义:@Aspect@ComponentpublicclassRateLimiterAspect{privatestaticfinalLoggerlog=LoggerFactory.getLogger(RateLimiterAspect.class);@AutowiredprivateRedisTemplateredisTemplate;@AutowiredprivateRedisScriptlimitScript;@Before("@annotation(rateLimiter)")publicvoiddoBefore(JoinPointpoint,RateLimiterrateLimiter)throwsThrowable{Stringkey=rateLimiter.key();inttime=rateLimiter.time();intcount=rateLimiter.count();StringcombineKey=getCombineKey(rateLimiter,point);Listkeys=Collections.singletonList(combineKey);try{Longnumber=redisTemplate.执行(limitScript,键,计数,时间);if(number==null||number.intValue()>count){thrownewServiceException("访问过于频繁,请稍后再试");}log.info("限制请求'{}',当前请求'{}',缓存key'{}'",count,number.intValue(),key);}catch(ServiceExceptione){throwe;}catch(Exceptione){thrownewRuntimeException("服务器限制异常,请稍后再试");}}publicStringgetCombineKey(RateLimiterrateLimiter,JoinPointpoint){StringBufferstringBuffer=newStringBuffer(rateLimiter.key());if(rateLimiter.limitType()==LimitType.IP){stringBuffer.append(IpUtils.getIpAddr(((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest())).append("-");}MethodSignaturesignature=(MethodSignature)point.getSignature();Method方法=signature.getMethod();ClasstargetClass=method.getDeclaringClass();stringBuffer.append(targetClass.getName()).append("-").append(method.getName());returnstringBuffer.toString();}}这方面是拦截所有的在带注解的方法,在预通知中处理注解@RateLimiter首先获取注解中的key、time、count三个参数。获得组合密钥。所谓组合键就是在注解的键属性的基础上加上方法的全路径。如果是IP模式,添加IP地址。以IP模式为例,最终生成的key是这样的:(如果不是IP模式,生成的key不包含IP地址)。rate_limit:127.0.0.1-org.javaboy.ratelimiter.controller.HelloController-hello将生成的key放入集合中。通过redisTemplate.execute方法执行一个Lua脚本,第一个参数是脚本封装的对象,第二个参数是key,对应脚本中的KEYS,后面是变长参数,对应脚本中的ARGV脚本。将Lua脚本执行的结果与count进行比较,如果大于count,则表示过载,抛出异常即可。好的,你完成了。6、接口测试接下来我们对接口进行简单的测试,如下:@RestControllerpublicclassHelloController{@GetMapping("/hello")@RateLimiter(time=5,count=3,limitType=LimitType.IP)publicStringhello(){return"hello>>>"+newDate();}}每个IP地址在5秒内只能访问3次。这可以通过手动刷新浏览器来测试。7.全局异常处理由于重载时会抛出异常,所以我们还需要一个全局的异常处理器,如下:String,Object>map=newHashMap<>();map.put("状态",500);map.put("消息",e.getMessage());返回地图;}}这是一个小demo,我就不定义实体类了,直接用Map返回JSON。好的,你完成了。最后看下过载时的测试效果:好了,我们使用Redis限流就是这样的。