固定窗口限流
- 每个请求对应一个时间窗口(如每分钟最多 100 次)。
- 到达窗口末尾时,计数清零或重新开始。
- Redis 中 key 的过期时间控制窗口长度(如 expire 设置为 60 秒)。
场景模拟:
设你的限流策略是:每 60 秒最多允许 100 次访问。
| 时间点 | 请求次数 |
|---|---|
| 00:00 - 00:59 | 100 次 |
| 01:00 - 01:00(刚好切换窗口) | 又可以接受 100 次 |
极端情况:用户可以在 00:59 ~ 01:00 这一秒之间发送 200 次请求,系统仍然会全部放行
这就是所谓的窗口边界问题,会导致:
突发流量绕过限流,实际吞吐量超过预期值。
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
HttpServletRequest request = ServletUtil.getRequest();
HttpServletResponse response = ServletUtil.getResponse();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method signatureMethod = signature.getMethod();
Limit limit = signatureMethod.getAnnotation(Limit.class);
LimitType limitType = limit.limitType();
String key = limit.key();
if (StringUtils.isEmpty(key)) {
if (limitType == LimitType.IP) {
key = IpUtils.getIpAddr(request);
} else {
key = signatureMethod.getName();
}
}
ImmutableList<Object> keys = ImmutableList.of(StringUtils.join(limit.prefix(), "_", key, "_", request.getRequestURI().replaceAll("/", "_")));
String luaScript = buildLuaScript();
RedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
Long count = redisTemplate.execute(redisScript, keys, limit.count(), limit.period());
if (null != count && count.intValue() <= limit.count()) {
log.debug("第{}次访问key为 {},描述为 [{}] 的接口", count, keys, limit.name());
return joinPoint.proceed();
} else {
// throw new BusinessException("访问次数受限制");
return R.error("访问次数受限制");
}
}
/**
* 限流脚本
*/
private String buildLuaScript() {
return """
local c
c = redis.call('get',KEYS[1])
if c and tonumber(c) > tonumber(ARGV[1]) then
return c;
end
c = redis.call('incr',KEYS[1])
if tonumber(c) == 1 then
redis.call('expire',KEYS[1],ARGV[2])
end
return c;""";
}
滑动窗口限流
- 维护一个“固定长度的时间窗口”,例如:60 秒。
- 每次请求到来时,记录其发生的时间戳。
- 清除窗口中过期的请求记录。
- 如果当前窗口内的请求数量小于阈值,则允许请求通过;否则拒绝。
场景模拟
假设限流规则为:每 60 秒最多 100 次请求
| 时间点 | 请求次数 | 当前窗口内总请求数 | 是否允许 |
|---|---|---|---|
| 00:00 | 1 | 1 | 允许 |
| 00:30 | 50 | 51 | 允许 |
| 01:00 | 50 | 101 | 不允许 |
-- KEYS[1]: 限流 key(如 user:123)
-- ARGV[1]: 最大请求数(如 100)
-- ARGV[2]: 时间窗口(秒)(如 60)
local now = redis.call('TIME')[1] -- 获取当前时间戳(秒级)
local windowStart = now - tonumber(ARGV[2]) -- 计算窗口起始时间
-- 获取当前窗口内的请求数
local count = redis.call('ZCARD', KEYS[1])
-- 删除窗口外的历史记录
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, windowStart)
-- 判断是否超过限制
if count >= tonumber(ARGV[1]) then
return 0 -- 拒绝
else
-- 添加当前时间戳到 ZSet 中
redis.call('ZADD', KEYS[1], now, now)
return 1 -- 允许
end
String luaScript = buildSlidingWindowScript();
RedisScript<Long> script = new DefaultRedisScript<>(luaScript, Long.class);
Long result = redisTemplate.execute(script, keys, "100", "60"); // 每 60 秒最多 100 次访问
if (result != null && result == 1) {
return joinPoint.proceed(); // 放行
} else {
return R.error("访问次数受限制");
}
| 对比项 | 固定窗口 | 滑动窗口 |
|---|---|---|
| 窗口划分方式 | 每整段时间重置(如 00:00~00:59) | 窗口随时间滑动(如从 00:30 到 01:30) |
| 边界效应 | 存在突增问题(两个窗口交界处可能双倍请求) | 无边界问题,流量更平滑 |
| 实现复杂度 | 简单 | 稍复杂 |
| 支持突发流量 | 不支持 | 支持有限突发 |
缺点:ZREMRANGEBYSCORE的时间复杂度是O(log N + M),log N:跳表中查找起始分数的位置;M:被删除元素的数量。 当数据量很大的情况下,M 会变得很大,导致每次执行这个命令都会消耗较多 CPU 和内存资源。
解决方案:
1.本地维护一个一个滑动窗口缓存如Guava Cache,但是这样功能的复杂性就上升了
2.使用令牌桶
令牌桶
- 维护一个固定的桶容量,如100;维护最后填充时间。
- 当一个请求过来,计算应该填充的令牌数(时间差*填充速率)
- 判断当前请求数满足桶大小,满足就放行并更新桶的令牌数和最后填充时间
场景模拟
1.正常情况:
- 初始状态桶是满的(100个令牌)
- 每秒补充10个新令牌
- 当请求到来时,如果桶中有令牌,就取出一个令牌并处理请求
2.突发流量:
- 突然有120个请求同时到达
- 前100个请求成功获取令牌并被处理
- 剩余20个请求因无法获取令牌而被拒绝
3.持续高流量:
- 如果每秒请求数量超过10个
- 一段时间后,桶中的令牌将被耗尽
- 超过令牌补充速度的请求都将被拒绝
-- Lua 脚本实现令牌桶算法
-- key 为 Redis 中用于存储桶的键
-- rate 为令牌填充速率(每秒填充的令牌数)
-- capacity 为桶的容量
-- now 为当前时间戳
-- permits 为本次请求需要的令牌数
local key = KEYS[1]
local rate = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local permits = tonumber(ARGV[4])
local bucket = redis.call('hmget', key, 'lastRefillTime', 'tokens')
local lastRefillTime = tonumber(bucket[1])
local tokens = tonumber(bucket[2])
if lastRefillTime == nil then
lastRefillTime = now
tokens = capacity
end
-- 计算自上次填充以来经过的时间
local delta = math.max(0, now - lastRefillTime)
-- 计算应该填充的令牌数
local refillTokens = math.floor(delta * rate)
tokens = math.min(capacity, tokens + refillTokens)
lastRefillTime = now
local enoughTokens = false
if tokens >= permits then
enoughTokens = true
tokens = tokens - permits
end
-- 更新桶的状态
redis.call('hmset', key, 'lastRefillTime', lastRefillTime, 'tokens', tokens)
-- 设置过期时间防止无限增长
redis.call('expire', key, math.ceil(capacity/rate)*2)
if enoughTokens then
return 1
else
return 0
end
漏桶
- 维护一个固定的桶容量,如100;维护最后漏出时间。
- 当一个请求过来,计算可以漏出的量(时间差*漏出速率)
- 判断当前请求数满足可漏出量,满足就放行并更新桶内水量和最后漏出时间
场景模拟
1.正常情况:
- 初始状态桶是空的
- 每秒处理10个请求
- 新请求按处理速率被处理
2.突发流量:
- 突然有120个请求同时到达
- 前100个请求被放入桶中
- 剩余20个请求因桶已满而被拒绝
- 系统以每秒10个的速度处理桶中的请求
3.持续高流量:
- 如果每秒请求数量超过10个
- 一段时间后,桶中的请求将积压
- 当积压达到桶容量后,新请求将被拒绝
-- KEYS[1]: 唯一标识符,如 "leaky_bucket:order_create"
-- ARGV[1]: 桶容量(capacity)
-- ARGV[2]: 漏水速率(每秒处理多少请求)
-- ARGV[3]: 当前时间戳(秒)
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local leak_rate = tonumber(ARGV[2])
local current_time = tonumber(ARGV[3])
-- 获取上次更新时间和当前水量
local last_update = redis.call("HGET", key, "last_update")
local water = redis.call("HGET", key, "water")
if not last_update then last_update = current_time end
if not water then water = 0 end
-- 计算过去的时间和应漏出的水量
local elapsed = current_time - last_update
local leaked = elapsed * leak_rate
water = math.max(water - leaked, 0)
-- 判断是否可以加入新请求
if water < capacity then
water = water + 1
redis.call("HSET", key, "water", water)
redis.call("HSET", key, "last_update", current_time)
return 1 -- 允许请求
else
return 0 -- 拒绝请求
end
对比:
从代码实现来说,漏桶和令牌桶的行为差异其实很小,令牌桶控制了入桶的速度,漏桶控制了出桶的速度,桶容量控制并发。只要调整参数(容量、速率),两者可以达到相似的限流效果。以令牌桶代码为例,我只需要缩小桶容量,减缓令牌生成的速率,那么他就是一个平滑输出、没有突发的算法。它们的区别更多是算法概念上的。
- 令牌桶算法更适用于那些允许一定程度突发流量,并希望总体速率受控的场景。例如网络带宽控制,允许用户偶尔发送大量数据,但在长时间尺度上总的数据传输速率仍受控。
- 漏桶算法更适合对系统稳定性要求极高,不允许任何突发流量导致响应时间抖动的情况,比如实时通信系统、心跳监测等场景,它能够严格限制系统的处理速率,避免过载。
总结
| 场景 | 固定窗口 | 滑动窗口 | 令牌桶 | 漏桶 |
|---|---|---|---|---|
| 处理突发流量 | 不擅长 | 较好 | 很好 | 不擅长 |
| 控制处理速率 | 不擅长 | 不擅长 | 可控 | 严格 |
| 实现复杂度 | 简单 | 复杂 | 中等 | 中等 |