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:
Ray.Hao
2026-07-05 00:19:44 +08:00
parent 12b226fdc9
commit b17bd6bf7c
9 changed files with 279 additions and 21 deletions

View File

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

View File

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

View File

@@ -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:{}";
}
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:

View File

@@ -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: