场景
为了防止我们的接口被人恶意访问,比如有人通过JMeter工具频繁访问我们的接口,导致接口响应变慢甚至崩溃,所以我们需要对一些特定的接口进行IP限流,即一定时间内同一IP访问的次数是有限的。
实现原理
用Redis作为限流组件的核心的原理,将用户的IP地址当Key,一段时间内访问次数为value,同时设置该Key过期时间。
比如某接口设置相同IP10秒
内请求5次
,超过5次不让访问该接口。
1. 第一次该IP地址存入redis的时候,key值为IP地址,value值为1,设置key值过期时间为10秒。 2. 第二次该IP地址存入redis时,如果key没有过期,那么更新value为2。 3. 以此类推当value已经为5时,如果下次该IP地址在存入redis同时key还没有过期,那么该Ip就不能访问了。 4. 当10秒后,该key值过期,那么该IP地址再进来,value又从1开始,过期时间还是10秒,这样反反复复。
说明
从上面的逻辑可以看出,是一时间段内访问次数受限,不是完全不让该IP访问接口。
技术框架
SpringBoot + RedisTemplate (采用自定义注解完成)
这个可以用于真实项目开发场景。
这边采用自定义注解的目的就是,在接口上使用自定义注解,让代码看去非常整洁。
IpLimiter
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface IpLimiter { /** * 限流ip */ String ipAdress() ; /** * 单位时间限制通过请求数 */ long limit() default 10; /** * 单位时间,单位秒 */ long time() default 1; /** * 达到限流提示语 */ String message(); }
在接口上使用了自定义注解@IpLimiter
@Controller public class IpController { private static final Logger LOGGER = LoggerFactory.getLogger(IpController.class); private static final String MESSAGE = "请求失败,你的IP访问太频繁"; //这里就不获取请求的ip,而是写死一个IP @ResponseBody @RequestMapping("iplimiter") @IpLimiter(ipAdress = "127.198.66.01", limit = 5, time = 10, message = MESSAGE) public String sendPayment(HttpServletRequest request) throws Exception { return "请求成功"; } @ResponseBody @RequestMapping("iplimiter1") @IpLimiter(ipAdress = "127.188.145.54", limit = 4, time = 10, message = MESSAGE) public String sendPayment1(HttpServletRequest request) throws Exception { return "请求成功"; } }
这边采用切面的方式处理自定义注解。同时为了保证原子性,这边写了redis脚本ipLimiter.lua
来执行redis命令,来保证操作原子性。
@Aspect @Component public class IpLimterHandler { private static final Logger LOGGER = LoggerFactory.getLogger(IpLimterHandler.class); @Autowired RedisTemplate redisTemplate; /** * getRedisScript 读取脚本工具类 * 这里设置为Long,是因为ipLimiter.lua 脚本返回的是数字类型 */ private DefaultRedisScript<Long> getRedisScript; @PostConstruct public void init() { getRedisScript = new DefaultRedisScript<>(); getRedisScript.setResultType(Long.class); getRedisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("ipLimiter.lua"))); LOGGER.info("IpLimterHandler[分布式限流处理器]脚本加载完成"); } /** * 这个切点可以不要,因为下面的本身就是个注解 */ // @Pointcut("@annotation(com.jincou.iplimiter.annotation.IpLimiter)") // public void rateLimiter() {} /** * 如果保留上面这个切点,那么这里可以写成 * @Around("rateLimiter()&&@annotation(ipLimiter)") */ @Around("@annotation(ipLimiter)") public Object around(ProceedingJoinPoint proceedingJoinPoint, IpLimiter ipLimiter) throws Throwable { if (LOGGER.isDebugEnabled()) { LOGGER.debug("IpLimterHandler[分布式限流处理器]开始执行限流操作"); } Signature signature = proceedingJoinPoint.getSignature(); if (!(signature instanceof MethodSignature)) { throw new IllegalArgumentException("the Annotation @IpLimter must used on method!"); } /** * 获取注解参数 */ // 限流模块IP String limitIp = ipLimiter.ipAdress(); Preconditions.checkNotNull(limitIp); // 限流阈值 long limitTimes = ipLimiter.limit(); // 限流超时时间 long expireTime = ipLimiter.time(); if (LOGGER.isDebugEnabled()) { LOGGER.debug("IpLimterHandler[分布式限流处理器]参数值为-limitTimes={},limitTimeout={}", limitTimes, expireTime); } // 限流提示语 String message = ipLimiter.message(); /** * 执行Lua脚本 */ List<String> ipList = new ArrayList(); // 设置key值为注解中的值 ipList.add(limitIp); /** * 调用脚本并执行 */ Long result = (Long) redisTemplate.execute(getRedisScript, ipList, expireTime, limitTimes); if (result == 0) { String msg = "由于超过单位时间=" + expireTime + "-允许的请求次数=" + limitTimes + "[触发限流]"; LOGGER.debug(msg); // 达到限流返回给前端信息 return message; } if (LOGGER.isDebugEnabled()) { LOGGER.debug("IpLimterHandler[分布式限流处理器]限流执行结果-result={},请求[正常]响应", result); } return proceedingJoinPoint.proceed(); } }
@Configuration public class RedisCacheConfig { private static final Logger LOGGER = LoggerFactory.getLogger(RedisCacheConfig.class); @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; } }
优点减少网络的开销
: 脚本只执行一次,不需要发送多次请求, 减少网络传输;保证原子操作
: 整个脚本作为一个原子执行, 就不用担心并发问题;
--获取KEY local key1 = KEYS[1] local val = redis.call('incr', key1) 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 redis.call('expire', key1, tonumber(expire)) end end if val > tonumber(times) then return 0 end return 1
#redis spring.redis.hostName= spring.redis.host= spring.redis.port=6379 spring.redis.jedis.pool.max-active=8 spring.redis.jedis.pool.max-wait= spring.redis.jedis.pool.max-idle=8 spring.redis.jedis.pool.min-idle=10 spring.redis.timeout=100ms spring.redis.password= logging.path= /Users/xub/log logging.level.com.jincou.iplimiter=DEBUG server.port=8888
@SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }
完美
上面这个测试非常符合我们的预期,前五次访问接口是成功的,后面就失败了,直到10秒后才可以重新访问,这样反反复复。
其它的这边就不一一展示了,附上该项目源码。
Github地址
https://github.com/yudiandemingzi/spring-boot-redis-ip-limiter