限流被讨论得很多,落到代码里常常只是 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. 自测
- 限 100 次/秒的固定窗口,在什么时刻会出现双倍突发?为什么?
- 滑动窗口日志与滑动窗口计数,内存差别在哪?
- 漏桶与令牌桶对"突发"的态度有何不同?
- 为什么
INCR后面跟EXPIRE是危险的? - 5 个实例、单机阈值 100 QPS,实际放行上限是多少?该怎么改?
- 限流、熔断、并发隔离分别控制什么?