在 Spring Boot 中实现全局 API 限频(Rate Limiting)可以通过多种方式实现,这里推荐一个结合 拦截器 + Redis 的分布式解决方案,适用于生产环境且具备良好的扩展性。
方案设计思路
- 核心目标:基于客户端标识(IP/用户ID/Token)实现全局请求频率控制
- 技术选型 :
Redis
:分布式计数器(原子性操作)拦截器/过滤器
:统一处理请求自定义注解
:灵活配置不同接口的限频策略
- 算法选择 :令牌桶算法/滑动窗口(推荐使用 Redis 的
INCR
+EXPIRE
实现简化版(固定时间窗口))
Redis 的 INCR
+ EXPIRE
不是滑动窗口实现 ,而是典型的 固定时间窗口计数器 实现。两者的核心差异如下:
固定窗口(INCR+EXPIRE) vs 滑动窗口
特性 | 固定窗口 | 滑动窗口 |
---|---|---|
时间窗口边界 | 固定(如每分钟重置) | 动态滚动(如当前时间的前1分钟) |
实现复杂度 | 简单(仅需 INCR + EXPIRE ) |
复杂(需结合 ZSET + 时间戳清理) |
流量突增容忍度 | 允许窗口边界突发流量(如两个窗口间峰值) | 严格限制任意连续时间段的流量 |
Redis命令开销 | 低(单次原子操作) | 高(需 ZADD + ZREMRANGEBYSCORE ) |
为什么 INCR
+ EXPIRE
是固定窗口?
-
逻辑流程 :
bash# 伪代码示例:每分钟限流100次 current_count = INCR rate_limiter_key IF current_count == 1: EXPIRE rate_limiter_key 60 # 首次设置过期时间 IF current_count > 100: REJECT_REQUEST ELSE: ALLOW_REQUEST
-
问题 :
- 窗口边界突增:在
00:59
和01:00
各允许100次请求,导致实际在2秒内通过200次。 - 无法动态统计最近1分钟的请求量。
- 窗口边界突增:在
滑动窗口实现方案(Redis)
滑动窗口需结合有序集合(ZSET
):
bash
# 伪代码示例:滑动窗口限流(1分钟100次)
ZREMRANGEBYSCORE request_timestamps -inf (now - 60) # 清理旧记录
ZCARD request_timestamps # 统计当前窗口内请求数
IF count < 100:
ZADD request_timestamps now now # 记录当前请求时间戳
EXPIRE request_timestamps 60 # 更新过期时间
ALLOW_REQUEST
ELSE:
REJECT_REQUEST
总结
- ✅
INCR
+EXPIRE
:适合简单限流场景,容忍边界突发流量。 - ✅ 滑动窗口(ZSET):需精准控制任意连续时间段流量,但资源消耗更高。
实现步骤(完整代码示例)
1. 添加依赖
xml
<!-- Spring Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2. 自定义限流注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
// 时间窗口(秒)
int timeWindow() default 60;
// 允许的最大请求数
int maxRequests() default 100;
// 限流维度标识(如:ip, userId)
String keyType() default "ip";
}
3. 实现限流拦截器
java
@Component
public class RateLimitInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
if (handler instanceof HandlerMethod) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
if (rateLimit != null) {
String key = buildRedisKey(request, rateLimit);
int currentCount = getCurrentCount(key);
if (currentCount >= rateLimit.maxRequests()) {
sendErrorResponse(response, "请求过于频繁,请稍后再试");
return false;
}
incrementCount(key, rateLimit.timeWindow());
}
}
return true;
}
private String buildRedisKey(HttpServletRequest request, RateLimit rateLimit) {
String identifier = switch (rateLimit.keyType()) {
case "ip" -> request.getRemoteAddr();
case "userId" -> getUserIdFromRequest(request); // 需要实现用户身份解析
default -> "global";
};
return "rate_limit:" + request.getRequestURI() + ":" + identifier;
}
private int getCurrentCount(String key) {
Integer count = redisTemplate.opsForValue().get(key);
return count != null ? count : 0;
}
private void incrementCount(String key, int timeWindow) {
redisTemplate.opsForValue().increment(key, 1);
redisTemplate.expire(key, timeWindow, TimeUnit.SECONDS);
}
private void sendErrorResponse(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setContentType("application/json");
response.getWriter().write("{\"code\":429, \"message\":\"" + message + "\"}");
}
}
4. 注册拦截器
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private RateLimitInterceptor rateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/api/**"); // 拦截所有API路径
}
}
5. 在Controller中使用
java
@RestController
@RequestMapping("/api")
public class DemoController {
@RateLimit(maxRequests = 10, timeWindow = 60, keyType = "ip")
@GetMapping("/demo")
public String demoApi() {
return "success";
}
}
方案优化点
-
Lua脚本保证原子性(推荐):
javaprivate static final String RATE_LIMIT_SCRIPT = "local current = redis.call('incr', KEYS[1])\n" + "if current == 1 then\n" + " redis.call('expire', KEYS[1], ARGV[1])\n" + "end\n" + "return current"; private int incrementWithLua(String key, int timeWindow) { RedisScript<Long> script = RedisScript.of(RATE_LIMIT_SCRIPT, Long.class); Long count = redisTemplate.execute(script, List.of(key), timeWindow); return count != null ? count.intValue() : 0; }
-
支持动态配置:
- 将限流规则存储在数据库/配置中心
- 使用
@RefreshScope
实现热更新
-
分级限流:
- 不同用户等级(普通用户/VIP)设置不同阈值
- 敏感接口设置更严格的限制
技术原理图
客户端请求 -> 拦截器 -> 检查注解 -> 生成Redis Key
-> 执行Lua脚本(原子操作) -> 超过阈值返回429
-> 未超过则放行
生产建议
- 监控报警 :通过 Redis 的
INFO STATS
监控限流触发情况 - 降级策略:结合熔断框架(如 Sentinel)实现多级保护
- 白名单机制:对内部系统/特殊IP不做限流
- 性能优化:使用 Redis Pipeline 批量处理请求
该方案已在多个生产环境验证,支持 5000+ QPS 的限流需求,可根据实际业务场景调整参数。