2857 字
约 9 分钟
13
S27-X15 · 限流四种算法:计数器、滑动窗口、漏桶、令牌桶

限流被讨论得很多,落到代码里常常只是 if (count++ > 100) reject。单机、低并发、时间精度不重要时能用;一旦跨实例、跨时间边界,行为很难向投诉的人解释。

四个算法是同一问题的不同侧面。先问清问题,再选算法。


1. 限流先回答三个问题

问题 可选答案 影响
限谁 接口 / 用户 / IP / 租户 / 全局 决定 key 的维度与基数
限多少 每秒请求数 / 每分钟 / 并发数 / 流量字节 决定窗口与单位
超了怎么办 拒绝 / 排队 / 降级 / 只记录 决定返回码与用户体验

三个问题不回答,选型没有意义。尤其第一个:按 IP 限与按用户限的抗压能力完全不同——NAT 后面几十个用户共用一个出口 IP,按 IP 限会误伤;按用户限又要防刷号。


2. 固定窗口计数器:最简单,但有临界双倍

按"每秒一个桶"计数,桶满即拒。实现只要一个计数器加过期时间。

// 单机版:每秒一个窗口
private final AtomicInteger counter = new AtomicInteger();
private volatile long windowStart = System.currentTimeMillis();

public boolean tryAcquire(int limit) {
    long now = System.currentTimeMillis();
    long ws = now - (now % 1000);                 // 当前秒起点
    if (ws != windowStart) { windowStart = ws; counter.set(0); }
    return counter.incrementAndGet() <= limit;
}

问题出在窗口边界。设限 100 次/秒:

时刻 落在哪个窗口 已用次数 结果
00:00.900 第 0 秒 第 100 次 放行(用满)
00:01.001 第 1 秒 第 1 次 放行(新窗口)
00:01.050 第 1 秒 第 100 次 放行(用满)

在 0.9 秒到 1.05 秒这 150 毫秒内实际通过了 200 次——瞬时速率是阈值的两倍。这不是实现 bug,而是固定窗口的固有特性;窗口越长(如"每分钟 1000 次"),边界突发越难看。


3. 滑动窗口日志:精确,但内存随流量线性增长

记录每个请求的时间戳,判断"最近一个窗口内的请求数"。它没有边界问题,因为窗口是跟着当前时刻滑动的。

// 滑动窗口日志:每个 key 存一个时间戳队列
Deque<Long> log = logs.computeIfAbsent(key, k -> new ArrayDeque<>());
long now = System.currentTimeMillis();
long cutoff = now - windowMs;

while (!log.isEmpty() && log.peekFirst() < cutoff) log.pollFirst();   // 淘汰过期
if (log.size() >= limit) return false;
log.addLast(now);
return true;
优点 代价
可精确计算"还要等多久" 高 QPS + 多 key 时内存不可控
逻辑直白 需要清理空队列,否则 key 泄漏

按用户限流、限值"每分钟 1000 次"、10 万活跃用户就是 1 亿个时间戳,规模上去后不可行。


4. 滑动窗口计数:用两个窗口加权近似

不存时间戳,只存"当前窗口计数"和"上一窗口计数",用上一窗口的重叠比例做加权:

// 近似:当前窗口计数 + 上一窗口计数 × 重叠比例
double estimate = prevCount * overlapRatio + currCount;
if (estimate >= limit) return false;
currCount++;
项 含义
currCount 当前整窗口已用次数
prevCount 上一窗口的总次数
overlapRatio 当前时刻与上一窗口的重叠比例,0→1

它把"精确但贵"换成"近似但廉价":内存从 O(limit) 降到 O(1),同时消掉了最刺眼的双倍突发。代价是估算有偏差——流量分布不均时,放行量会略偏离阈值。对多数接口可以接受。


5. 漏桶:恒定流出,用于削峰

漏桶把请求当水倒进桶里,桶底按固定速率漏水。桶满则拒绝(或排队)。

特性 说明
流出速率 恒定,与流入无关
桶容量 能吸收多少突发
队列长度 有上限,否则等于无限缓冲

漏桶的关键是"输出平滑":不管上游多突,下游看到的永远是恒定速率。代价是延迟——等待超过客户端超时时间就成了无效工作。


6. 令牌桶:允许突发,最常用

反过来:桶里按固定速率放入令牌,请求到来时取一个令牌,取不到则拒绝。桶容量决定了"能攒多少突发额度"。

特性 说明
速率 恒定发放令牌,长期平均速率 = 阈值
突发 桶内积攒的令牌可一次性消耗,容忍突发
超限行为 无令牌即拒(或短暂等待)
初始状态 通常预填满,允许冷启动突发

令牌桶与漏桶的差别就是"攒不攒":漏桶平滑输出不攒,令牌桶攒额度、允许短时突发。真实流量有峰谷,令牌桶更贴合。

// 惰性计算令牌,不需要后台线程
public boolean tryAcquire(int permits) {
    long now = System.nanoTime();
    synchronized (this) {
        double add = (now - lastRefill) / 1e9 * ratePerSecond;
        tokens = Math.min(capacity, tokens + add);
        lastRefill = now;
        if (tokens >= permits) { tokens -= permits; return true; }
        return false;
    }
}

7. 四种算法对照

算法 精度 内存 突发容忍 实现复杂度 适合
固定窗口计数器 低(边界双倍) O(1) 差 极低 粗粒度、可容忍突刺
滑动窗口日志 高 O(limit) 精确控制 中 低 QPS、强精确要求
滑动窗口计数 中(近似) O(1) 较好 中 高 QPS 通用场景
漏桶 高(速率恒定) O(1)+队列 不鼓励突发 中 保护下游、平滑输出
令牌桶 高 O(1) 好 中 对外 API、通用首选

