refactor(rate-limit): 完善双层滑动窗口限流实现
This commit is contained in:
@@ -1,11 +1,10 @@
|
||||
package com.youlai.boot.common.annotation;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 接口限流
|
||||
* <p>标注在 Controller 方法上,基于 Redis 计数窗口实现</p>
|
||||
* <p>标注在 Controller 方法上,基于 Redis 滑动窗口实现</p>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.3.1
|
||||
@@ -23,21 +22,9 @@ public @interface RateLimit {
|
||||
int limit() default 0;
|
||||
|
||||
/**
|
||||
* 滑动窗口大小。
|
||||
* 滑动窗口大小(秒)。
|
||||
* <p>
|
||||
* 单位由 {@link #timeUnit()} 决定;{@code <=0} 时使用全局默认值。
|
||||
* {@code <=0} 时使用全局默认值 {@code rate-limit.default-window}。
|
||||
*/
|
||||
int window() default 0;
|
||||
|
||||
/**
|
||||
* 窗口单位,默认秒。
|
||||
*/
|
||||
TimeUnit timeUnit() default TimeUnit.SECONDS;
|
||||
|
||||
/**
|
||||
* 限流 Key 的分组标签,用于区分不同接口。
|
||||
* <p>
|
||||
* 默认 {@code api}。
|
||||
*/
|
||||
String prefix() default "api";
|
||||
}
|
||||
|
||||
@@ -24,12 +24,10 @@ public class RedisConfig {
|
||||
* 修改 Redis 序列化方式,默认 JdkSerializationRedisSerializer
|
||||
*
|
||||
* @param redisConnectionFactory {@link RedisConnectionFactory}
|
||||
* @param jsonMapper Jackson 序列化器(统一使用不写入类型信息的配置)
|
||||
* @return {@link RedisTemplate}
|
||||
*/
|
||||
@Bean
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory,
|
||||
JsonMapper jsonMapper) {
|
||||
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
|
||||
|
||||
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
|
||||
redisTemplate.setConnectionFactory(redisConnectionFactory);
|
||||
@@ -38,7 +36,9 @@ public class RedisConfig {
|
||||
redisTemplate.setKeySerializer(RedisSerializer.string());
|
||||
redisTemplate.setHashKeySerializer(RedisSerializer.string());
|
||||
|
||||
// Value 使用自定义 JSON 序列化(不写入类型信息,避免 HashSet 等集合被序列化成带 @class 的结构)
|
||||
JsonMapper jsonMapper = JsonMapper.builder()
|
||||
.disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build();
|
||||
JacksonJsonRedisSerializer<Object> jsonSerializer = new JacksonJsonRedisSerializer<>(jsonMapper, Object.class);
|
||||
|
||||
redisTemplate.setValueSerializer(jsonSerializer);
|
||||
@@ -48,17 +48,4 @@ public class RedisConfig {
|
||||
return redisTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的 JsonMapper Bean
|
||||
* <p>
|
||||
* 禁止将日期序列化为时间戳;不写入类型信息,保证 Redis 存储的 JSON 纯净可读。
|
||||
* 需要反序列化到特定类型时,调用方应使用 {@link JsonMapper#convertValue(Object, Class)} 显式转换。
|
||||
*/
|
||||
@Bean
|
||||
public JsonMapper jsonMapper() {
|
||||
return JsonMapper.builder()
|
||||
.disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -55,8 +55,9 @@ public class RateLimitAspect {
|
||||
|
||||
String key = buildKey(request, rateLimit);
|
||||
int limit = rateLimit.limit() > 0 ? rateLimit.limit() : rateLimitProperties.getDefaultLimit();
|
||||
int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindowSeconds();
|
||||
long windowMs = rateLimit.timeUnit().toMillis(window);
|
||||
long windowMs = rateLimit.window() > 0
|
||||
? rateLimit.window() * 1000L
|
||||
: rateLimitProperties.getDefaultWindow().toMillis();
|
||||
|
||||
Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
|
||||
|
||||
@@ -73,8 +74,7 @@ public class RateLimitAspect {
|
||||
|
||||
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
|
||||
String user = resolveUser(request);
|
||||
return StrUtil.format(RedisConstants.RateLimit.API,
|
||||
rateLimit.prefix(), user, request.getRequestURI());
|
||||
return StrUtil.format(RedisConstants.RateLimit.API, user, request.getRequestURI());
|
||||
}
|
||||
|
||||
private String resolveUser(HttpServletRequest request) {
|
||||
|
||||
@@ -4,11 +4,11 @@ import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* 限流配置
|
||||
* <p>
|
||||
* 对应 application.yml 中的 {@code rate-limit} 配置节点。
|
||||
* </p>
|
||||
* <p>对应 application.yml 中的 {@code rate-limit} 配置节点。</p>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.3.1
|
||||
@@ -24,9 +24,9 @@ public class RateLimitProperties {
|
||||
private int defaultLimit = 5;
|
||||
|
||||
/**
|
||||
* @RateLimit 注解未显式指定 window 时的默认窗口大小(秒)
|
||||
* @RateLimit 注解未显式指定 window 时的默认窗口大小,支持 60s / 1m / 500ms 等 Duration 写法
|
||||
*/
|
||||
private int defaultWindowSeconds = 60;
|
||||
private Duration defaultWindow = Duration.ofSeconds(60);
|
||||
|
||||
/**
|
||||
* IP 全局限流配置
|
||||
@@ -48,9 +48,9 @@ public class RateLimitProperties {
|
||||
private int limit = 1000;
|
||||
|
||||
/**
|
||||
* 窗口大小(秒,默认 60)
|
||||
* 滑动窗口大小,支持 60s / 1m / 500ms 等 Duration 写法(默认 60s)
|
||||
*/
|
||||
private int windowSeconds = 60;
|
||||
private Duration window = Duration.ofSeconds(60);
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +68,7 @@ public class IpRateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
String ip = IPUtils.getIpAddr(request);
|
||||
String key = StrUtil.format(RedisConstants.RateLimit.IP, ip);
|
||||
long windowMs = ipConfig.getWindowSeconds() * 1000L;
|
||||
long windowMs = ipConfig.getWindow().toMillis();
|
||||
|
||||
// 执行滑动窗口计数(Lua 原子操作)
|
||||
Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
|
||||
@@ -80,7 +80,7 @@ public class IpRateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
if (current > limit) {
|
||||
log.warn("IP 限流触发 ip={} count={} limit={}", ip, current, limit);
|
||||
response.setHeader("Retry-After", String.valueOf(ipConfig.getWindowSeconds()));
|
||||
response.setHeader("Retry-After", String.valueOf(ipConfig.getWindow().getSeconds()));
|
||||
ResponseWriter.writeError(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.youlai.boot.system.controller;
|
||||
|
||||
import com.youlai.boot.common.result.Result;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 系统健康检查接口,返回 ok。
|
||||
* 不标注 @RateLimit,仅受 IP 全局限流层约束,用作该层的验证端点。
|
||||
*/
|
||||
@Tag(name = "00.系统健康检查")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/health")
|
||||
@Slf4j
|
||||
public class HealthController {
|
||||
|
||||
@Operation(summary = "健康检查", description = "返回 ok,供限流测试与探针使用")
|
||||
@GetMapping
|
||||
public Result<String> health() {
|
||||
return Result.success("ok");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user