From b17bd6bf7c5044640be1ed081fa5755afde813fe Mon Sep 17 00:00:00 2001
From: "Ray.Hao" <1490493387@qq.com>
Date: Sun, 5 Jul 2026 00:19:44 +0800
Subject: [PATCH] =?UTF-8?q?refactor(rate-limit):=20=E9=99=90=E6=B5=81?=
=?UTF-8?q?=E6=9E=B6=E6=9E=84=E5=8D=87=E7=BA=A7=E4=B8=BA=E5=8F=8C=E5=B1=82?=
=?UTF-8?q?=E6=BB=91=E5=8A=A8=E7=AA=97=E5=8F=A3=EF=BC=8C=E6=96=B0=E5=A2=9E?=
=?UTF-8?q?IP=E5=85=A8=E5=B1=80=E9=98=B2=E6=8A=A4=E4=B8=8E=E5=93=8D?=
=?UTF-8?q?=E5=BA=94=E5=A4=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
限流算法从 INCR+EXPIRE 固定窗口升级为 ZSET+Lua 滑动窗口,
新增 IpRateLimitFilter 提供 IP 全局防护(默认 1000次/60s)。
新增 X-RateLimit-Limit/Remaining/Reset 响应头与 Retry-After,
新增 rate-limit.ip.* 配置项控制 IP 全局开关与阈值,
@RateLimit 空注解统一走全局配置,消除硬编码。
---
.../boot/auth/controller/AuthController.java | 2 +-
.../auth/controller/WxMaAuthController.java | 6 +-
.../boot/common/constant/RedisConstants.java | 6 +-
.../framework/web/aspect/RateLimitAspect.java | 62 +++++++---
.../web/config/RateLimitProperties.java | 39 +++++++
.../web/filter/IpRateLimitFilter.java | 106 ++++++++++++++++++
.../web/ratelimit/SlidingWindowScript.java | 69 ++++++++++++
src/main/resources/application-dev.yml | 6 +-
src/main/resources/application-prod.yml | 4 +
9 files changed, 279 insertions(+), 21 deletions(-)
create mode 100644 src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java
create mode 100644 src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java
diff --git a/src/main/java/com/youlai/boot/auth/controller/AuthController.java b/src/main/java/com/youlai/boot/auth/controller/AuthController.java
index 305938e5..f3884f0e 100644
--- a/src/main/java/com/youlai/boot/auth/controller/AuthController.java
+++ b/src/main/java/com/youlai/boot/auth/controller/AuthController.java
@@ -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 login(@RequestBody @Valid LoginForm request) {
AuthenticationToken authenticationToken = authService.login(request.getUsername(), request.getPassword());
return Result.success(authenticationToken);
diff --git a/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java b/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java
index 33653466..760295fe 100644
--- a/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java
+++ b/src/main/java/com/youlai/boot/auth/controller/WxMaAuthController.java
@@ -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 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 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 bindMobile(@Valid @RequestBody WxMaBindMobileForm req) {
AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode());
return Result.success(result);
diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java
index 93e24ccd..035d6792 100644
--- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java
+++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java
@@ -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:{}";
}
/**
diff --git a/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java b/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java
index bcbc377c..7c1940f1 100644
--- a/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java
+++ b/src/main/java/com/youlai/boot/framework/web/aspect/RateLimitAspect.java
@@ -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;
-
/**
- * 接口限流切面
- * 对 @RateLimit 标注的方法做 Redis 计数,超阈值则拒绝
+ * 接口级限流切面
+ *
+ * 对标注了 {@link RateLimit} 的 Controller 方法做 Redis 滑动窗口计数。
+ * 超阈值抛出 {@link BusinessException}(A0502),全局异常处理器统一返回 JSON。
+ *
+ *
+ * 升级历史
+ *
+ * - 4.3.1 — 初始版本:固定窗口计数器
+ * - 4.4.0 — 升级为滑动窗口 + X-RateLimit-* 渐进式响应头
+ *
*
* @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));
+ }
+ }
+ }
+
}
diff --git a/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java b/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java
index 6ea46c6d..a62102dc 100644
--- a/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java
+++ b/src/main/java/com/youlai/boot/framework/web/config/RateLimitProperties.java
@@ -6,13 +6,52 @@ import org.springframework.stereotype.Component;
/**
* 限流配置
+ *
+ * 对应 application.yml 中的 {@code rate-limit} 配置节点。
+ *
+ *
+ * @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;
+
+ }
+
}
diff --git a/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java
new file mode 100644
index 00000000..a05d8f99
--- /dev/null
+++ b/src/main/java/com/youlai/boot/framework/web/filter/IpRateLimitFilter.java
@@ -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 全局限流过滤器
+ *
+ * 在所有请求进入 Controller 之前,按 IP 维度做滑动窗口限流。
+ * 与 {@code @RateLimit} 注解级限流叠加:先过 IP 全局,再过接口级。
+ *
+ *
+ * 限流维度
+ * {@code
+ * Key: rate_limiter:ip:{clientIp}
+ * 默认: 1000 req / 60s(可通过 rate-limit.ip.* 配置)
+ * }
+ *
+ * 响应头
+ *
+ * | X-RateLimit-Limit | 窗口内最大允许请求数 |
+ * | X-RateLimit-Remaining | 窗口内剩余可用请求数 |
+ * | X-RateLimit-Reset | 窗口重置时间(Unix 秒) |
+ * | Retry-After | 超限时建议重试等待秒数(仅 429) |
+ *
+ *
+ * @author Ray.Hao
+ * @since 4.4.0
+ */
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE + 5)
+@RequiredArgsConstructor
+@Slf4j
+public class IpRateLimitFilter extends OncePerRequestFilter {
+
+ private final RedisTemplate 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));
+ }
+
+}
diff --git a/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java b/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java
new file mode 100644
index 00000000..a05bc25e
--- /dev/null
+++ b/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java
@@ -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 脚本(共享工具)
+ *
+ * 基于 Redis Sorted Set 实现原子化的滑动窗口计数:
+ *
+ * - ZREMRANGEBYSCORE 清除窗口外旧请求
+ * - ZADD 添加当前请求
+ * - ZCARD 统计窗口内请求数
+ * - PEXPIRE 设置 Key 过期(窗口 + 1s 冗余)
+ *
+ * 一次网络往返完成全部操作,避免多次 Redis 调用之间的竞态条件。
+ *
+ *
+ * @author Ray.Hao
+ * @since 4.4.0
+ */
+public final class SlidingWindowScript {
+
+ private SlidingWindowScript() {
+ throw new UnsupportedOperationException("工具类不允许实例化");
+ }
+
+ private static final DefaultRedisScript 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 redisTemplate,
+ String key,
+ long windowMs) {
+ // 传 Long 而非 String,避免 Jackson 序列化器加双引号导致 Lua tonumber() 返回 nil
+ return redisTemplate.execute(
+ SCRIPT,
+ Collections.singletonList(key),
+ System.currentTimeMillis(),
+ windowMs,
+ UUID.randomUUID().toString()
+ );
+ }
+
+}
diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml
index f3758e1b..ab421526 100644
--- a/src/main/resources/application-dev.yml
+++ b/src/main/resources/application-dev.yml
@@ -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:
diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml
index f445a740..260fb344 100644
--- a/src/main/resources/application-prod.yml
+++ b/src/main/resources/application-prod.yml
@@ -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: