接口限流Part 2:滑动窗口

在上一篇文章中,我们了解了固定窗口限流的基本原理和实现方式。虽然固定窗口实现简单,但在实际应用中存在一个致命问题:边界突刺

一、固定窗口的边界突刺问题

固定窗口算法虽然简单有效,但在窗口边界处存在明显的流量突刺问题:

时间轴:|----窗口1----|----窗口2----|
请求:   10:00:59    10:01:00
结果:   第20次      第1次(新窗口)
问题:   在边界时刻可能出现2倍阈值的突发流量

具体场景

  • 设置限制:每分钟20次请求
  • 用户在10:00:59发送20次请求(窗口1的最后时刻)
  • 用户在10:01:00又发送20次请求(窗口2的开始时刻)
  • 结果:1秒内实际处理了40次请求,远超预期限制

这就是为什么我们需要滑动窗口 算法来解决这个问题。

二、滑动窗窗口实现原理

滑动窗口算法的核心思想是:统计任意时刻向前推N秒内的请求总数,而不是固定时间块的请求数

工作原理

滑动窗口通过以下方式实现平滑限流:

  1. 动态时间窗口 :每次请求时,都计算”当前时间向前推N秒”这个时间窗口内的请求总数
  2. 实时清理 :自动移除超出时间窗口的过期请求记录
  3. 精确统计 :统计窗口内的请求数量,确保不超过限制

与固定窗口的对比

固定窗口

窗口1: [10:00:00 - 10:01:00] 计数: 20
窗口2: [10:01:00 - 10:02:00] 计数: 20
问题: 边界时刻可能处理40次请求

滑动窗口

10:00:59时刻: 统计 [10:00:00 - 10:00:59] 计数: 20
10:01:00时刻: 统计 [10:00:01 - 10:01:00] 计数: 20
结果: 任何时刻都不会超过20次限制

滑动窗口通过连续的时间窗口 消除了边界突刺问题,实现了真正的平滑限流。

三、Redis + Lua 实现方案

滑动窗口的实现相对复杂,我们需要记录每个请求的时间戳,并动态清理过期数据。这里采用Redis ZSET + Lua脚本的方案:

  • ZSET结构 :使用Score存储请求时间戳,Value存储请求标识
  • Lua脚本 :原子性执行清理和统计操作
  • AOP切面 :注解驱动,使用简单

1. 定义限流注解

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
    /**
     * 限流的key
     * @return key
     */
    String key() default "";

    /**
     * 限流的时间窗口
     * @return 时间窗口
     */
    int timeWindow() default 60;

    /**
     * 限流的时间单位
     * @return 时间单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * 限流的请求数
     * @return 请求数
     */
    int requestCount() default 1;

    /**
     * 限流的提示信息
     * @return 提示信息
     */
    String message() default "请求过于频繁,请稍后再试!";
    /**
     * 限流类型
     * @return 限流类型
     */
    LimitType limitType() default LimitType.IP;

    enum LimitType {
        /**
         * 按照IP限流
         */
        IP,

        /**
         * 按照用户ID限流
         */
        USER_ID,

        /**
         * 按照接口限流
         */
        API,

        /**
         * 全局限流
         */
        GLOBAL
    }
}

注解参数说明

  • key:限流标识,用于构造Redis Key
  • timeWindow:时间窗口大小,限流统计的时间范围
  • timeUnit:时间单位,支持秒、分钟、小时等
  • requestCount:窗口内允许的最大请求数
  • message:限流触发时的提示信息
  • limitType:限流维度,支持IP、用户ID、接口、全局四种策略

2. 限流切面实现

@Aspect
@Component
@Slf4j
public class RateLimitAspect {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    private final DefaultRedisScript<Long> rateLimitScript;

    public RateLimitAspect(){
        // 初始化Lua脚本
        DefaultRedisScript<Long> rateLimitScript = new DefaultRedisScript<>();
        rateLimitScript.setLocation(new ClassPathResource("rate_limit.lua"));
        rateLimitScript.setResultType(Long.class);
        this.rateLimitScript = rateLimitScript;
    }
}

设计说明

  • 使用Spring AOP实现注解驱动的限流
  • Lua脚本预加载,提高执行效率
  • 支持多种限流策略(IP、用户、接口、全局)

3. 核心Lua脚本

-- KEYS[1] = 限流key
-- ARGV[1] = 时间窗口(毫秒)
-- ARGV[2] = 限制次数
-- ARGV[3] = 当前时间戳(毫秒)

local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

-- 步骤1:清理过期请求(移除窗口外的数据)
redis.call("ZREMRANGEBYSCORE", key, 0, now - window)

