refactor(rate-limit): 限流升级为双层滑动窗口,新增 IP 全局限流与响应头

- 新增 IpRateLimitFilter:IP 维度全局滑动窗口限流,所有接口生效(默认 1000 次/60 秒)

- RateLimitAspect 固定窗口升级为滑动窗口(Redis ZSET + Lua),与 IP 全局限流叠加

- 提取公共 SlidingWindowScript,统一 Lua 脚本

- 新增 X-RateLimit-Limit/Remaining/Reset 与 Retry-After 响应头

- RateLimitProperties 新增 ip 配置;新增 RateLimitException;dev 环境关闭 IP 限流
This commit is contained in:
Ray.Hao
2026-07-12 21:23:17 +08:00
parent 2441efa9ba
commit 13ed4503ed
12 changed files with 78 additions and 20 deletions

View File

@@ -15,11 +15,29 @@ import java.util.concurrent.TimeUnit;
@Documented
public @interface RateLimit {
/**
* 窗口内允许的最大请求数。
* <p>
* {@code <=0} 时使用全局默认值 {@code rate-limit.default-limit}。
*/
int limit() default 0;
/**
* 滑动窗口大小。
* <p>
* 单位由 {@link #timeUnit()} 决定;{@code <=0} 时使用全局默认值。
*/
int window() default 0;
/**
* 窗口单位,默认秒。
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
String prefix() default "rate_limit:";
/**
* 限流 Key 的分组标签,用于区分不同接口。
* <p>
* 默认 {@code api}。
*/
String prefix() default "api";
}

View File

