接口限流Part 2:滑动窗口
接口限流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秒内的请求总数,而不是固定时间块的请求数 。
工作原理
滑动窗口通过以下方式实现平滑限流:
- 动态时间窗口 :每次请求时,都计算”当前时间向前推N秒”这个时间窗口内的请求总数
- 实时清理 :自动移除超出时间窗口的过期请求记录
- 精确统计 :统计窗口内的请求数量,确保不超过限制
与固定窗口的对比
固定窗口 :
窗口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 KeytimeWindow:时间窗口大小,限流统计的时间范围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 -- 允许请求
脚本执行流程 :
- 清理过期数据 :移除超出时间窗口的请求记录
- 统计当前数量 :计算窗口内的请求总数
- 阈值判断 :检查是否超过限制
- 记录新请求 :将当前请求加入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集群,提高可用性
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 Aromatic!



