Просмотр исходного кода

feat(backend): RateLimitInterceptor 改为 Redis 滑动窗口限流,支持多实例共享并保留内存降级

Xiaogang Liao 1 неделя назад
Родитель
Сommit
e450094d8a
1 измененных файлов с 103 добавлено и 12 удалено
  1. 103 12
      cfc-backend/src/main/java/com/etotem/cfc/config/RateLimitInterceptor.java

+ 103 - 12
cfc-backend/src/main/java/com/etotem/cfc/config/RateLimitInterceptor.java

@@ -3,29 +3,49 @@ package com.etotem.cfc.config;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.slf4j.Logger;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.slf4j.LoggerFactory;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.script.DefaultRedisScript;
 import org.springframework.stereotype.Component;
 import org.springframework.stereotype.Component;
 import org.springframework.web.servlet.HandlerInterceptor;
 import org.springframework.web.servlet.HandlerInterceptor;
 
 
+import javax.annotation.Resource;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import javax.servlet.http.HttpServletResponse;
+import java.util.Collections;
 import java.util.Deque;
 import java.util.Deque;
 import java.util.LinkedList;
 import java.util.LinkedList;
 import java.util.Map;
 import java.util.Map;
+import java.util.UUID;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ConcurrentHashMap;
 
 
 /**
 /**
- * 滑动窗口限流拦截器(进程内,基于 IP+URI
+ * 滑动窗口限流拦截器(基于 Redis ZSET,支持多实例共享
  * 限制:高频接口 10次/秒,发送验证码 1次/60秒
  * 限制:高频接口 10次/秒,发送验证码 1次/60秒
- * 注意:多实例部署时需替换为 Redis 方案
+ *
+ * 实现:Redis 原子 Lua 脚本做滑动窗口计数;Redis 不可用时自动降级到
+ *       进程内内存限流(ConcurrentHashMap),保证服务不中断。
  */
  */
 @Component
 @Component
 public class RateLimitInterceptor implements HandlerInterceptor {
 public class RateLimitInterceptor implements HandlerInterceptor {
 
 
     private static final Logger log = LoggerFactory.getLogger(RateLimitInterceptor.class);
     private static final Logger log = LoggerFactory.getLogger(RateLimitInterceptor.class);
 
 
-    private final Map<String, Deque<Long>> requestLogs = new ConcurrentHashMap<>();
+    @Resource
+    private RedisTemplate<String, Object> redisTemplate;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
     private final ObjectMapper objectMapper = new ObjectMapper();
 
 
+    /** Redis 限流 key 前缀 */
+    private static final String KEY_PREFIX = "rl:";
+
+    /** 内存限流兜底(Redis 不可用时使用) */
+    private final Map<String, Deque<Long>> requestLogs = new ConcurrentHashMap<>();
+
+    /** Redis 健康标记 + 失败后重试间隔(毫秒) */
+    private volatile boolean redisHealthy = true;
+    private volatile long lastRedisFailTime = 0L;
+    private static final long REDIS_RETRY_INTERVAL_MS = 30_000L;
+
     /** 限流配置:key模式 → {窗口毫秒, 允许次数} */
     /** 限流配置:key模式 → {窗口毫秒, 允许次数} */
     private static final Map<String, long[]> RATE_LIMIT_RULES = new ConcurrentHashMap<>();
     private static final Map<String, long[]> RATE_LIMIT_RULES = new ConcurrentHashMap<>();
 
 
@@ -39,38 +59,109 @@ public class RateLimitInterceptor implements HandlerInterceptor {
         RATE_LIMIT_RULES.put("__default__", new long[]{1000L, 50});
         RATE_LIMIT_RULES.put("__default__", new long[]{1000L, 50});
     }
     }
 
 
+    /**
+     * 滑动窗口限流 Lua 脚本(原子):
+     *   1. 清理窗口外的旧记录
+     *   2. 统计窗口内请求数
+     *   3. 未超限则记录本次请求并返回 1,超限返回 0
+     * KEYS[1]=限流key; ARGV[1]=当前时间戳ms; ARGV[2]=窗口ms; ARGV[3]=允许次数; ARGV[4]=member
+     */
+    private static final DefaultRedisScript<Long> SLIDING_WINDOW_SCRIPT =
+            new DefaultRedisScript<>(
+                    "redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, tonumber(ARGV[1]) - tonumber(ARGV[2])) " +
+                    "local count = redis.call('ZCARD', KEYS[1]) " +
+                    "if count >= tonumber(ARGV[3]) then return 0 end " +
+                    "redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4]) " +
+                    "redis.call('PEXPIRE', KEYS[1], ARGV[2]) " +
+                    "return 1",
+                    Long.class);
+
     @Override
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
         String path = request.getRequestURI();
         String path = request.getRequestURI();
         String clientIp = getClientIp(request);
         String clientIp = getClientIp(request);
-        String key = clientIp + ":" + path;
 
 
         long[] rule = findRule(path);
         long[] rule = findRule(path);
         long windowMs = rule[0];
         long windowMs = rule[0];
         int maxRequests = (int) rule[1];
         int maxRequests = (int) rule[1];
 
 
+        // 优先走 Redis 限流
+        if (shouldUseRedis()) {
+            try {
+                boolean allowed = tryAcquireRedis(clientIp, path, windowMs, maxRequests);
+                if (!allowed) {
+                    return reject(response, clientIp, path, windowMs);
+                }
+                return true;
+            } catch (Exception e) {
+                markRedisUnhealthy(e);
+                // 降级到内存限流
+            }
+        }
+
+        // 内存限流兜底
+        return tryAcquireMemory(clientIp, path, windowMs, maxRequests, response);
+    }
+
+    private boolean shouldUseRedis() {
+        if (redisHealthy) {
+            return true;
+        }
+        // 失败后间隔一段时间再尝试恢复 Redis 限流
+        if (System.currentTimeMillis() - lastRedisFailTime >= REDIS_RETRY_INTERVAL_MS) {
+            redisHealthy = true;
+            return true;
+        }
+        return false;
+    }
+
+    private void markRedisUnhealthy(Exception e) {
+        redisHealthy = false;
+        lastRedisFailTime = System.currentTimeMillis();
+        log.warn("Redis 限流不可用,降级到内存限流: {}", e.getMessage());
+    }
+
+    private boolean tryAcquireRedis(String clientIp, String path, long windowMs, int maxRequests) {
+        String key = KEY_PREFIX + clientIp + ":" + path;
+        long now = System.currentTimeMillis();
+        String member = now + ":" + UUID.randomUUID().toString();
+        Long result = redisTemplate.execute(
+                SLIDING_WINDOW_SCRIPT,
+                Collections.singletonList(key),
+                String.valueOf(now),
+                String.valueOf(windowMs),
+                String.valueOf(maxRequests),
+                member);
+        return result != null && result == 1L;
+    }
+
+    private boolean tryAcquireMemory(String clientIp, String path, long windowMs, int maxRequests,
+                                     HttpServletResponse response) throws Exception {
+        String key = clientIp + ":" + path;
         long now = System.currentTimeMillis();
         long now = System.currentTimeMillis();
 
 
         Deque<Long> timestamps = requestLogs.computeIfAbsent(key, k -> new LinkedList<>());
         Deque<Long> timestamps = requestLogs.computeIfAbsent(key, k -> new LinkedList<>());
 
 
         synchronized (timestamps) {
         synchronized (timestamps) {
-            // 清理过期记录
             while (!timestamps.isEmpty() && timestamps.peekFirst() < now - windowMs) {
             while (!timestamps.isEmpty() && timestamps.peekFirst() < now - windowMs) {
                 timestamps.pollFirst();
                 timestamps.pollFirst();
             }
             }
             if (timestamps.size() >= maxRequests) {
             if (timestamps.size() >= maxRequests) {
-                log.warn("限流触发: key={}, count={}, window={}ms", key, timestamps.size(), windowMs);
-                response.setStatus(429);
-                response.setContentType("application/json;charset=UTF-8");
-                response.getWriter().write("{\"code\":429,\"message\":\"请求过于频繁,请稍后再试\",\"data\":null}");
-                return false;
+                return reject(response, clientIp, path, windowMs);
             }
             }
             timestamps.addLast(now);
             timestamps.addLast(now);
         }
         }
-
         return true;
         return true;
     }
     }
 
 
+    private boolean reject(HttpServletResponse response, String clientIp, String path, long windowMs) throws Exception {
+        log.warn("限流触发: ip={}, path={}, window={}ms", clientIp, path, windowMs);
+        response.setStatus(429);
+        response.setContentType("application/json;charset=UTF-8");
+        response.getWriter().write("{\"code\":429,\"message\":\"请求过于频繁,请稍后再试\",\"data\":null}");
+        return false;
+    }
+
     private long[] findRule(String path) {
     private long[] findRule(String path) {
         for (Map.Entry<String, long[]> entry : RATE_LIMIT_RULES.entrySet()) {
         for (Map.Entry<String, long[]> entry : RATE_LIMIT_RULES.entrySet()) {
             if (path.contains(entry.getKey())) {
             if (path.contains(entry.getKey())) {
@@ -93,4 +184,4 @@ public class RateLimitInterceptor implements HandlerInterceptor {
         }
         }
         return ip;
         return ip;
     }
     }
-}
+}