场景 为了防止我们的接口被人恶意访问, 比如有人通过 JMeter 工具频繁访问我们的接口, 导致接口响应变慢甚至崩溃, 所以我们需要对一些特定的接口进行 IP 限流, 即一定时间内同一 IP 访问的次数是有限的.
实现原理 用 Redis 作为限流组件的核心的原理, 将用户的 IP 地址当 Key, 一段时间内访问次数为 value, 同时设置该 Key 过期时间.
比如某接口设置相同 IP10 秒内请求 5 次, 超过 5 次不让访问该接口.
第一次该 IP 地址存入 Redis 的时候, key 值为 IP 地址, value 值为 1, 设置 key 值过期时间为 10 秒.
第二次该 IP 地址存入 Redis 时, 如果 key 没有过期, 那么更新 value 为 2.
以此类推当 value 已经为 5 时, 如果下次该 IP 地址在存入 Redis 同时 key 还没有过期, 那么该 Ip 就不能访问了.
当 10 秒后, 该 key 值过期, 那么该 IP 地址再进来, value 又从 1 开始, 过期时间还是 10 秒, 这样反反复复.
说明从上面的逻辑可以看出, 是一时间段内访问次数受限, 不是完全不让该 IP 访问接口.
技术框架 SpringBoot + RedisTemplate (采用自定义注解完成)
这个可以用于真实项目开发场景.
一, 代码
1, 自定义注解
这边采用自定义注解的目的就是, 在接口上使用自定义注解, 让代码看去非常整洁.
- IpLimiter
- @Target(ElementType.METHOD)
- @Retention(RetentionPolicy.RUNTIME)
- @Documented
- public @interface IpLimiter {
- /**
- * 限流 ip
- */
- String ipAdress() ;
- /**
- * 单位时间限制通过请求数
- */
- long limit() default 10;
- /**
- * 单位时间, 单位秒
- */
- long time() default 1;
- /**
- * 达到限流提示语
- */
- String message();
- }
2, 测试接口
在接口上使用了自定义注解 @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 "请求成功";
- }
- }
3, 处理 IpLimter 注解的 AOP
这边采用切面的方式处理自定义注解. 同时为了保证原子性, 这边写了 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();
- }
- }
- 4,RedisCacheConfig(配置类)
- @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;
- }
- }
5,ipLimiter.lua 脚本
优点
减少网络的开销: 脚本只执行一次, 不需要发送多次请求, 减少网络传输;
保证原子操作: 整个脚本作为一个原子执行, 就不用担心并发问题;
-- 获取 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
- 6,application.properties
- #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
7,SpringBoot 启动类
- @SpringBootApplication
- public class Application {
- public static void main(String[] args) {
- SpringApplication.run(Application.class, args);
- }
- }
8, 测试
完美上面这个测试非常符合我们的预期, 前五次访问接口是成功的, 后面就失败了, 直到 10 秒后才可以重新访问, 这样反反复复.
来源: http://www.jianshu.com/p/8f88c5a1003d