赞
踩
本文主要演示了 Spring Boot 项目如何通过 AOP 结合 Redis + Lua 脚本实现分布式限流,旨在保护 API 被恶意频繁访问的问题;
1、redis 配置文件
@Configuration public class RedisConfig { @Bean public RedisScript<Long> limitRedisScript() { DefaultRedisScript redisScript = new DefaultRedisScript<>(); redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("scripts/redis/limit.lua"))); redisScript.setResultType(Long.class); return redisScript; } }
2、lua脚本
-- 下标从 1 开始 local key = KEYS[1] local now = tonumber(ARGV[1]) local ttl = tonumber(ARGV[2]) local expired = tonumber(ARGV[3]) -- 最大访问量 local max = tonumber(ARGV[4]) -- 清除过期的数据 -- 移除指定分数区间内的所有元素,expired 即已经过期的 score -- 根据当前时间毫秒数 - 超时毫秒数,得到过期时间 expired redis.call('zremrangebyscore', key, 0, expired) -- 获取 zset 中的当前元素个数 local current = tonumber(redis.call('zcard', key)) local next = current + 1 if next > max then -- 达到限流大小 返回 0 return 0; else -- 往 zset 中添加一个值、得分均为当前时间戳的元素,[value,score] redis.call("zadd", key, now, now) -- 每次访问均重新设置 zset 的过期时间,单位毫秒 redis.call("pexpire", key, ttl) return next end
3、注解
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface RateLimiter { long DEFAULT_REQUEST = 10; /** * max 最大请求数 */ @AliasFor("max") long value() default DEFAULT_REQUEST; /** * max 最大请求数 */ @AliasFor("value") long max() default DEFAULT_REQUEST; /** * 限流key */ String key() default ""; /** * 超时时长,默认1分钟 */ long timeout() default 1; /** * 超时时间单位,默认 分钟 */ TimeUnit timeUnit() default TimeUnit.MINUTES; }
4、切面
@Aspect @Component @RequiredArgsConstructor(onConstructor_ = @Autowired) @Slf4j public class LimiteAspect { private final static String SEPARATOR = ":"; private final static String REDIS_LIMIT_KEY_PREFIX = "limit:"; private final StringRedisTemplate stringRedisTemplate; private final RedisScript<Long> limitRedisScript; @Pointcut("@annotation(com.juejueguai.springbootdemoratelimitredis.annotation.RateLimiter)") public void rateLimit() { } @Around("rateLimit()") public Object pointcut(ProceedingJoinPoint point) throws Throwable { MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); // 通过 AnnotationUtils.findAnnotation 获取 RateLimiter 注解 RateLimiter rateLimiter = AnnotationUtils.findAnnotation(method, RateLimiter.class); if (rateLimiter != null) { String key = rateLimiter.key(); // 默认用类名+方法名做限流的 key 前缀 if (StrUtil.isBlank(key)) { key = method.getDeclaringClass().getName()+StrUtil.DOT+method.getName(); } // 最终限流的 key 为 前缀 + IP地址 // TODO: 此时需要考虑局域网多用户访问的情况,因此 key 后续需要加上方法参数更加合理 key = key + SEPARATOR + IpUtil.getIpAddr(); long max = rateLimiter.max(); long timeout = rateLimiter.timeout(); TimeUnit timeUnit = rateLimiter.timeUnit(); boolean limited = shouldLimited(key, max, timeout, timeUnit); if (limited) { throw new RuntimeException("手速太快了,慢点儿吧~"); } } return point.proceed(); } private boolean shouldLimited(String key, long max, long timeout, TimeUnit timeUnit) { // 最终的 key 格式为: // limit:自定义key:IP // limit:类名.方法名:IP key = REDIS_LIMIT_KEY_PREFIX + key; // 统一使用单位毫秒 long ttl = timeUnit.toMillis(timeout); // 当前时间毫秒数 long now = Instant.now().toEpochMilli(); long expired = now - ttl; // 注意这里必须转为 String,否则会报错 java.lang.Long cannot be cast to java.lang.String Long executeTimes = stringRedisTemplate.execute(limitRedisScript, Collections.singletonList(key), now + "", ttl + "", expired + "", max + ""); if (executeTimes != null) { if (executeTimes == 0) { log.error("【{}】在单位时间 {} 毫秒内已达到访问上限,当前接口上限 {}", key, ttl, max); return true; } else { log.info("【{}】在单位时间 {} 毫秒内访问 {} 次", key, ttl, executeTimes); return false; } } return false; } }
5、全局异常
@Slf4j @RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(RuntimeException.class) public Dict handler(RuntimeException ex) { return Dict.create().set("msg", ex.getMessage()); } }
6、ip工具类
@Slf4j public class IpUtil { private final static String UNKNOWN = "unknown"; private final static int MAX_LENGTH = 15; /** * 获取IP地址 * 使用Nginx等反向代理软件, 则不能通过request.getRemoteAddr()获取IP地址 * 如果使用了多级反向代理的话,X-Forwarded-For的值并不止一个,而是一串IP地址,X-Forwarded-For中第一个非unknown的有效IP字符串,则为真实IP地址 */ public static String getIpAddr() { HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); String ip = null; try { ip = request.getHeader("x-forwarded-for"); if (StrUtil.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (StrUtil.isEmpty(ip) || ip.length() == 0 || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (StrUtil.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_CLIENT_IP"); } if (StrUtil.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_X_FORWARDED_FOR"); } if (StrUtil.isEmpty(ip) || UNKNOWN.equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } } catch (Exception e) { log.error("IPUtils ERROR ", e); } // 使用代理,则获取第一个IP地址 if (!StrUtil.isEmpty(ip) && ip.length() > MAX_LENGTH) { if (ip.indexOf(StrUtil.COMMA) > 0) { ip = ip.substring(0, ip.indexOf(StrUtil.COMMA)); } } return ip; } }
7、接口测试 @RestController @RequestMapping @Slf4j public class TestController { @RateLimiter(value = 5) @GetMapping("/test1") public Dict test1() { log.info("【test1】被执行了。。。。。"); return Dict.create().set("msg", "hello,world!").set("description", "别想一直看到我,不信你快速刷新看看~"); } @GetMapping("/test2") public Dict test2() { log.info("【test2】被执行了。。。。。"); return Dict.create().set("msg", "hello,world!").set("description", "我一直都在,卟离卟弃"); } @RateLimiter(value = 2, key = "测试自定义key") @GetMapping("/test3") public Dict test3() { log.info("【test3】被执行了。。。。。"); return Dict.create().set("msg", "hello,world!").set("description", "别想一直看到我,不信你快速刷新看看~"); } }
8、压测工具JMeter
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。