业务限流的几种算法比较

759 阅读7分钟

固定窗口限流

  • 每个请求对应一个时间窗口(如每分钟最多 100 次)。
  • 到达窗口末尾时,计数清零或重新开始。
  • Redis 中 key 的过期时间控制窗口长度(如 expire 设置为 60 秒)。

场景模拟:

设你的限流策略是:每 60 秒最多允许 100 次访问。

时间点请求次数
00:00 - 00:59100 次
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:0011允许
00:305051允许
01:0050101不允许
-- 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

对比:

从代码实现来说,漏桶和令牌桶的行为差异其实很小,令牌桶控制了入桶的速度,漏桶控制了出桶的速度,桶容量控制并发。只要调整参数(容量、速率),两者可以达到相似的限流效果。以令牌桶代码为例,我只需要缩小桶容量,减缓令牌生成的速率,那么他就是一个平滑输出、没有突发的算法。它们的区别更多是算法概念上的。

  • 令牌桶算法更适用于那些允许一定程度突发流量,并希望总体速率受控的场景。例如网络带宽控制,允许用户偶尔发送大量数据,但在长时间尺度上总的数据传输速率仍受控。
  • 漏桶算法更适合对系统稳定性要求极高,不允许任何突发流量导致响应时间抖动的情况,比如实时通信系统、心跳监测等场景,它能够严格限制系统的处理速率,避免过载。

总结

场景固定窗口滑动窗口令牌桶漏桶
处理突发流量不擅长较好很好不擅长
控制处理速率不擅长不擅长可控严格
实现复杂度简单复杂中等中等