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