refactor(rate-limit): 完善双层滑动窗口限流实现

This commit is contained in:
Ray.Hao
2026-07-17 00:19:12 +08:00
parent 0087d5e5c6
commit 291d473cc3
6 changed files with 46 additions and 46 deletions

View File

@@ -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";
}

View File

@@ -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();
}
}

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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");
}
}