|
| 1 | +# spring-boot-demo-ratelimit-redis |
| 2 | + |
| 3 | +> 此 demo 主要演示了 Spring Boot 项目如何通过 AOP 结合 Redis 实现分布式限流,旨在保护 API 被恶意频繁访问的问题,是 `spring-boot-demo-ratelimit-guava` 的升级版。 |
| 4 | +
|
| 5 | +## 1. 主要代码 |
| 6 | + |
| 7 | +### 1.1. pom.xml |
| 8 | + |
| 9 | +```xml |
| 10 | +<?xml version="1.0" encoding="UTF-8"?> |
| 11 | +<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" |
| 12 | + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> |
| 13 | + <modelVersion>4.0.0</modelVersion> |
| 14 | + |
| 15 | + <artifactId>spring-boot-demo-ratelimit-redis</artifactId> |
| 16 | + <version>1.0.0-SNAPSHOT</version> |
| 17 | + <packaging>jar</packaging> |
| 18 | + |
| 19 | + <name>spring-boot-demo-ratelimit-redis</name> |
| 20 | + <description>Demo project for Spring Boot</description> |
| 21 | + |
| 22 | + <parent> |
| 23 | + <groupId>com.xkcoding</groupId> |
| 24 | + <artifactId>spring-boot-demo</artifactId> |
| 25 | + <version>1.0.0-SNAPSHOT</version> |
| 26 | + </parent> |
| 27 | + |
| 28 | + <properties> |
| 29 | + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> |
| 30 | + <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> |
| 31 | + <java.version>1.8</java.version> |
| 32 | + </properties> |
| 33 | + |
| 34 | + <dependencies> |
| 35 | + <dependency> |
| 36 | + <groupId>org.springframework.boot</groupId> |
| 37 | + <artifactId>spring-boot-starter-web</artifactId> |
| 38 | + </dependency> |
| 39 | + |
| 40 | + <dependency> |
| 41 | + <groupId>org.springframework.boot</groupId> |
| 42 | + <artifactId>spring-boot-starter-aop</artifactId> |
| 43 | + </dependency> |
| 44 | + |
| 45 | + <dependency> |
| 46 | + <groupId>org.springframework.boot</groupId> |
| 47 | + <artifactId>spring-boot-starter-data-redis</artifactId> |
| 48 | + </dependency> |
| 49 | + |
| 50 | + <!-- 对象池,使用redis时必须引入 --> |
| 51 | + <dependency> |
| 52 | + <groupId>org.apache.commons</groupId> |
| 53 | + <artifactId>commons-pool2</artifactId> |
| 54 | + </dependency> |
| 55 | + |
| 56 | + <dependency> |
| 57 | + <groupId>cn.hutool</groupId> |
| 58 | + <artifactId>hutool-all</artifactId> |
| 59 | + </dependency> |
| 60 | + |
| 61 | + <dependency> |
| 62 | + <groupId>org.springframework.boot</groupId> |
| 63 | + <artifactId>spring-boot-starter-test</artifactId> |
| 64 | + <scope>test</scope> |
| 65 | + </dependency> |
| 66 | + |
| 67 | + <dependency> |
| 68 | + <groupId>org.projectlombok</groupId> |
| 69 | + <artifactId>lombok</artifactId> |
| 70 | + <optional>true</optional> |
| 71 | + </dependency> |
| 72 | + </dependencies> |
| 73 | + |
| 74 | + <build> |
| 75 | + <finalName>spring-boot-demo-ratelimit-redis</finalName> |
| 76 | + <plugins> |
| 77 | + <plugin> |
| 78 | + <groupId>org.springframework.boot</groupId> |
| 79 | + <artifactId>spring-boot-maven-plugin</artifactId> |
| 80 | + </plugin> |
| 81 | + </plugins> |
| 82 | + </build> |
| 83 | + |
| 84 | +</project> |
| 85 | +``` |
| 86 | + |
| 87 | +### 1.2. 限流注解 |
| 88 | + |
| 89 | +```java |
| 90 | +/** |
| 91 | + * <p> |
| 92 | + * 限流注解,添加了 {@link AliasFor} 必须通过 {@link AnnotationUtils} 获取,才会生效 |
| 93 | + * </p> |
| 94 | + * |
| 95 | + * @author yangkai.shen |
| 96 | + * @date Created in 2019/9/30 10:31 |
| 97 | + * @see AnnotationUtils |
| 98 | + */ |
| 99 | +@Target(ElementType.METHOD) |
| 100 | +@Retention(RetentionPolicy.RUNTIME) |
| 101 | +@Documented |
| 102 | +public @interface RateLimiter { |
| 103 | + long DEFAULT_REQUEST = 10; |
| 104 | + |
| 105 | + /** |
| 106 | + * max 最大请求数 |
| 107 | + */ |
| 108 | + @AliasFor("max") long value() default DEFAULT_REQUEST; |
| 109 | + |
| 110 | + /** |
| 111 | + * max 最大请求数 |
| 112 | + */ |
| 113 | + @AliasFor("value") long max() default DEFAULT_REQUEST; |
| 114 | + |
| 115 | + /** |
| 116 | + * 限流key |
| 117 | + */ |
| 118 | + String key() default ""; |
| 119 | + |
| 120 | + /** |
| 121 | + * 超时时长,默认1分钟 |
| 122 | + */ |
| 123 | + long timeout() default 1; |
| 124 | + |
| 125 | + /** |
| 126 | + * 超时时间单位,默认 分钟 |
| 127 | + */ |
| 128 | + TimeUnit timeUnit() default TimeUnit.MINUTES; |
| 129 | +} |
| 130 | +``` |
| 131 | + |
| 132 | +### 1.3. AOP处理限流 |
| 133 | + |
| 134 | +```java |
| 135 | +/** |
| 136 | + * <p> |
| 137 | + * 限流切面 |
| 138 | + * </p> |
| 139 | + * |
| 140 | + * @author yangkai.shen |
| 141 | + * @date Created in 2019/9/30 10:30 |
| 142 | + */ |
| 143 | +@Slf4j |
| 144 | +@Aspect |
| 145 | +@Component |
| 146 | +@RequiredArgsConstructor(onConstructor_ = @Autowired) |
| 147 | +public class RateLimiterAspect { |
| 148 | + private final static String SEPARATOR = ":"; |
| 149 | + private final static String REDIS_LIMIT_KEY_PREFIX = "limit:"; |
| 150 | + private final StringRedisTemplate stringRedisTemplate; |
| 151 | + private final RedisScript<Long> limitRedisScript; |
| 152 | + |
| 153 | + @Pointcut("@annotation(com.xkcoding.ratelimit.redis.annotation.RateLimiter)") |
| 154 | + public void rateLimit() { |
| 155 | + |
| 156 | + } |
| 157 | + |
| 158 | + @Around("rateLimit()") |
| 159 | + public Object pointcut(ProceedingJoinPoint point) throws Throwable { |
| 160 | + MethodSignature signature = (MethodSignature) point.getSignature(); |
| 161 | + Method method = signature.getMethod(); |
| 162 | + // 通过 AnnotationUtils.findAnnotation 获取 RateLimiter 注解 |
| 163 | + RateLimiter rateLimiter = AnnotationUtils.findAnnotation(method, RateLimiter.class); |
| 164 | + if (rateLimiter != null) { |
| 165 | + String key = rateLimiter.key(); |
| 166 | + // 默认用类名+方法名做限流的 key 前缀 |
| 167 | + if (StrUtil.isBlank(key)) { |
| 168 | + key = method.getDeclaringClass().getName()+StrUtil.DOT+method.getName(); |
| 169 | + } |
| 170 | + // 最终限流的 key 为 前缀 + IP地址 |
| 171 | + // TODO: 此时需要考虑局域网多用户访问的情况,因此 key 后续需要加上方法参数更加合理 |
| 172 | + key = key + SEPARATOR + IpUtil.getIpAddr(); |
| 173 | + |
| 174 | + long max = rateLimiter.max(); |
| 175 | + long timeout = rateLimiter.timeout(); |
| 176 | + TimeUnit timeUnit = rateLimiter.timeUnit(); |
| 177 | + boolean limited = shouldLimited(key, max, timeout, timeUnit); |
| 178 | + if (limited) { |
| 179 | + throw new RuntimeException("手速太快了,慢点儿吧~"); |
| 180 | + } |
| 181 | + } |
| 182 | + |
| 183 | + return point.proceed(); |
| 184 | + } |
| 185 | + |
| 186 | + private boolean shouldLimited(String key, long max, long timeout, TimeUnit timeUnit) { |
| 187 | + // 最终的 key 格式为: |
| 188 | + // limit:自定义key:IP |
| 189 | + // limit:类名.方法名:IP |
| 190 | + key = REDIS_LIMIT_KEY_PREFIX + key; |
| 191 | + // 统一使用单位毫秒 |
| 192 | + long ttl = timeUnit.toMillis(timeout); |
| 193 | + // 当前时间毫秒数 |
| 194 | + long now = Instant.now().toEpochMilli(); |
| 195 | + long expired = now - ttl; |
| 196 | + // 注意这里必须转为 String,否则会报错 java.lang.Long cannot be cast to java.lang.String |
| 197 | + Long executeTimes = stringRedisTemplate.execute(limitRedisScript, Collections.singletonList(key), now + "", ttl + "", expired + "", max + ""); |
| 198 | + if (executeTimes != null) { |
| 199 | + if (executeTimes == 0) { |
| 200 | + log.error("【{}】在单位时间 {} 毫秒内已达到访问上限,当前接口上限 {}", key, ttl, max); |
| 201 | + return true; |
| 202 | + } else { |
| 203 | + log.info("【{}】在单位时间 {} 毫秒内访问 {} 次", key, ttl, executeTimes); |
| 204 | + return false; |
| 205 | + } |
| 206 | + } |
| 207 | + return false; |
| 208 | + } |
| 209 | +} |
| 210 | +``` |
| 211 | + |
| 212 | +### 1.4. lua 脚本 |
| 213 | + |
| 214 | +```lua |
| 215 | +-- 下标从 1 开始 |
| 216 | +local key = KEYS[1] |
| 217 | +local now = tonumber(ARGV[1]) |
| 218 | +local ttl = tonumber(ARGV[2]) |
| 219 | +local expired = tonumber(ARGV[3]) |
| 220 | +-- 最大访问量 |
| 221 | +local max = tonumber(ARGV[4]) |
| 222 | + |
| 223 | +-- 清除过期的数据 |
| 224 | +-- 移除指定分数区间内的所有元素,expired 即已经过期的 score |
| 225 | +-- 根据当前时间毫秒数 - 超时毫秒数,得到过期时间 expired |
| 226 | +redis.call('zremrangebyscore', key, 0, expired) |
| 227 | + |
| 228 | +-- 获取 zset 中的当前元素个数 |
| 229 | +local current = tonumber(redis.call('zcard', key)) |
| 230 | +local next = current + 1 |
| 231 | + |
| 232 | +if next > max then |
| 233 | + -- 达到限流大小 返回 0 |
| 234 | + return 0; |
| 235 | +else |
| 236 | + -- 往 zset 中添加一个值、得分均为当前时间戳的元素,[value,score] |
| 237 | + redis.call("zadd", key, now, now) |
| 238 | + -- 每次访问均重新设置 zset 的过期时间,单位毫秒 |
| 239 | + redis.call("pexpire", key, ttl) |
| 240 | + return next |
| 241 | +end |
| 242 | +``` |
| 243 | + |
| 244 | +### 1.5. 接口测试 |
| 245 | + |
| 246 | +```java |
| 247 | +/** |
| 248 | + * <p> |
| 249 | + * 测试 |
| 250 | + * </p> |
| 251 | + * |
| 252 | + * @author yangkai.shen |
| 253 | + * @date Created in 2019/9/30 10:30 |
| 254 | + */ |
| 255 | +@Slf4j |
| 256 | +@RestController |
| 257 | +public class TestController { |
| 258 | + |
| 259 | + @RateLimiter(value = 5) |
| 260 | + @GetMapping("/test1") |
| 261 | + public Dict test1() { |
| 262 | + log.info("【test1】被执行了。。。。。"); |
| 263 | + return Dict.create().set("msg", "hello,world!").set("description", "别想一直看到我,不信你快速刷新看看~"); |
| 264 | + } |
| 265 | + |
| 266 | + @GetMapping("/test2") |
| 267 | + public Dict test2() { |
| 268 | + log.info("【test2】被执行了。。。。。"); |
| 269 | + return Dict.create().set("msg", "hello,world!").set("description", "我一直都在,卟离卟弃"); |
| 270 | + } |
| 271 | + |
| 272 | + @RateLimiter(value = 2, key = "测试自定义key") |
| 273 | + @GetMapping("/test3") |
| 274 | + public Dict test3() { |
| 275 | + log.info("【test3】被执行了。。。。。"); |
| 276 | + return Dict.create().set("msg", "hello,world!").set("description", "别想一直看到我,不信你快速刷新看看~"); |
| 277 | + } |
| 278 | +} |
| 279 | +``` |
| 280 | + |
| 281 | +### 1.6. 其余代码参见 demo |
| 282 | + |
| 283 | +## 2. 测试 |
| 284 | + |
| 285 | +- 触发限流时控制台打印 |
| 286 | + |
| 287 | + |
| 288 | + |
| 289 | +- 触发限流的时候 Redis 的数据 |
| 290 | + |
| 291 | + |
| 292 | + |
| 293 | +## 3. 参考 |
| 294 | + |
| 295 | +- [mica-plus-redis 的分布式限流实现](https://github.com/lets-mica/mica/tree/master/mica-plus-redis) |
| 296 | +- [Java并发:分布式应用限流 Redis + Lua 实践](https://segmentfault.com/a/1190000016042927) |
0 commit comments