refactor(rate-limit): 限流架构升级为双层滑动窗口,新增IP全局防护与响应头
限流算法从 INCR+EXPIRE 固定窗口升级为 ZSET+Lua 滑动窗口, 新增 IpRateLimitFilter 提供 IP 全局防护(默认 1000次/60s)。 新增 X-RateLimit-Limit/Remaining/Reset 响应头与 Retry-After, 新增 rate-limit.ip.* 配置项控制 IP 全局开关与阈值, @RateLimit 空注解统一走全局配置,消除硬编码。
This commit is contained in:
@@ -48,7 +48,7 @@ public class AuthController {
|
||||
@Operation(summary = "账号密码登录")
|
||||
@PostMapping("/login")
|
||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||
@RateLimit(limit = 5, window = 60)
|
||||
@RateLimit
|
||||
public Result<AuthenticationToken> login(@RequestBody @Valid LoginForm request) {
|
||||
AuthenticationToken authenticationToken = authService.login(request.getUsername(), request.getPassword());
|
||||
return Result.success(authenticationToken);
|
||||
|
||||
@@ -50,7 +50,7 @@ public class WxMaAuthController {
|
||||
@Operation(summary = "静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token,未绑定用户返回 openid 需绑定手机号")
|
||||
@PostMapping("/silent-login")
|
||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||
@RateLimit(limit = 10, window = 60)
|
||||
@RateLimit
|
||||
public Result<WxMaLoginVO> silentLogin(
|
||||
@Parameter(description = "微信登录凭证(wx.login 获取)", required = true, example = "0xxx")
|
||||
@RequestParam String code
|
||||
@@ -69,7 +69,7 @@ public class WxMaAuthController {
|
||||
@Operation(summary = "手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序")
|
||||
@PostMapping("/phone-login")
|
||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||
@RateLimit(limit = 5, window = 60)
|
||||
@RateLimit
|
||||
public Result<AuthenticationToken> phoneLogin(@Valid @RequestBody WxMaPhoneLoginForm req) {
|
||||
AuthenticationToken result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode());
|
||||
return Result.success(result);
|
||||
@@ -85,7 +85,7 @@ public class WxMaAuthController {
|
||||
@Operation(summary = "绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录")
|
||||
@PostMapping("/bind-mobile")
|
||||
@Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN)
|
||||
@RateLimit(limit = 3, window = 60)
|
||||
@RateLimit
|
||||
public Result<AuthenticationToken> bindMobile(@Valid @RequestBody WxMaBindMobileForm req) {
|
||||
AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode());
|
||||
return Result.success(result);
|
||||
|
||||
@@ -12,7 +12,11 @@ public interface RedisConstants {
|
||||
* 限流相关键
|
||||
*/
|
||||
interface RateLimiter {
|
||||
String API = "{}rate_limit:{}:{}"; // 接口限流(示例:login:rate_limit:token:/api/v1/auth/login)
|
||||
/** 接口级限流 Key(示例:login:rate_limit:token:/api/v1/auth/login) */
|
||||
String API = "{}rate_limit:{}:{}";
|
||||
|
||||
/** IP 全局限流 Key(示例:rate_limiter:ip:192.168.1.100) */
|
||||
String IP = "rate_limiter:ip:{}";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,9 @@ 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.ratelimit.SlidingWindowScript;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
@@ -21,11 +23,18 @@ import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 接口限流切面
|
||||
* <p>对 @RateLimit 标注的方法做 Redis 计数,超阈值则拒绝</p>
|
||||
* 接口级限流切面
|
||||
* <p>
|
||||
* 对标注了 {@link RateLimit} 的 Controller 方法做 Redis 滑动窗口计数。
|
||||
* 超阈值抛出 {@link BusinessException}(A0502),全局异常处理器统一返回 JSON。
|
||||
* </p>
|
||||
*
|
||||
* <h3>升级历史</h3>
|
||||
* <ul>
|
||||
* <li>4.3.1 — 初始版本:固定窗口计数器</li>
|
||||
* <li>4.4.0 — 升级为滑动窗口 + X-RateLimit-* 渐进式响应头</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.3.1
|
||||
@@ -41,25 +50,31 @@ public class RateLimitAspect {
|
||||
|
||||
@Around("@annotation(rateLimit)")
|
||||
public Object handle(ProceedingJoinPoint jp, RateLimit rateLimit) throws Throwable {
|
||||
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
|
||||
String key = buildKey(request, rateLimit);
|
||||
HttpServletRequest request =
|
||||
((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
|
||||
|
||||
String key = buildKey(request, rateLimit);
|
||||
int limit = rateLimit.limit() > 0 ? rateLimit.limit() : rateLimitProperties.getDefaultLimit();
|
||||
Long count = redisTemplate.opsForValue().increment(key);
|
||||
if (count != null && count == 1) {
|
||||
int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindow();
|
||||
redisTemplate.expire(key, window, rateLimit.timeUnit());
|
||||
}
|
||||
if (count != null && count > limit) {
|
||||
log.warn("限流触发 key={} count={} limit={}", key, count, limit);
|
||||
int window = rateLimit.window() > 0 ? rateLimit.window() : rateLimitProperties.getDefaultWindow();
|
||||
long windowMs = rateLimit.timeUnit().toMillis(window);
|
||||
|
||||
Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
|
||||
|
||||
int current = count != null ? count.intValue() : 0;
|
||||
setRateLimitHeaders(request, limit, current, windowMs);
|
||||
|
||||
if (current > limit) {
|
||||
log.warn("接口限流触发 key={} count={} limit={}", key, current, limit);
|
||||
throw new BusinessException(ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
|
||||
}
|
||||
|
||||
return jp.proceed();
|
||||
}
|
||||
|
||||
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
|
||||
String user = resolveUser(request);
|
||||
return StrUtil.format(RedisConstants.RateLimiter.API, rateLimit.prefix(), user, request.getRequestURI());
|
||||
return StrUtil.format(RedisConstants.RateLimiter.API,
|
||||
rateLimit.prefix(), user, request.getRequestURI());
|
||||
}
|
||||
|
||||
private String resolveUser(HttpServletRequest request) {
|
||||
@@ -69,4 +84,23 @@ public class RateLimitAspect {
|
||||
}
|
||||
return IPUtils.getIpAddr(request);
|
||||
}
|
||||
|
||||
private void setRateLimitHeaders(HttpServletRequest request,
|
||||
int limit,
|
||||
int current,
|
||||
long windowMs) {
|
||||
ServletRequestAttributes attrs =
|
||||
(ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
|
||||
if (attrs != null) {
|
||||
HttpServletResponse response = attrs.getResponse();
|
||||
if (response != null) {
|
||||
int remaining = Math.max(0, limit - current);
|
||||
long resetAt = (System.currentTimeMillis() + windowMs) / 1000;
|
||||
response.setHeader("X-RateLimit-Limit", String.valueOf(limit));
|
||||
response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining));
|
||||
response.setHeader("X-RateLimit-Reset", String.valueOf(resetAt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -6,13 +6,52 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 限流配置
|
||||
* <p>
|
||||
* 对应 application.yml 中的 {@code rate-limit} 配置节点。
|
||||
* </p>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.3.1
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "rate-limit")
|
||||
public class RateLimitProperties {
|
||||
|
||||
/**
|
||||
* @RateLimit 注解未显式指定 limit 时的默认阈值
|
||||
*/
|
||||
private int defaultLimit = 5;
|
||||
|
||||
/**
|
||||
* @RateLimit 注解未显式指定 window 时的默认窗口大小(秒)
|
||||
*/
|
||||
private int defaultWindow = 1;
|
||||
|
||||
/**
|
||||
* IP 全局限流配置
|
||||
*/
|
||||
private Ip ip = new Ip();
|
||||
|
||||
@Data
|
||||
public static class Ip {
|
||||
|
||||
/**
|
||||
* 是否启用 IP 全局限流。
|
||||
* 建议生产环境开启,开发/测试环境酌情关闭。
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* 窗口内最大请求数(默认 1000)
|
||||
*/
|
||||
private int limit = 1000;
|
||||
|
||||
/**
|
||||
* 窗口大小(秒,默认 60)
|
||||
*/
|
||||
private int windowSeconds = 60;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.youlai.boot.framework.web.filter;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.youlai.boot.common.constant.RedisConstants;
|
||||
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.ratelimit.SlidingWindowScript;
|
||||
import com.youlai.boot.framework.web.util.ResponseWriter;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* IP 全局限流过滤器
|
||||
* <p>
|
||||
* 在所有请求进入 Controller 之前,按 IP 维度做滑动窗口限流。
|
||||
* 与 {@code @RateLimit} 注解级限流叠加:先过 IP 全局,再过接口级。
|
||||
* </p>
|
||||
*
|
||||
* <h3>限流维度</h3>
|
||||
* <pre>{@code
|
||||
* Key: rate_limiter:ip:{clientIp}
|
||||
* 默认: 1000 req / 60s(可通过 rate-limit.ip.* 配置)
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>响应头</h3>
|
||||
* <table>
|
||||
* <tr><td>X-RateLimit-Limit</td><td>窗口内最大允许请求数</td></tr>
|
||||
* <tr><td>X-RateLimit-Remaining</td><td>窗口内剩余可用请求数</td></tr>
|
||||
* <tr><td>X-RateLimit-Reset</td><td>窗口重置时间(Unix 秒)</td></tr>
|
||||
* <tr><td>Retry-After</td><td>超限时建议重试等待秒数(仅 429)</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.4.0
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 5)
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IpRateLimitFilter extends OncePerRequestFilter {
|
||||
|
||||
private final RedisTemplate<String, Object> redisTemplate;
|
||||
private final RateLimitProperties rateLimitProperties;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
|
||||
// 未启用时直接放行
|
||||
RateLimitProperties.Ip ipConfig = rateLimitProperties.getIp();
|
||||
if (!ipConfig.isEnabled()) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String ip = IPUtils.getIpAddr(request);
|
||||
String key = StrUtil.format(RedisConstants.RateLimiter.IP, ip);
|
||||
long windowMs = ipConfig.getWindowSeconds() * 1000L;
|
||||
|
||||
// 执行滑动窗口计数(Lua 原子操作)
|
||||
Long count = SlidingWindowScript.execute(redisTemplate, key, windowMs);
|
||||
|
||||
int limit = ipConfig.getLimit();
|
||||
int current = count != null ? count.intValue() : 0;
|
||||
|
||||
setRateLimitHeaders(response, limit, current, windowMs);
|
||||
|
||||
if (current > limit) {
|
||||
log.warn("IP 限流触发 ip={} count={} limit={}", ip, current, limit);
|
||||
response.setHeader("Retry-After", String.valueOf(ipConfig.getWindowSeconds()));
|
||||
ResponseWriter.writeError(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
|
||||
return;
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置渐进式限流响应头(X-RateLimit-Limit / Remaining / Reset)。
|
||||
* 超限时额外返回 Retry-After。
|
||||
*/
|
||||
private void setRateLimitHeaders(HttpServletResponse response,
|
||||
int limit,
|
||||
int current,
|
||||
long windowMs) {
|
||||
int remaining = Math.max(0, limit - current);
|
||||
long resetAt = (System.currentTimeMillis() + windowMs) / 1000;
|
||||
response.setHeader("X-RateLimit-Limit", String.valueOf(limit));
|
||||
response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining));
|
||||
response.setHeader("X-RateLimit-Reset", String.valueOf(resetAt));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.youlai.boot.framework.web.ratelimit;
|
||||
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.data.redis.core.script.DefaultRedisScript;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 滑动窗口限流 Lua 脚本(共享工具)
|
||||
* <p>
|
||||
* 基于 Redis Sorted Set 实现原子化的滑动窗口计数:
|
||||
* <ol>
|
||||
* <li>ZREMRANGEBYSCORE 清除窗口外旧请求</li>
|
||||
* <li>ZADD 添加当前请求</li>
|
||||
* <li>ZCARD 统计窗口内请求数</li>
|
||||
* <li>PEXPIRE 设置 Key 过期(窗口 + 1s 冗余)</li>
|
||||
* </ol>
|
||||
* 一次网络往返完成全部操作,避免多次 Redis 调用之间的竞态条件。
|
||||
* </p>
|
||||
*
|
||||
* @author Ray.Hao
|
||||
* @since 4.4.0
|
||||
*/
|
||||
public final class SlidingWindowScript {
|
||||
|
||||
private SlidingWindowScript() {
|
||||
throw new UnsupportedOperationException("工具类不允许实例化");
|
||||
}
|
||||
|
||||
private static final DefaultRedisScript<Long> SCRIPT;
|
||||
|
||||
static {
|
||||
SCRIPT = new DefaultRedisScript<>();
|
||||
SCRIPT.setScriptText(
|
||||
"local key = KEYS[1] " +
|
||||
"local now = tonumber(ARGV[1]) " +
|
||||
"local window = tonumber(ARGV[2]) " +
|
||||
"local member = ARGV[3] " +
|
||||
"redis.call('ZREMRANGEBYSCORE', key, 0, now - window) " +
|
||||
"redis.call('ZADD', key, now, member) " +
|
||||
"redis.call('PEXPIRE', key, window + 1000) " +
|
||||
"return redis.call('ZCARD', key)"
|
||||
);
|
||||
SCRIPT.setResultType(Long.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行滑动窗口计数
|
||||
*
|
||||
* @param redisTemplate Redis 模板
|
||||
* @param key 限流 Key(如 {@code rate_limiter:ip:192.168.1.1})
|
||||
* @param windowMs 窗口大小(毫秒)
|
||||
* @return 当前窗口内请求数(包含本次)
|
||||
*/
|
||||
public static Long execute(RedisTemplate<String, Object> redisTemplate,
|
||||
String key,
|
||||
long windowMs) {
|
||||
// 传 Long 而非 String,避免 Jackson 序列化器加双引号导致 Lua tonumber() 返回 nil
|
||||
return redisTemplate.execute(
|
||||
SCRIPT,
|
||||
Collections.singletonList(key),
|
||||
System.currentTimeMillis(),
|
||||
windowMs,
|
||||
UUID.randomUUID().toString()
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -70,8 +70,10 @@ mybatis-plus:
|
||||
|
||||
# 接口限流
|
||||
rate-limit:
|
||||
default-limit: 5
|
||||
default-window: 1
|
||||
default-limit: 60
|
||||
default-window: 60
|
||||
ip:
|
||||
enabled: false # 开发环境关闭 IP 全局限流
|
||||
|
||||
# 安全配置
|
||||
security:
|
||||
|
||||
@@ -71,6 +71,10 @@ mybatis-plus:
|
||||
rate-limit:
|
||||
default-limit: 10
|
||||
default-window: 1
|
||||
ip:
|
||||
enabled: true
|
||||
limit: 1000 # IP 全局:1000 req / 60s
|
||||
window-seconds: 60
|
||||
|
||||
# 安全配置
|
||||
security:
|
||||
|
||||
Reference in New Issue
Block a user