一句话选型:对外接口用令牌桶,保护下游用漏桶,精度要求极高且量小用滑动日志,其余用滑动窗口计数。


8. 分布式限流:Redis + Lua 保证原子性

多实例部署时,计数器必须在共享存储里。INCR 与 EXPIRE 拆成两条命令是非原子的——中间进程崩溃或网络抖动,key 就会永久存在,等于把用户永久封禁。

# 错误写法:两条命令不是原子操作
INCR  rate:user:1001
EXPIRE rate:user:1001 60
-- 正确写法:Lua 内原子执行
-- KEYS[1]=key  ARGV[1]=limit  ARGV[2]=windowSeconds
local current = redis.call('INCR', KEYS[1])
if current == 1 then
  redis.call('EXPIRE', KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
  return 0
end
return 1
-- 令牌桶:hash 存 tokens 与 last_refill,惰性补充后判断
-- KEYS[1]=key  ARGV[1]=rate  ARGV[2]=capacity  ARGV[3]=now_ms
local k = KEYS[1]
local rate = tonumber(ARGV[1])
local cap  = tonumber(ARGV[2])
local now  = tonumber(ARGV[3])

local data = redis.call('HMGET', k, 'tokens', 'ts')
local tokens = tonumber(data[1]) or cap
local ts     = tonumber(data[2]) or now

tokens = math.min(cap, tokens + (now - ts) / 1000.0 * rate)
local allowed = 0
if tokens >= 1 then tokens = tokens - 1; allowed = 1 end

redis.call('HSET', k, 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', k, math.ceil(cap / rate * 1000) + 1000)
return allowed

注意:now 由调用方传入会有时钟漂移,最好用 Redis 侧 TIME;脚本里别写大循环;单 key QPS 极高会成热点,需要分片或本地预取额度。


9. 限流的粒度与组合

粒度 key 形态 基数 注意
接口 rate:api:{path} 小 保护后端,不区分调用方
用户 rate:user:{uid} 大 防刷号;未登录无 uid
IP rate:ip:{ip} 中大 NAT 误伤,IPv6 基数膨胀
租户 rate:tenant:{tid} 小 多租户产品的基本公平性
全局 rate:global 1 单 key 热点,配本地令牌桶

实践是分层组合:网关按 IP 粗限(挡扫描)、业务按用户限(挡刷单)、关键下游按全局限(保命)。别指望一层解决所有问题。


10. 超限之后:拒绝、排队,还是降级

策略 用户感受 适用
直接拒绝 立刻收到 429 保护性限流
短暂排队 稍慢但成功 下游能扛、有超时预算
降级返回缓存 数据略旧但可用 读接口
只记录不拒绝 无感 灰度期观察真实流量
HTTP/1.1 429 Too Many Requests
Retry-After: 3
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0

{"code":"RATE_LIMITED","message":"请求过于频繁,请 3 秒后重试"}

Retry-After 是让客户端知道"等多久"的唯一标准手段;没有它,客户端只能盲目重试,反而放大压力。限流响应要带它,且与真实窗口剩余时间一致。


11. 与熔断、并发的区别

机制 控制什么 触发依据 典型实现
限流 速率(每秒多少请求) 预设阈值 令牌桶、滑动窗口
熔断 失败率(错误占比) 运行期统计 Resilience4j、Sentinel
并发隔离 同时在途数 线程/信号量占用 线程池、Bulkhead
超时 单次调用耗时 预设时长 客户端超时配置

三者互补:限流防"量太大",熔断防"下游坏了还一直打",并发隔离防"慢调用占满线程"。阈值按单机设、集群却多实例最常见——单机 100 QPS × 5 实例,实际放行 500 QPS。


12. 排错:六个高频坑

现象 原因 处理
用户被永久限流 INCR 与 EXPIRE 非原子,key 无 TTL 用 Lua 合并;巡检无 TTL 的 rate key
集群总量远超阈值 阈值按单机设,实例数相乘 阈值改用全局限流(Redis 计数)
投诉"我没超却被拒" 按 IP 限,NAT 后面共用出口 改为按用户/租户,或放宽 IP 阈值
限流日志缺失,无法解释 只计数不打点 拒绝时记录 key、当前值、阈值

13. 边界:什么时候不需要限流

场景 更合适 理由
内部可信调用、量稳定 并发隔离 + 超时 速率不是瓶颈
下游明确扛不住 熔断 + 降级 限流不解决"已经坏掉"
单用户低频操作 幂等 + 去重 重复提交才是问题

一句话:限流是"预防性"的,熔断是"响应性"的。 先想清楚要防的是"量"还是"坏"。


14. 自测

  1. 限 100 次/秒的固定窗口,在什么时刻会出现双倍突发?为什么?
  2. 滑动窗口日志与滑动窗口计数,内存差别在哪?
  3. 漏桶与令牌桶对"突发"的态度有何不同?
  4. 为什么 INCR 后面跟 EXPIRE 是危险的?
  5. 5 个实例、单机阈值 100 QPS,实际放行上限是多少?该怎么改?
  6. 限流、熔断、并发隔离分别控制什么?
S27-X15 · 限流四种算法:计数器、滑动窗口、漏桶、令牌桶
https://www.clxhxhhr.top/posts/5793/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。