什么是限流器
限流(Rate Limiting)是一种通过主动拒绝或排队超出阈值的请求,来保护系统资源不被压垮的手段。
🎯 限流器要解决的核心问题
任何一个系统的资源都是有限的:CPU、内存、线程池、数据库连接、第三方 API 配额。限流器的本质就是「流量整形」——让进入系统的请求速率不超过系统能承受的速率,从而避免雪崩、击穿、级联故障。
🛡️ 保护系统
防止突发流量打满线程池 / 数据库连接池
⚖️ 公平分配
多租户 / 多用户之间公平地瓜分资源
💰 成本控制
限制对收费第三方 API 的调用次数
🚫 防刷防爬
抵御恶意刷接口、爬虫、DDoS 攻击
✅ 本文主线
接下来逐一拆解 5 种本地限流器 + 1 种分布式限流器。每种都按 「原理 → 代码实现 → 怎么用 → 优缺点」 四步展开,最后用一张对比表 + 决策树帮你选型。
六种限流器总览
它们都回答同一个问题「现在还能不能放这个请求进来」,但底层数据结构、突发处理能力、精度、实现复杂度各不相同。
固定窗口计数器
把时间切成等长片段,每个片段内维护一个计数器,超过阈值就拒绝,片段结束重置。
📐 原理
将时间轴划分为固定大小的窗口(如 1 秒 / 1 分钟)。每个窗口起始时计数器归零,每来一个请求计数器 +1;当计数器 ≥ 阈值时,后续请求被拒绝,直到进入下一个窗口重新计数。实现最简单、内存占用最低,但存在窗口边界突刺问题。
💻 实现代码(Java,线程安全)
public class FixedWindowRateLimiter {
private final int limit; // 窗口内允许的最大请求数
private final long windowSizeMs; // 窗口大小(毫秒)
private int counter; // 当前窗口计数器
private long windowStart; // 当前窗口起始时间戳
public FixedWindowRateLimiter(int limit, long windowSizeMs) {
this.limit = limit;
this.windowSizeMs = windowSizeMs;
this.windowStart = System.currentTimeMillis();
this.counter = 0;
}
/** 尝试获取一个配额,成功返回 true */
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 1. 若已跨入新窗口,则重置计数器与窗口起点
if (now - windowStart >= windowSizeMs) {
windowStart = now;
counter = 0;
}
// 2. 未超阈值则放行并计数
if (counter < limit) {
counter++;
return true;
}
// 3. 超过阈值,拒绝
return false;
}
}
🔧 怎么使用
// 限制:每 1 秒最多 100 个请求
FixedWindowRateLimiter limiter = new FixedWindowRateLimiter(100, 1000);
void handleRequest(Request req) {
if (limiter.tryAcquire()) {
doBusiness(req); // 正常处理
} else {
return HttpStatus.TOO_MANY_REQUESTS; // 429 拒绝
}
}
synchronized 保证单 JVM 内线程安全;集群环境需替换为 Redis 等共享存储(见第 ⑥ 节)。✅ 优点
- 实现极简,几行代码搞定
- 内存占用 O(1),只需一个整数
- 性能极高,无额外数据结构
❌ 缺点
- 边界突刺:两个窗口交界可瞬时 2 倍流量
- 无法应对窗口内流量倾斜
- 精度较低,仅适合粗粒度限流
滑动窗口计数器
把大窗口拆成若干小桶(bucket),随时间滚动丢弃过期桶,用桶之和近似「任意时刻向前看一个窗口」的精确计数。
📐 原理
固定窗口的问题是边界突变。滑动窗口把一个大窗口(如 60 秒)细分为 N 个小桶(如 60 个 1 秒桶),只保留最近 N 个桶的计数总和。随着时间推进,窗口像「滑块」一样平滑移动,从而消除边界突刺。它是精度与成本的折中:桶越多越精确,内存略增。
💻 实现代码(Java,滚动桶)
public class SlidingWindowRateLimiter {
private final int limit; // 窗口内最大请求数
private final long windowMs; // 总窗口大小(毫秒)
private final int bucketCount; // 小桶数量
private final long bucketMs; // 每个小桶的时间跨度
private final int[] buckets; // 各桶计数
private int idx; // 当前写入桶下标
private long windowStart; // 当前窗口起点
private int total; // 窗口内总计数(缓存)
public SlidingWindowRateLimiter(int limit, long windowMs, int bucketCount) {
this.limit = limit;
this.windowMs = windowMs;
this.bucketCount = bucketCount;
this.bucketMs = windowMs / bucketCount;
this.buckets = new int[bucketCount];
this.windowStart = System.currentTimeMillis();
this.idx = 0; this.total = 0;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
long elapsed = now - windowStart;
// 1. 推进窗口:把过期的桶清零并从 total 扣除
while (elapsed >= bucketMs) {
idx = (idx + 1) % bucketCount;
total -= buckets[idx];
buckets[idx] = 0;
windowStart += bucketMs;
elapsed -= bucketMs;
}
// 2. 窗口内未满则放行并计数
if (total < limit) {
buckets[idx]++;
total++;
return true;
}
return false; // 超限拒绝
}
}
🔧 怎么使用
// 限制:60 秒内最多 1000 次,拆成 60 个 1 秒桶
SlidingWindowRateLimiter limiter =
new SlidingWindowRateLimiter(1000, 60_000, 60);
if (limiter.tryAcquire()) handle(req);
else return 429;
✅ 优点
- 消除固定窗口的边界突刺
- 实现仍较简单,内存 O(N) 可控
- 精度可调(桶越多越准)
❌ 缺点
- 桶内仍是固定窗口,极端边界仍有微小误差
- 相比固定窗口略占内存
- 分布式需额外同步各桶计数
滑动日志
精确记录每个请求的时间戳,判断时只统计「窗口内」的日志条数。这是理论上最精确的限流,但内存开销最大。
📐 原理
每来一个请求,就把当前时间戳追加进一个有序集合(队列 / 跳表 / Redis ZSet)。判断时,先删除所有早于 now - window 的过期日志,再看剩余条数是否超过阈值。由于始终基于「真实时间戳」统计,不存在窗口边界误差,精度 100%。代价是:每个请求都要存一条日志,高 QPS 下内存和时间开销显著。
💻 实现代码(Java,基于 Queue)
public class SlidingLogRateLimiter {
private final int limit; // 窗口内最大请求数
private final long windowMs; // 窗口大小(毫秒)
private final Queue<Long> logs = new LinkedList<>();
public SlidingLogRateLimiter(int limit, long windowMs) {
this.limit = limit;
this.windowMs = windowMs;
}
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
// 1. 移除窗口外的过期时间戳
while (!logs.isEmpty() && now - logs.peek() >= windowMs) {
logs.poll();
}
// 2. 窗口内未满则记录并放行
if (logs.size() < limit) {
logs.offer(now);
return true;
}
return false; // 超限拒绝
}
}
🔧 怎么使用
// 限制:每 60 秒最多 1000 次(精确计数每一个请求)
SlidingLogRateLimiter limiter = new SlidingLogRateLimiter(1000, 60_000);
if (limiter.tryAcquire()) handle(req);
else return 429;
ZREMRANGEBYSCORE 清理过期项,ZCARD 计数。✅ 优点
- 精度最高,无窗口边界误差
- 天然支持任意时间窗口
- 逻辑直观,便于理解
❌ 缺点
- 内存 O(请求数),高 QPS 下巨大
- 每次判断都要清理 + 计数,O(n) 开销
- 分布式下存储成本高
令牌桶
系统以恒定速率往桶里放令牌,请求必须取到令牌才能执行。桶有容量上限,因此允许一定程度的突发流量——这是生产环境最常用、最推荐的限流算法。
📐 原理
有一个容量为 capacity 的桶,以 refillRate(个/秒)的恒定速率生成令牌(不超过容量)。每个请求来时尝试拿走 1 个令牌:有就放行,没有就拒绝(或排队)。当流量低时令牌会积攒,一旦突发流量到来,桶里积攒的令牌允许瞬间处理一波突发,随后速率被限制回 refillRate。Guava RateLimiter、Sentinel 都基于此思想。
💻 实现代码(Java,支持预热/突发)
public class TokenBucketRateLimiter {
private final long capacity; // 桶容量(最大突发令牌数)
private final double refillRate; // 补充速率(个/秒)
private double tokens; // 当前令牌数
private long lastRefill; // 上次补充时间
public TokenBucketRateLimiter(long capacity, double refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.tokens = capacity; // 初始充满,允许启动突发
this.lastRefill = System.currentTimeMillis();
}
public synchronized boolean tryAcquire(int n) {
refill(); // 先按时间补充令牌
if (tokens >= n) {
tokens -= n; // 取走 n 个令牌
return true;
}
return false; // 令牌不足,拒绝
}
private void refill() {
long now = System.currentTimeMillis();
double elapsedSec = (now - lastRefill) / 1000.0;
tokens = Math.min(capacity, tokens + elapsedSec * refillRate);
lastRefill = now;
}
}
🔧 怎么使用
// 容量 10(允许瞬间突发 10 个),每秒补充 2 个
TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(10, 2.0);
if (limiter.tryAcquire(1)) handle(req);
else return 429;
Guava RateLimiter(支持 acquire() 阻塞等待 / tryAcquire() 非阻塞);分布式用 Sentinel / Redis 令牌桶。✅ 优点
- 允许可控的突发流量(用户体验好)
- 速率平滑,O(1) 计算,性能好
- 实现简单,生态成熟(Guava/Sentinel)
❌ 缺点
- 突发可能瞬间打满下游(需配合容量规划)
- 单机版本无全局视角,需分布式改造
漏桶
请求像水一样流入桶中,桶以恒定速率从底部漏出(处理)。漏桶强制输出速率恒定,不允许突发,适合需要严格平滑流量的场景。
📐 原理
把请求看作流入桶里的水,桶底有个小孔以恒定速率 leakRate 漏出(即被处理)。若请求流入速度超过漏出速度,水(请求)会在桶里堆积;当水位超过桶容量 capacity 时,多余的请求被直接拒绝。与令牌桶相反:漏桶「削峰填谷」、强制恒定输出,不提供突发能力,因此能更好地保护下游。
💻 实现代码(Java,漏桶计量版)
public class LeakyBucketRateLimiter {
private final int capacity; // 桶容量(最大排队请求数)
private final double leakRate; // 漏出速率(个/秒)
private double water; // 当前水量(积压请求数)
private long lastLeak; // 上次漏水时间
public LeakyBucketRateLimiter(int capacity, double leakRate) {
this.capacity = capacity;
this.leakRate = leakRate;
this.water = 0;
this.lastLeak = System.currentTimeMillis();
}
public synchronized boolean tryAcquire(int n) {
leak(); // 先按时间漏水
if (water + n <= capacity) { // 加水后不超过容量则放行
water += n;
return true;
}
return false; // 桶满,拒绝
}
private void leak() {
long now = System.currentTimeMillis();
double elapsedSec = (now - lastLeak) / 1000.0;
water = Math.max(0, water - elapsedSec * leakRate);
lastLeak = now;
}
}
🔧 怎么使用
// 容量 100(最多积压 100 个),每秒恒定处理 20 个
LeakyBucketRateLimiter limiter = new LeakyBucketRateLimiter(100, 20.0);
if (limiter.tryAcquire(1)) handle(req);
else return 429; // 或返回友好提示「系统繁忙请稍后」
✅ 优点
- 强制恒定输出速率,完美削峰
- 对下游最友好,不会被突发打爆
- 可平滑网络/接口流量
❌ 缺点
- 不支持突发,流量高峰时大量请求被拒或排队
- 队列版会增加延迟
- 突发友好性差(对比令牌桶)
🆚 令牌桶 vs 漏桶 一句话区别
令牌桶:桶里攒令牌,请求「取」令牌 → 允许突发、然后限速。
漏桶:桶里攒请求,恒定「漏」出去 → 强制匀速、削平突发。两者都能限平均速率,差别在「突发怎么处理」。
Redis + Lua 分布式限流
单机限流器在集群中无法统计全局流量。必须用共享存储 + 原子操作。Redis + Lua 脚本是最经典的分布式限流方案(对应面试常问的「集群限流」)。
📐 原理
多台应用实例共享一个 Redis。把计数器 / 时间戳存到 Redis,用 Lua 脚本保证「读-判断-写」的原子性(避免多实例并发导致的超发)。根据算法不同,可用 INCR + EXPIRE(固定窗口)或 ZSet(滑动日志 / 滑动窗口)。
💻 实现代码 A:固定窗口分布式(INCR + Lua)
-- KEYS[1] = 限流 key ARGV[1] = 阈值 ARGV[2] = 窗口毫秒
local current = redis.call('GET', KEYS[1])
if current and tonumber(current) >= tonumber(ARGV[1]) then
return 0 -- 已超阈值,拒绝
end
local new = redis.call('INCR', KEYS[1])
if new == 1 then
redis.call('PEXPIRE', KEYS[1], ARGV[2]) -- 首请求设置窗口过期
end
return 1 -- 放行
💻 实现代码 B:滑动日志分布式(ZSet + Lua,最精确)
-- KEYS[1] = 限流 key
-- ARGV[1] = 当前时间戳(ms) ARGV[2] = 窗口(ms) ARGV[3] = 阈值
-- 1. 删除窗口外的过期请求
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2])
-- 2. 统计窗口内剩余数量
local count = redis.call('ZCARD', KEYS[1])
if count >= tonumber(ARGV[3]) then
return 0 -- 超限拒绝
end
-- 3. 记录本次请求(用唯一 member 防覆盖)
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[1] .. '-' .. math.random())
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1 -- 放行
🔧 怎么使用
// Java 侧:用 RedisTemplate 执行上面的 Lua 脚本
DefaultRedisScript<Long> script = new DefaultRedisScript<>(luaText, Long.class);
Long allowed = redis.execute(script,
Collections.singletonList("rate:order:" + userId),
"1000", "60000"); // 阈值 1000,窗口 60s
if (allowed == 1) handle(req); else return 429;
✅ 优点
- 支持集群全局精确限流
- Lua 保证原子性,无并发超发
- 方案成熟,生态完善
❌ 缺点
- 每次请求多一次 Redis 网络往返(延迟 + 成本)
- Redis 本身需高可用,否则成单点
- 实现比单机复杂
七维度全面对比
把六种限流器放在同一张表里,差异一目了然。
| 维度 | 固定窗口 | 滑动窗口 | 滑动日志 | 令牌桶 | 漏桶 | Redis+Lua |
|---|---|---|---|---|---|---|
| 实现难度 | ⭐ 极简 | ⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
| 空间复杂度 | O(1) | O(N)桶 | O(请求数) | O(1) | O(1) | O(请求数) |
| 时间复杂度 | O(1) | O(1) | O(n)清理 | O(1) | O(1) | O(1)~O(n) |
| 限流精度 | 低 | 中 | 最高 | 高 | 高 | 取决于算法 |
| 是否允许突发 | 会(边界2倍) | 部分 | 否 | 是✅ | 否❌ | 取决于算法 |
| 流量平滑度 | 差 | 较好 | 最好 | 较好 | 最好✅ | 较好 |
| 边界突刺 | 有❌ | 无 | 无 | 无 | 无 | 无 |
| 适用环境 | 单机粗粒度 | 单机较精细 | 精确/低频 | 通用/突发 | 严格整形 | 集群✅ |
🔑 最核心的三组区别
① 精度 vs 成本:滑动日志最精确但最费内存,固定窗口最省但最不准。
② 突发 vs 平滑:令牌桶允许突发,漏桶强制平滑——这取决于你要保护谁。
③ 单机 vs 集群:前五种默认单机,集群必须用 Redis+Lua 等共享存储方案。
选型决策树
按需求一步步选,不再纠结。
Q1:是否需要集群全局限流?
是 → Redis + Lua(选 ZSet 滑动日志做精确限流,或 Redisson 令牌桶)。
否 → 进入下一步选单机算法。
Q2:是否要允许突发流量(用户体验优先)?
是 → 令牌桶(Guava / Sentinel 直接可用)。
否,要强制平滑输出(下游保护优先)→ 漏桶。
Q3:对精度要求高吗?
极高 + 低频 → 滑动日志。
一般(能接受微小误差)→ 滑动窗口。
粗粒度、极简 → 固定窗口。
🏆 默认推荐
绝大多数业务场景:单机用令牌桶(Guava RateLimiter),集群用 Redis 令牌桶 / Sentinel。它兼顾突发友好、性能高、生态成熟。除非你需要「严格匀速」(选漏桶)或「绝对精确」(选滑动日志)。
生产使用建议
🌐 别只靠单机
集群环境务必用分布式限流,否则每台机器各自为战,全局仍会超发(详见上一篇《限流为什么不能只在网关层做》)。
🎯 阈值靠压测
限流阈值不是拍脑袋,来自容量模型:接口并发力 + 下游承受力 + 拒绝后体验。
🔄 多维限流
按用户 / IP / 接口分别限流,核心接口(支付/下单)用独立更严格阈值。
📉 优雅拒绝
返回 429 + Retry-After 头,或排队 / 降级兜底,别直接抛错,保证体验。
📊 可观测
埋点限流触发次数、拒绝率,配告警,避免限流误伤正常流量而不自知。
🧪 预热保护
冷启动服务用令牌桶「预热模式」,逐步放开速率,避免一上线就被打挂。
📚 延伸阅读
想搞清「为什么不能只在网关做限流」、内部服务如何做四层限流,参见同目录文章: 《限流为什么不能只在网关层做》。 高可用总入口:high-availability/index.html。