-- 步骤2:统计当前窗口内的请求数量
local count = redis.call("ZCARD", key)

-- 步骤3:判断是否超过限制
if count >= limit then
    return 0  -- 拒绝请求
end

-- 步骤4:记录本次请求
redis.call("ZADD", key, now, tostring(now))
redis.call("EXPIRE", key, math.ceil(window / 1000))

return 1  -- 允许请求

脚本执行流程

  1. 清理过期数据 :移除超出时间窗口的请求记录
  2. 统计当前数量 :计算窗口内的请求总数
  3. 阈值判断 :检查是否超过限制
  4. 记录新请求 :将当前请求加入ZSet,设置过期时间

ZSet的优势

  • Score存储时间戳,支持范围查询
  • 自动排序,便于清理过期数据
  • 原子操作,避免并发问题

4. AOP切面方法

@Before("@annotation(common.core.annotation.limit.RateLimit)")
public void doBefore(JoinPoint joinPoint){
    MethodSignature signature = (MethodSignature) joinPoint.getSignature();
    Method method = signature.getMethod();
    RateLimit rateLimit = method.getAnnotation(RateLimit.class);

    if (rateLimit != null) {
        // 构建限流Key
        String key = buildKey(rateLimit, method);

        // 执行Lua脚本
        Long result = stringRedisTemplate.execute(
                rateLimitScript,
                Collections.singletonList(key),
                String.valueOf(rateLimit.timeUnit().toMillis(rateLimit.timeWindow())),
                String.valueOf(rateLimit.requestCount()),
                String.valueOf(System.currentTimeMillis())
        );

        // 判断是否限流
        if (result != null && result > rateLimit.requestCount()) {
            log.warn("接口 {} 请求过于频繁,最大请求次数:{},当前请求次数:{}",
                    key, rateLimit.requestCount(), result);
            throw new RateLimitException(rateLimit.message());
        }
    }
}

/**
 * 构建限流Key
 */
private String buildKey(RateLimit rateLimit, Method method) {
    StringBuilder keyBuilder = new StringBuilder("rate_limit:");
    
    switch (rateLimit.limitType()) {
        case IP:
            keyBuilder.append(getClientIP()).append(":");
            break;
        case USER_ID:
            keyBuilder.append(getCurrentUserId()).append(":");
            break;
        case API:
            keyBuilder.append(method.getName()).append(":");
            break;
        case GLOBAL:
            break;
    }
    
    keyBuilder.append(rateLimit.key());
    return keyBuilder.toString();
}

实现要点

  • 方法执行前进行限流检查
  • 支持多种限流维度(IP、用户、接口、全局)
  • 限流触发时抛出异常,统一处理

5. 使用示例

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    @PostMapping("/login")
    @RateLimit(
        key = "login", 
        requestCount = 3,
        timeWindow = 5, 
        timeUnit = TimeUnit.MINUTES, 
        message = "登录频率过快,请稍后再试",
        limitType = LimitType.IP
    )
    public Response<LoginVO> login(@RequestBody @Valid LoginDTO loginDTO){
        return authService.login(loginDTO);
    }

    @PostMapping("/register")
    @RateLimit(
        key = "register", 
        requestCount = 1,
        timeWindow = 1, 
        timeUnit = TimeUnit.HOURS, 
        message = "注册过于频繁,请1小时后再试",
        limitType = LimitType.IP
    )
    public Response<RegisterVO> register(@RequestBody @Valid RegisterDTO registerDTO){
        return authService.register(registerDTO);
    }

    @GetMapping("/profile")
    @RateLimit(
        key = "profile", 
        requestCount = 100,
        timeWindow = 1, 
        timeUnit = TimeUnit.MINUTES, 
        message = "请求过于频繁",
        limitType = LimitType.USER_ID
    )
    public Response<UserProfileVO> getProfile(){
        return userService.getProfile();
    }
}

6. 最佳实践

配置建议

  • 登录接口 :3次/5分钟(防止暴力破解)
  • 注册接口 :1次/小时(防止批量注册)
  • 查询接口 :100次/分钟(正常业务频率)
  • 支付接口 :10次/分钟(重要操作限制)

监控告警

// 可以增加监控指标
@EventListener
public void handleRateLimitEvent(RateLimitEvent event) {
    // 记录限流触发情况
    meterRegistry.counter("rate_limit.triggered", 
        "key", event.getKey(),
        "limit", String.valueOf(event.getLimit())
    ).increment();
}

注意事项

  • 合理设置时间窗口大小,避免过于严格
  • 监控限流触发频率,及时调整策略
  • 考虑用户体验,提供友好的错误提示
  • 生产环境建议配置Redis集群,提高可用性