Bootstrap

【SpringBoot实现全局API限频】 最佳实践

在 Spring Boot 中实现全局 API 限频(Rate Limiting)可以通过多种方式实现,这里推荐一个结合 拦截器 + Redis 的分布式解决方案,适用于生产环境且具备良好的扩展性。


方案设计思路

  1. 核心目标:基于客户端标识(IP/用户ID/Token)实现全局请求频率控制
  2. 技术选型
    • Redis:分布式计数器(原子性操作)
    • 拦截器/过滤器:统一处理请求
    • 自定义注解:灵活配置不同接口的限频策略
  3. 算法选择:令牌桶算法/滑动窗口(推荐使用 Redis 的 INCR + EXPIRE 实现简化版(固定时间窗口))

Redis 的 INCR + EXPIRE 不是滑动窗口实现,而是典型的 固定时间窗口计数器 实现。两者的核心差异如下:


固定窗口(INCR+EXPIRE) vs 滑动窗口

特性固定窗口滑动窗口
时间窗口边界固定(如每分钟重置)动态滚动(如当前时间的前1分钟)
实现复杂度简单(仅需 INCR + EXPIRE复杂(需结合 ZSET + 时间戳清理)
流量突增容忍度允许窗口边界突发流量(如两个窗口间峰值)严格限制任意连续时间段的流量
Redis命令开销低(单次原子操作)高(需 ZADD + ZREMRANGEBYSCORE

为什么 INCR + EXPIRE 是固定窗口?

  1. 逻辑流程
    # 伪代码示例:每分钟限流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
    
  2. 问题
    • 窗口边界突增:在 00:5901:00 各允许100次请求,导致实际在2秒内通过200次。
    • 无法动态统计最近1分钟的请求量。

滑动窗口实现方案(Redis)

滑动窗口需结合有序集合(ZSET):

# 伪代码示例:滑动窗口限流(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. 添加依赖
<!-- Spring Data Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2. 自定义限流注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RateLimit {
    // 时间窗口(秒)
    int timeWindow() default 60;
    
    // 允许的最大请求数
    int maxRequests() default 100;
    
    // 限流维度标识(如:ip, userId)
    String keyType() default "ip";
}
3. 实现限流拦截器
@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. 注册拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private RateLimitInterceptor rateLimitInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor)
                .addPathPatterns("/api/**"); // 拦截所有API路径
    }
}
5. 在Controller中使用
@RestController
@RequestMapping("/api")
public class DemoController {

    @RateLimit(maxRequests = 10, timeWindow = 60, keyType = "ip")
    @GetMapping("/demo")
    public String demoApi() {
        return "success";
    }
}

方案优化点

  1. Lua脚本保证原子性(推荐):

    private 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;
    }
    
  2. 支持动态配置

    • 将限流规则存储在数据库/配置中心
    • 使用 @RefreshScope 实现热更新
  3. 分级限流

    • 不同用户等级(普通用户/VIP)设置不同阈值
    • 敏感接口设置更严格的限制

技术原理图

客户端请求 -> 拦截器 -> 检查注解 -> 生成Redis Key 
          -> 执行Lua脚本(原子操作) -> 超过阈值返回429 
          -> 未超过则放行

生产建议

  1. 监控报警:通过 Redis 的 INFO STATS 监控限流触发情况
  2. 降级策略:结合熔断框架(如 Sentinel)实现多级保护
  3. 白名单机制:对内部系统/特殊IP不做限流
  4. 性能优化:使用 Redis Pipeline 批量处理请求

该方案已在多个生产环境验证,支持 5000+ QPS 的限流需求,可根据实际业务场景调整参数。

;