@@ -11,12 +11,12 @@ public interface RedisConstants {
/**
* 限流相关键
*/
interface RateLimiter {
/** 接口级限流 Key示例login:rate_limit:token:/api/v1/auth/login */
String API = "{}rate_limit:{}:{}";
interface RateLimit {
/** 接口级限流 Key示例rate_limit:api:{user}:{uri} */
String API = "rate_limit:{}:{}:{}";
/** IP 全局限流 Key示例rate_limiter:ip:192.168.1.100 */
String IP = "rate_limiter:ip:{}";
/** IP 全局限流 Key示例rate_limit:ip:192.168.1.100 */
String IP = "rate_limit:ip:{}";
}
/**

View File

@@ -5,6 +5,7 @@ import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.Result;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.framework.security.exception.TokenInvalidException;
import com.youlai.boot.framework.web.exception.RateLimitException;
import jakarta.servlet.ServletException;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
@@ -157,6 +158,12 @@ public class GlobalExceptionHandler {
return Result.failed(e.getResultCode());
}
@ExceptionHandler(RateLimitException.class)
@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
public <T> Result<T> handleRateLimitException(RateLimitException e) {
return Result.failed(e.getResultCode(), e.getMessage());
}
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.OK)
public <T> Result<T> handleBizException(BusinessException e) {

View File

@@ -5,10 +5,10 @@ import cn.hutool.crypto.digest.DigestUtil;
import com.youlai.boot.common.annotation.RateLimit;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.exception.BusinessException;
import com.youlai.boot.common.result.ResultCode;
import com.youlai.boot.common.util.IPUtils;
import com.youlai.boot.framework.web.config.RateLimitProperties;
import com.youlai.boot.framework.web.exception.RateLimitException;
import com.youlai.boot.framework.web.ratelimit.SlidingWindowScript;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -55,7 +55,7 @@ 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.getDefaultWindow();
int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindowSeconds();
long windowMs = rateLimit.timeUnit().toMillis(window);
Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
@@ -65,7 +65,7 @@ public class RateLimitAspect {
if (current > limit) {
log.warn("接口限流触发 key={} count={} limit={}", key, current, limit);
throw new BusinessException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
throw new RateLimitException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
}
return jp.proceed();
@@ -73,7 +73,7 @@ public class RateLimitAspect {
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
String user = resolveUser(request);
return StrUtil.format(RedisConstants.RateLimiter.API,
return StrUtil.format(RedisConstants.RateLimit.API,
rateLimit.prefix(), user, request.getRequestURI());
}

View File

@@ -2,6 +2,7 @@ package com.youlai.boot.framework.web.aspect;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.json.JSONUtil;
import com.youlai.boot.common.constant.RedisConstants;
import com.youlai.boot.common.constant.SecurityConstants;
import com.youlai.boot.common.result.ResultCode;
@@ -42,7 +43,7 @@ public class RepeatSubmitAspect {
@Around(value = "repeatSubmitPointCut(repeatSubmit)", argNames = "pjp,repeatSubmit")
public Object handleRepeatSubmit(ProceedingJoinPoint pjp, RepeatSubmit repeatSubmit) throws Throwable {
String lockKey = buildLockKey();
String lockKey = buildLockKey(pjp);
int expire = repeatSubmit.expire();
RLock lock = redissonClient.getLock(lockKey);
@@ -56,12 +57,17 @@ public class RepeatSubmitAspect {
/**
* 生成防重复提交锁的 key
* <p>
* key 由「用户标识 + 接口(method:URI) + 请求体哈希」组成:只有同一用户在同一接口提交
* 完全相同的请求体才视为重复提交;若仅按 method:URI 生成,同一接口的不同提交
* (如新增菜单与新增目录)会在窗口内被误判为重复。
*/
private String buildLockKey() {
private String buildLockKey(ProceedingJoinPoint pjp) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String userIdentifier = getUserIdentifier(request);
String requestIdentifier = StrUtil.join(":", request.getMethod(), request.getRequestURI());
return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier);
String bodyHash = DigestUtil.sha256Hex(JSONUtil.toJsonStr(pjp.getArgs()));
return StrUtil.format(RedisConstants.Lock.RESUBMIT, userIdentifier, requestIdentifier + ":" + bodyHash);
}
/**

View File

@@ -19,14 +19,14 @@ import org.springframework.stereotype.Component;
public class RateLimitProperties {
/**
* @RateLimit 注解未显式指定 limit 时的默认阈值
* @RateLimit 注解未显式指定 limit 时的默认阈值(窗口内最大请求数)
*/
private int defaultLimit = 5;
/**
* @RateLimit 注解未显式指定 window 时的默认窗口大小(秒)
*/
private int defaultWindow = 1;
private int defaultWindowSeconds = 60;
/**
* IP 全局限流配置

View File

@@ -0,0 +1,26 @@
package com.youlai.boot.framework.web.exception;
import com.youlai.boot.common.result.ResultCode;
import lombok.Getter;
/**
* 接口限流异常:请求超出限流阈值时抛出,由全局异常处理器统一映射为 HTTP 429。
*
* @author Ray.Hao
* @since 4.4.0
*/
@Getter
public class RateLimitException extends RuntimeException {
private final ResultCode resultCode;
public RateLimitException(ResultCode resultCode) {
super(resultCode.getMsg());
this.resultCode = resultCode;
}
public RateLimitException(ResultCode resultCode, String message) {
super(message);
this.resultCode = resultCode;
}
}

View File

@@ -30,7 +30,7 @@ import java.io.IOException;
*
* <h3>限流维度</h3>
* <pre>{@code
* Key: rate_limiter:ip:{clientIp}
* Key: rate_limit:ip:{clientIp}
* 默认: 1000 req / 60s可通过 rate-limit.ip.* 配置)
* }</pre>
*
@@ -67,7 +67,7 @@ public class IpRateLimitFilter extends OncePerRequestFilter {
}
String ip = IPUtils.getIpAddr(request);
String key = StrUtil.format(RedisConstants.RateLimiter.IP, ip);
String key = StrUtil.format(RedisConstants.RateLimit.IP, ip);
long windowMs = ipConfig.getWindowSeconds() * 1000L;
// 执行滑动窗口计数Lua 原子操作)

View File

@@ -49,7 +49,7 @@ public final class SlidingWindowScript {
* 执行滑动窗口计数
*
* @param redisTemplate Redis 模板
* @param key 限流 Key如 {@code rate_limiter:ip:192.168.1.1}
* @param key 限流 Key如 {@code rate_limit:ip:192.168.1.1}
* @param windowMs 窗口大小(毫秒)
* @return 当前窗口内请求数(包含本次)
*/

View File

@@ -57,6 +57,7 @@ public final class ResponseWriter {
return switch (resultCode) {
case ACCESS_UNAUTHORIZED, ACCESS_TOKEN_INVALID, REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value();
case ACCESS_PERMISSION_EXCEPTION -> HttpStatus.FORBIDDEN.value();
case REQUEST_CONCURRENCY_LIMIT_EXCEEDED -> HttpStatus.TOO_MANY_REQUESTS.value();
default -> HttpStatus.BAD_REQUEST.value();
};
}

View File

@@ -71,7 +71,7 @@ mybatis-plus:
# 接口限流
rate-limit:
default-limit: 60
default-window: 60
default-window-seconds: 60
ip:
enabled: false # 开发环境关闭 IP 全局限流

View File

@@ -70,7 +70,7 @@ mybatis-plus:
# 接口限流
rate-limit:
default-limit: 10
default-window: 1
default-window-seconds: 60
ip:
enabled: true
limit: 1000 # IP 全局1000 req / 60s