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:
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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:{}";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 全局限流配置
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 原子操作)
|
||||
|
||||
@@ -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 当前窗口内请求数(包含本次)
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ mybatis-plus:
|
||||
# 接口限流
|
||||
rate-limit:
|
||||
default-limit: 60
|
||||
default-window: 60
|
||||
default-window-seconds: 60
|
||||
ip:
|
||||
enabled: false # 开发环境关闭 IP 全局限流
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user