最常用的限流器,一文讲透

固定窗口令牌桶漏桶,再到分布式限流。每种都给出:原理图解、完整实现代码、使用方式、核心优缺点,最后用一张表说清它们之间的本质区别。

🔵 固定窗口 🟣 滑动窗口 🩷 滑动日志 🟢 令牌桶 🟠 漏桶 🔴 分布式
📌

什么是限流器

限流(Rate Limiting)是一种通过主动拒绝或排队超出阈值的请求,来保护系统资源不被压垮的手段。

🎯 限流器要解决的核心问题

任何一个系统的资源都是有限的:CPU、内存、线程池、数据库连接、第三方 API 配额。限流器的本质就是「流量整形」——让进入系统的请求速率不超过系统能承受的速率,从而避免雪崩、击穿、级联故障。

🛡️ 保护系统

防止突发流量打满线程池 / 数据库连接池

⚖️ 公平分配

多租户 / 多用户之间公平地瓜分资源

💰 成本控制

限制对收费第三方 API 的调用次数

🚫 防刷防爬

抵御恶意刷接口、爬虫、DDoS 攻击

✅ 本文主线

接下来逐一拆解 5 种本地限流器 + 1 种分布式限流器。每种都按 「原理 → 代码实现 → 怎么用 → 优缺点」 四步展开,最后用一张对比表 + 决策树帮你选型。

🗺️

六种限流器总览

它们都回答同一个问题「现在还能不能放这个请求进来」,但底层数据结构、突发处理能力、精度、实现复杂度各不相同。

🕐 基于「时间窗口」 ① 固定窗口 最简单 / 有边界突刺 ② 滑动窗口 平滑 / 近似精确 ③ 滑动日志 最精确 / 最耗内存 🪣 基于「令牌/漏斗」 ④ 令牌桶 允许突发 / 最常用 ⑤ 漏桶 平滑输出 / 无突发 🌐 基于「共享存储」 ⑥ Redis + Lua

固定窗口计数器

把时间切成等长片段,每个片段内维护一个计数器,超过阈值就拒绝,片段结束重置。

📐 原理

将时间轴划分为固定大小的窗口(如 1 秒 / 1 分钟)。每个窗口起始时计数器归零,每来一个请求计数器 +1;当计数器 ≥ 阈值时,后续请求被拒绝,直到进入下一个窗口重新计数。实现最简单、内存占用最低,但存在窗口边界突刺问题。

t0t1t2 窗口① (限额100) 窗口② (限额100) 100 ✅ ⚠️ 临界 1 秒涌入 200!

💻 实现代码(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 个桶的计数总和。随着时间推进,窗口像「滑块」一样平滑移动,从而消除边界突刺。它是精度与成本的折中:桶越多越精确,内存略增。

滑动窗口 = 滚动的 N 个小桶 ← 当前窗口(最近 60s)→ 12 8 15 9 11 7 6 已丢弃 窗口左边界随 时间右移

💻 实现代码(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;
💡 桶数建议 = 窗口秒数(如 1 分钟用 60 桶),精度与固定窗口相当又平滑。桶数越多越精确但内存略增。

✅ 优点

  • 消除固定窗口的边界突刺
  • 实现仍较简单,内存 O(N) 可控
  • 精度可调(桶越多越准)

❌ 缺点

  • 桶内仍是固定窗口,极端边界仍有微小误差
  • 相比固定窗口略占内存
  • 分布式需额外同步各桶计数

滑动日志

精确记录每个请求的时间戳,判断时只统计「窗口内」的日志条数。这是理论上最精确的限流,但内存开销最大。

📐 原理

每来一个请求,就把当前时间戳追加进一个有序集合(队列 / 跳表 / Redis ZSet)。判断时,先删除所有早于 now - window 的过期日志,再看剩余条数是否超过阈值。由于始终基于「真实时间戳」统计,不存在窗口边界误差,精度 100%。代价是:每个请求都要存一条日志,高 QPS 下内存和时间开销显著。

滑动日志:真实时间戳集合 now-window now ← 有效窗口 → 已过期

💻 实现代码(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;
💡 分布式场景常用 Redis ZSet 实现(见第 ⑥ 节滑动日志 Lua),用时间戳作 score,ZREMRANGEBYSCORE 清理过期项,ZCARD 计数。

✅ 优点

  • 精度最高,无窗口边界误差
  • 天然支持任意时间窗口
  • 逻辑直观,便于理解

❌ 缺点

  • 内存 O(请求数),高 QPS 下巨大
  • 每次判断都要清理 + 计数,O(n) 开销
  • 分布式下存储成本高

令牌桶

系统以恒定速率往桶里放令牌,请求必须取到令牌才能执行。桶有容量上限,因此允许一定程度的突发流量——这是生产环境最常用、最推荐的限流算法。

📐 原理

有一个容量为 capacity 的桶,以 refillRate(个/秒)的恒定速率生成令牌(不超过容量)。每个请求来时尝试拿走 1 个令牌:有就放行,没有就拒绝(或排队)。当流量低时令牌会积攒,一旦突发流量到来,桶里积攒的令牌允许瞬间处理一波突发,随后速率被限制回 refillRate。Guava RateLimiter、Sentinel 都基于此思想。

令牌桶:恒定补充 + 允许突发 容量 = 5 速率 = 2 个/秒 补充 请求

💻 实现代码(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;
💡 生产推荐直接用成熟库:Java 用 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;  // 或返回友好提示「系统繁忙请稍后」
💡 漏桶的「队列版」会把超额请求暂存队列、按恒定速率消费,实现真正的流量整形(Traffic Shaping),但会增加请求延迟。

✅ 优点

  • 强制恒定输出速率,完美削峰
  • 对下游最友好,不会被突发打爆
  • 可平滑网络/接口流量

❌ 缺点

  • 不支持突发,流量高峰时大量请求被拒或排队
  • 队列版会增加延迟
  • 突发友好性差(对比令牌桶)

🆚 令牌桶 vs 漏桶 一句话区别

令牌桶:桶里攒令牌,请求「取」令牌 → 允许突发、然后限速
漏桶:桶里攒请求,恒定「漏」出去 → 强制匀速、削平突发。两者都能限平均速率,差别在「突发怎么处理」。

Redis + Lua 分布式限流

单机限流器在集群中无法统计全局流量。必须用共享存储 + 原子操作。Redis + Lua 脚本是最经典的分布式限流方案(对应面试常问的「集群限流」)。

📐 原理

多台应用实例共享一个 Redis。把计数器 / 时间戳存到 Redis,用 Lua 脚本保证「读-判断-写」的原子性(避免多实例并发导致的超发)。根据算法不同,可用 INCR + EXPIRE(固定窗口)或 ZSet(滑动日志 / 滑动窗口)。

实例 A 实例 B 实例 C Redis Lua 原子脚本 所有实例共用同一计数器 → 全局限流

💻 实现代码 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-Cell(CL.THROTTLE 模块,基于漏桶)、Sentinel 集群流控、或 Redisson RRateLimiter(令牌桶)。

✅ 优点

  • 支持集群全局精确限流
  • 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