Bladeren bron

feat(core): 优化IP限流实现及异常降级处理

- 使用Lua脚本保证Redis计数与过期时间原子执行,避免计数键漏过期
- 将IP限流阈值调整为默认每秒1000次,支持通过系统配置动态调整
- 实现限流依赖异常时的降级策略,可配置为降级放行或拒绝请求
- 在异常拒绝情况下返回明确的限流服务不可用响应,避免接口500错误
- 增加RateLimiterConfig配置项,支持限流异常处理策略配置
- 修改SecurityConfig以注入新增限流配置
- WebResponseHelper新增支持带自定义HTTP状态码的错误响应写入方法
- 调整限流过滤器代码结构,明确限流判定结果类型,增强代码可读性和扩展性
wangzq 1 maand geleden
bovenliggende
commit
57b5cb8bc1

+ 1 - 1
src/main/java/com/zsElectric/boot/config/SecurityConfig.java

@@ -150,7 +150,7 @@ public class SecurityConfig {
                 // 禁用 X-Frame-Options 响应头,允许页面被嵌套到 iframe 中
                 .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
                 // 限流过滤器
-                .addFilterBefore(new RateLimiterFilter(redisTemplate, configService), UsernamePasswordAuthenticationFilter.class)
+                .addFilterBefore(new RateLimiterFilter(redisTemplate, configService, securityProperties.getRateLimiter()), UsernamePasswordAuthenticationFilter.class)
                 // 验证码校验过滤器
                 .addFilterBefore(new CaptchaValidationFilter(redisTemplate, codeGenerator), UsernamePasswordAuthenticationFilter.class)
                 // 验证和解析过滤器

+ 34 - 0
src/main/java/com/zsElectric/boot/config/property/SecurityProperties.java

@@ -51,6 +51,11 @@ public class SecurityProperties {
      */
     private MaliciousRequestBlockConfig maliciousRequestBlock = new MaliciousRequestBlockConfig();
 
+    /**
+     * IP 限流过滤器配置
+     */
+    private RateLimiterConfig rateLimiter = new RateLimiterConfig();
+
     /**
      * 会话配置嵌套类
      */
@@ -132,4 +137,33 @@ public class SecurityProperties {
          */
         private Boolean enabled = true;
     }
+
+    /**
+     * IP 限流过滤器配置嵌套类
+     */
+    @Data
+    public static class RateLimiterConfig {
+        /**
+         * Redis 异常时的降级策略
+         * <p>ALLOW - 降级放行,避免 Redis 故障拖垮登录和普通接口(默认)</p>
+         * <p>REJECT - 返回明确的限流服务异常响应</p>
+         */
+        @NotNull
+        private RedisFailureStrategy redisFailureStrategy = RedisFailureStrategy.ALLOW;
+    }
+
+    /**
+     * 限流 Redis 异常处理策略
+     */
+    public enum RedisFailureStrategy {
+        /**
+         * Redis 异常时放行请求
+         */
+        ALLOW,
+
+        /**
+         * Redis 异常时拒绝请求并返回服务不可用
+         */
+        REJECT
+    }
 }

+ 105 - 32
src/main/java/com/zsElectric/boot/core/filter/RateLimiterFilter.java

@@ -4,8 +4,9 @@ import cn.hutool.core.convert.Convert;
 import cn.hutool.core.util.StrUtil;
 import com.zsElectric.boot.common.constant.RedisConstants;
 import com.zsElectric.boot.common.constant.SystemConstants;
-import com.zsElectric.boot.core.web.ResultCode;
 import com.zsElectric.boot.common.util.IPUtils;
+import com.zsElectric.boot.config.property.SecurityProperties;
+import com.zsElectric.boot.core.web.ResultCode;
 import com.zsElectric.boot.core.web.WebResponseHelper;
 import com.zsElectric.boot.system.service.ConfigService;
 import jakarta.servlet.FilterChain;
@@ -15,60 +16,101 @@ import jakarta.servlet.http.HttpServletResponse;
 import lombok.extern.slf4j.Slf4j;
 import org.jetbrains.annotations.NotNull;
 import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
+import org.springframework.data.redis.core.script.RedisScript;
+import org.springframework.http.HttpStatus;
 import org.springframework.web.filter.OncePerRequestFilter;
 
 import java.io.IOException;
-import java.util.concurrent.TimeUnit;
+import java.util.Collections;
 
 /**
  * IP 限流过滤器
  *
+ * <p>基于 Redis 记录单个 IP 每秒请求次数。Redis 异常时按配置降级处理,避免限流依赖故障导致登录和接口整体 500。</p>
+ *
  * @author Theo
  * @since 2024/08/10 14:38
  */
 @Slf4j
 public class RateLimiterFilter extends OncePerRequestFilter {
 
+    /**
+     * 使用 Lua 保证 INCR 与首次 EXPIRE 在 Redis 侧原子执行,避免计数键漏设过期时间。
+     */
+    private static final String RATE_LIMIT_SCRIPT_TEXT = """
+            local current = redis.call('INCR', KEYS[1])
+            if current == 1 then
+                redis.call('EXPIRE', KEYS[1], ARGV[1])
+            end
+            return current
+            """;
+    private static final RedisScript<Long> RATE_LIMIT_SCRIPT = new DefaultRedisScript<>(RATE_LIMIT_SCRIPT_TEXT, Long.class);
+
+    /**
+     * IP 限流时间窗口,单位:秒
+     */
+    private static final long RATE_LIMIT_WINDOW_SECONDS = 1L;
+
+    /**
+     * 默认 IP 限流阈值
+     */
+    private static final long DEFAULT_IP_LIMIT = 1000L;
+
+    /**
+     * 限流服务不可用时返回给客户端的明确提示
+     */
+    private static final String RATE_LIMIT_SERVICE_UNAVAILABLE_MESSAGE = "限流服务异常,请稍后再试";
+
     private final RedisTemplate<String, Object> redisTemplate;
     private final ConfigService configService;
+    private final SecurityProperties.RateLimiterConfig rateLimiterConfig;
 
-    private static final long DEFAULT_IP_LIMIT = 1000L; // 默认 IP 限流阈值
-
-    public RateLimiterFilter(RedisTemplate<String, Object> redisTemplate, ConfigService configService) {
+    public RateLimiterFilter(RedisTemplate<String, Object> redisTemplate,
+                             ConfigService configService,
+                             SecurityProperties.RateLimiterConfig rateLimiterConfig) {
         this.redisTemplate = redisTemplate;
         this.configService = configService;
+        this.rateLimiterConfig = rateLimiterConfig == null ? new SecurityProperties.RateLimiterConfig() : rateLimiterConfig;
     }
 
     /**
-     * 判断 IP 是否触发限流
-     * 默认限制同一 IP 每秒最多请求 10 次,可通过系统配置调整。
+     * 判断 IP 限流结果
+     * 默认限制同一 IP 每秒最多请求 1000 次,可通过系统配置调整。
      * 如果系统未配置限流阈值,默认跳过限流。
      *
      * @param ip IP 地址
-     * @return 是否限流:true 表示限流;false 表示未限流
+     * @return 限流判定结果
      */
-    public boolean rateLimit(String ip) {
-        // 限流 Redis 键
-        String key = StrUtil.format(RedisConstants.RateLimiter.IP, ip);
-
-        // 自增请求计数
-        Long count = redisTemplate.opsForValue().increment(key);
-        if (count == null || count == 1) {
-            // 第一次访问时设置过期时间为 1 秒
-            redisTemplate.expire(key, 1, TimeUnit.SECONDS);
-        }
+    RateLimitDecision rateLimit(String ip) {
+        try {
+            // 获取系统配置的限流阈值
+            Object systemConfig = configService.getSystemConfig(SystemConstants.SYSTEM_CONFIG_IP_QPS_LIMIT_KEY);
+            if (systemConfig == null) {
+                log.warn("系统未配置限流阈值,跳过限流");
+                return RateLimitDecision.ALLOW;
+            }
 
-        // 获取系统配置的限流阈值
-        Object systemConfig = configService.getSystemConfig(SystemConstants.SYSTEM_CONFIG_IP_QPS_LIMIT_KEY);
-        if (systemConfig == null) {
-            // 系统未配置限流,跳过限流逻辑
-            log.warn("系统未配置限流阈值,跳过限流");
-            return false;
-        }
+            // 执行 Redis 计数脚本,返回当前窗口内该 IP 的请求次数
+            long limit = Convert.toLong(systemConfig, DEFAULT_IP_LIMIT);
+            String key = StrUtil.format(RedisConstants.RateLimiter.IP, ip);
+            Long count = redisTemplate.execute(RATE_LIMIT_SCRIPT, Collections.singletonList(key), RATE_LIMIT_WINDOW_SECONDS);
+            if (count == null) {
+                throw new IllegalStateException("Redis 限流脚本返回空结果");
+            }
 
-        // 转换系统配置为限流值,默认为 10
-        long limit = Convert.toLong(systemConfig, DEFAULT_IP_LIMIT);
-        return count != null && count > limit;
+            return count > limit ? RateLimitDecision.RATE_LIMITED : RateLimitDecision.ALLOW;
+        } catch (Exception e) {
+            // 限流依赖异常只影响限流能力,不应默认拖垮登录和业务接口。
+            SecurityProperties.RedisFailureStrategy failureStrategy = rateLimiterConfig.getRedisFailureStrategy();
+            if (failureStrategy == SecurityProperties.RedisFailureStrategy.REJECT) {
+                log.error("IP限流依赖异常,按配置拒绝请求,ip={}", ip, e);
+                return RateLimitDecision.SERVICE_UNAVAILABLE;
+            }
+
+            log.error("IP限流依赖异常,降级放行,ip={}", ip, e);
+            return RateLimitDecision.ALLOW;
+        }
     }
 
     /**
@@ -80,19 +122,50 @@ public class RateLimiterFilter extends OncePerRequestFilter {
      * @param filterChain 过滤器链
      */
     @Override
-    protected void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response,
+    protected void doFilterInternal(@NotNull HttpServletRequest request,
+                                    @NotNull HttpServletResponse response,
                                     @NotNull FilterChain filterChain) throws ServletException, IOException {
         // 获取请求的 IP 地址
         String ip = IPUtils.getIpAddr(request);
+        RateLimitDecision decision = rateLimit(ip);
 
-        // 判断是否限流
-        if (rateLimit(ip)) {
-            // 返回限流错误信息
+        if (decision == RateLimitDecision.RATE_LIMITED) {
+            // 返回正常限流错误信息
             WebResponseHelper.writeError(response, ResultCode.REQUEST_CONCURRENCY_LIMIT_EXCEEDED);
             return;
         }
+        if (decision == RateLimitDecision.SERVICE_UNAVAILABLE) {
+            // Redis 异常且配置为拒绝时,返回明确的限流服务异常,避免继续冒泡成 500。
+            WebResponseHelper.writeError(
+                    response,
+                    HttpStatus.SERVICE_UNAVAILABLE.value(),
+                    ResultCode.SYSTEM_FUNCTION_DEGRADATION,
+                    RATE_LIMIT_SERVICE_UNAVAILABLE_MESSAGE
+            );
+            return;
+        }
 
         // 未触发限流,继续执行过滤器链
         filterChain.doFilter(request, response);
     }
+
+    /**
+     * IP 限流判定结果
+     */
+    enum RateLimitDecision {
+        /**
+         * 允许请求继续执行
+         */
+        ALLOW,
+
+        /**
+         * 请求超过配置阈值
+         */
+        RATE_LIMITED,
+
+        /**
+         * 限流服务不可用,且配置要求拒绝请求
+         */
+        SERVICE_UNAVAILABLE
+    }
 }

+ 24 - 7
src/main/java/com/zsElectric/boot/core/web/WebResponseHelper.java

@@ -38,22 +38,31 @@ public class WebResponseHelper {
      * @param message    自定义消息
      */
     public static void writeError(HttpServletResponse response, ResultCode resultCode, String message) {
+        writeError(response, mapHttpStatus(resultCode), resultCode, message);
+    }
+
+    /**
+     * 写入错误响应(带自定义 HTTP 状态码)
+     *
+     * @param response   HttpServletResponse
+     * @param httpStatus HTTP状态码
+     * @param resultCode 响应结果码
+     * @param message    自定义消息
+     */
+    public static void writeError(HttpServletResponse response, int httpStatus, ResultCode resultCode, String message) {
         try {
-            // 设置HTTP状态码
-            int httpStatus = mapHttpStatus(resultCode);
             response.setStatus(httpStatus);
             response.setCharacterEncoding(StandardCharsets.UTF_8.toString());
-            // 构建响应对象
+
             Result<?> result = message == null
                     ? Result.failed(resultCode)
                     : Result.failed(resultCode, message);
 
-            // 写入响应
-            JakartaServletUtil.write(response,
+            JakartaServletUtil.write(
+                    response,
                     JSONUtil.toJsonStr(result),
                     MediaType.APPLICATION_JSON_VALUE
             );
-
         } catch (Exception e) {
             log.error("写入错误响应失败: resultCode={}, message={}", resultCode, message, e);
         }
@@ -70,8 +79,16 @@ public class WebResponseHelper {
             case ACCESS_UNAUTHORIZED,
                     ACCESS_TOKEN_INVALID,
                     REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value();
+            case REQUEST_LIMIT_EXCEEDED,
+                    REQUEST_CONCURRENCY_LIMIT_EXCEEDED,
+                    USER_DUPLICATE_REQUEST,
+                    SYSTEM_RATE_LIMITING,
+                    THIRD_PARTY_SYSTEM_RATE_LIMITING -> HttpStatus.TOO_MANY_REQUESTS.value();
+            case SYSTEM_FUNCTION_DEGRADATION,
+                    CACHE_SERVICE_ERROR,
+                    CACHE_SERVICE_TIMEOUT,
+                    MIDDLEWARE_SERVICE_ERROR -> HttpStatus.SERVICE_UNAVAILABLE.value();
             default -> HttpStatus.BAD_REQUEST.value();
         };
     }
 }
-