Featured image of post 限流算法完全对比:固定窗口、滑动窗口、漏桶与令牌桶

限流算法完全对比:固定窗口、滑动窗口、漏桶与令牌桶

一文掌握 4 种主流限流算法的原理、优缺点、代码实现与工业级选型

在高并发系统中,限流(Rate Limiting)是保护下游服务不被突发流量打挂的核心手段。限流算法家族里,最常见的 4 个成员是:固定窗口计数器滑动窗口计数器漏桶算法令牌桶算法。它们各有取舍,分别适用于不同的业务场景。这篇文章会带你用 4 张原理图 + 代码实现 + 选型建议,把这 4 个算法彻底讲透。

为什么学限流? 限流是后端面试几乎必考的题目(无论是社招还是校招),因为它同时考察你算法理解、并发编程、系统设计三大能力。更重要的是,主流的微服务网关(Spring Cloud Gateway / Sentinel / Kong / Envoy)底层都跑着这些算法。

一、为什么需要限流?

在动手学算法之前,先搞清楚"限流到底在防什么":

风险说明
雪崩一个慢下游拖垮整个调用链
资源耗尽数据库连接池/线程池被打满
恶意流量爬虫/羊毛党/CC 攻击
突发流量秒杀、热点事件、明星离婚导致 10x 流量尖刺

限流的目标是:在系统容量范围内接待尽可能多的"好"请求,把"坏"请求挡在外面

二、固定窗口计数器(Fixed Window Counter)

2.1 算法原理

固定窗口算法

图源说明:上图来自原 限流/算法.md 文档,展示了固定窗口计数器把时间轴切成等长窗口(图中每个矩形就是一个窗口),每个窗口内独立计数的结构。

核心思想

  1. 将时间划分为多个等长的窗口(例如 1 秒一个窗口)
  2. 在每个窗口内每来一个请求就将计数器加一
  3. 如果计数器超过了限制数量,则本窗口内所有的请求都被丢弃
  4. 当时间到达下一个窗口时,计数器重置

2.2 致命缺陷:边界双倍突发

固定窗口是最简单的算法,但有一个著名的"边界问题"——它可能让通过请求量允许为限制的两倍

固定窗口边界问题

图源说明:上图演示了"边界双倍突发"问题。

例子:限制 1 秒内最多通过 5 个请求。

  • 第 1 个窗口的最后半秒:通过了 5 个请求
  • 第 2 个窗口的前半秒:又通过了 5 个请求

看起来是在 1 秒内通过了 10 个请求——两倍于限制!系统在临界点承受了远超设计容量的压力。

2.3 代码实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import time

class FixedWindowRateLimiter:
    """固定窗口计数器:实现最简单,但有边界双倍突发的风险"""
    def __init__(self, limit: int, window_seconds: int):
        self.limit = limit
        self.window = window_seconds
        self.count = 0
        self.window_start = int(time.time())

    def allow(self) -> bool:
        now = int(time.time())
        if now - self.window_start >= self.window:
            # 进入新窗口
            self.window_start = now
            self.count = 0
        if self.count < self.limit:
            self.count += 1
            return True
        return False

2.4 优缺点

优点缺点
实现最简单(一个计数器 + 一个时间戳)边界双倍突发问题
内存占用最小窗口切换时可能"突然丢一半请求"
单机/分布式都好实现不适合"严格平滑限流"的场景

三、滑动窗口计数器(Sliding Window Counter)

3.1 算法原理

滑动窗口算法

图源说明:上图展示了滑动窗口计数器把每个窗口再细分成多个小时间区间,并按时间"滑动"的机制。

核心思想

  1. 将时间划分为多个小时间区间(如 1 秒的窗口再分成 10 个 100ms 的子区间)
  2. 在每个子区间内每来一个请求就将该子区间的计数器加一
  3. 维持一个时间窗口,占据多个子区间
  4. 每经过一个子区间的时间,则抛弃最老的一个子区间,并纳入最新的一个子区间
  5. 如果当前窗口内所有子区间的请求计数总和超过了限制数量,则本窗口内所有的请求都被丢弃

3.2 为什么解决了边界问题?

滑动窗口通过"细分 + 滑动"避免了固定窗口的双倍突发——窗口的边界是连续滑动的,不会突然从一个干净状态跳到满载状态。

3.3 精度与空间的权衡

子区间数量精度内存占用(每个用户)
1(=固定窗口)最粗O(1)
10O(10)
60O(60)
1000很高O(1000)

子区间越多,限流越精准,但每个用户/接口的内存占用越大。Cloudflare 公开的方案里就用 60 个子区间的滑动窗口。

3.4 代码实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import time
from collections import deque

class SlidingWindowRateLimiter:
    """滑动窗口计数器:精度高,能避免固定窗口的边界双倍突发"""
    def __init__(self, limit: int, window_seconds: int):
        self.limit = limit
        self.window = window_seconds
        # 存 (时间戳, ),每次清理掉超过 window 的旧时间戳
        self.timestamps: deque[float] = deque()

    def allow(self) -> bool:
        now = time.time()
        # 弹出过期的时间戳
        while self.timestamps and self.timestamps[0] <= now - self.window:
            self.timestamps.popleft()
        if len(self.timestamps) < self.limit:
            self.timestamps.append(now)
            return True
        return False

3.5 优缺点

优点缺点
解决了固定窗口的边界问题内存占用更大
精度可调实现稍复杂
适合 API 限流、爬虫限频单机原子性需要锁/原子操作

四、漏桶算法(Leaky Bucket)

4.1 算法原理

漏桶算法

图源说明:上图展示了漏桶算法把请求视作"水滴"放入"漏桶",再以固定速率向外"漏"出请求的机制。

核心思想

  1. 将每个请求视作"水滴"放入"漏桶"进行存储
  2. “漏桶"以固定速率向外"漏"出请求来执行
  3. 如果"漏桶"空了则停止"漏水”
  4. 如果"漏桶"满了则多余的"水滴"会被直接丢弃

4.2 核心特性:强制平滑输出

漏桶最大的特点是输出速率恒定——无论进来的流量多不均匀,出去的速率永远是 leak_rate。这非常适合"下游服务处理能力固定“的场景(如调用银行接口、调用第三方 API)。

4.3 缺陷:无法应对突发

漏桶的缺陷也很明显——当短时间内有大量的突发请求时,即便此时服务器没有任何负载,每个请求也都得在队列中等待一段时间才能被响应。换句话说:它把"突发"给"抹平"了

4.4 代码实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import time
from collections import deque

class LeakyBucketRateLimiter:
    """漏桶算法:强制平滑输出,适合下游处理能力固定的场景"""
    def __init__(self, capacity: int, leak_rate_per_sec: float):
        """
        :param capacity: 桶容量
        :param leak_rate_per_sec: 每秒漏出的水滴数
        """
        self.capacity = capacity
        self.leak_rate = leak_rate_per_sec
        self.queue: deque[float] = deque()
        self.last_leak_time = time.time()

    def _leak(self):
        """把超过当前时间该漏出的水滴都漏掉"""
        now = time.time()
        elapsed = now - self.last_leak_time
        leaked = int(elapsed * self.leak_rate)
        if leaked > 0:
            for _ in range(min(leaked, len(self.queue))):
                self.queue.popleft()
            self.last_leak_time = now

    def allow(self) -> bool:
        self._leak()
        if len(self.queue) < self.capacity:
            self.queue.append(time.time())
            return True
        return False

4.5 优缺点

优点缺点
输出绝对平滑,保护下游抹掉突发——空闲时也无法利用富余容量
队列机制天然支持"等待"语义需要额外的队列/定时器
实现直观延迟 = 队列长度 / 漏出速率

五、令牌桶算法(Token Bucket)

5.1 算法原理

令牌桶算法

图源说明:上图展示了令牌桶算法以固定速率生成令牌、请求来临时取令牌的工作机制。

核心思想

  1. 令牌以固定速率生成
  2. 生成的令牌放入令牌桶中存放,如果令牌桶满了则多余的令牌会直接丢弃
  3. 当请求到达时,会尝试从令牌桶中取令牌
  4. 取到了令牌的请求可以执行
  5. 如果桶空了,那么尝试取令牌的请求会被直接丢弃

5.2 为什么是"工业界默认”?

令牌桶既能够将所有的请求平均分布到时间区间内,又能接受服务器能够承受范围内的突发请求——这两点恰好是漏桶和固定窗口都做不到的:

  • 平均限流:长期平均速率 = 令牌生成速率
  • 允许突发:桶里积攒的令牌可以一次性被突发请求用完
  • 平滑降级:桶空了就拒绝,不会让下游被打挂

因此 Guava RateLimiter、Nginx limit_req、AWS API Gateway、Spring Cloud Gateway 等都默认使用令牌桶或它的变体。

5.3 代码实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import time
import threading

class TokenBucketRateLimiter:
    """令牌桶算法:兼顾平滑限流 + 允许突发,是工业界最常用的限流算法"""
    def __init__(self, capacity: int, refill_rate_per_sec: float):
        """
        :param capacity: 桶容量(即最大突发量)
        :param refill_rate_per_sec: 每秒生成的令牌数(平均限流速率)
        """
        self.capacity = capacity
        self.refill_rate = refill_rate_per_sec
        self.tokens = float(capacity)  # 初始满桶
        self.last_refill_time = time.time()
        self._lock = threading.Lock()

    def allow(self, cost: float = 1.0) -> bool:
        with self._lock:
            now = time.time()
            # 补充令牌:自上次以来应该生成的令牌数
            elapsed = now - self.last_refill_time
            self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
            self.last_refill_time = now
            # 取令牌
            if self.tokens >= cost:
                self.tokens -= cost
                return True
            return False

5.4 优缺点

优点缺点
兼顾平均限流 + 突发实现比固定/滑动窗口复杂
工业界事实标准桶容量需要根据业务调优
Guava / Nginx / Sentinel 都内置分布式实现需要原子操作(Redis + Lua)

六、四大算法对比

维度固定窗口滑动窗口漏桶令牌桶
时间复杂度O(1)O(N)O(1)O(1)
空间复杂度O(1)O(N)O(队列长)O(1)
实现难度★★★★★★★★
平滑输出绝对平滑△ 平均平滑、允许突发
允许突发△ 边界双倍✗ 抹平突发可控突发
边界问题✗ 有✓ 无✓ 无✓ 无
典型应用简单统计Cloudflare消息队列 / 网络整形API 网关 / Nginx

七、核心要点与常见坑

7.1 核心 N 点

  1. 固定窗口有"边界双倍突发"问题——限制 1 秒 5 个,可能 1 秒放 10 个
  2. 滑动窗口把窗口细分并滑动,是固定窗口的精准化升级,但内存占用和精度成正比
  3. 漏桶强制平滑输出,抹掉突发——空闲时也无法"加速消化"
  4. 令牌桶兼顾平均限流 + 可控突发,是工业界事实标准——Guava/Nginx/Sentinel 都用它
  5. 限流本质是保护下游——算法选型要回答"下游怕什么":怕突发 → 漏桶;怕长期过载 → 令牌桶
  6. 分布式限流需要共享计数器(Redis + Lua 脚本 / Redis Cell 模块),单机限流直接用进程内限流器即可

7.2 常见 M 个坑

  1. 混淆"算法粒度"和"限流维度"——限流可以按 IP / User ID / 接口 / 租户,算法本身和粒度无关
  2. 桶容量设得太大——突发可能把下游打挂
  3. 桶容量设得太小——失去了"应对正常抖动"的能力
  4. 漏桶的"等待"语义被忽略——某些业务根本不能等,直接拒绝更友好
  5. 限流后没有兜底——被拒绝的请求要返回友好的错误(429 + Retry-After)
  6. 单机限流当成分布式限流用——多机部署时总速率会变成"单机限制 × 机器数"
  7. 限流开关没接入监控——出问题时根本不知道是谁在打

八、工业级实践

8.1 Guava RateLimiter(单机令牌桶)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 创建一个每秒生成 5 个令牌的限流器(即平均 200ms 一个)
RateLimiter limiter = RateLimiter.create(5.0);

if (limiter.tryAcquire()) {
    // 拿到令牌,处理请求
    handleRequest();
} else {
    // 没拿到令牌,直接拒绝
    return Response.status(429).build();
}

8.2 Nginx limit_req(基于令牌桶)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# 定义一个名为 my_limit 的限流区:
#   10m 共享内存大小
#   rate=10r/s 平均 10 个请求/秒
#   burst=20 桶容量 20(允许最大 20 个突发)
#   nodelay 突发请求不延迟直接处理
limit_req_zone $binary_remote_addr zone=my_limit:10m rate=10r/s;

server {
    location /api/ {
        limit_req zone=my_limit burst=20 nodelay;
        proxy_pass http://backend;
    }
}

8.3 Redis + Lua 分布式令牌桶

单机的 RateLimiter 只对单进程有效,分布式场景需要共享计数器。常见做法是 Redis + Lua 脚本保证原子性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
-- KEYS[1]: 令牌桶的 key
-- ARGV[1]: 桶容量
-- ARGV[2]: 填充速率(每秒)
-- ARGV[3]: 当前时间戳(秒)
-- ARGV[4]: 请求消耗的令牌数

local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])

local data = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or capacity
local last_refill = tonumber(data[2]) or now

-- 补充令牌
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * rate)

if tokens >= cost then
    tokens = tokens - cost
    redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', now)
    redis.call('EXPIRE', KEYS[1], 60)  -- 60 秒没访问就清掉
    return 1
else
    redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', now)
    return 0
end

8.4 选型速查

场景推荐算法理由
简单统计、QPS 监控固定窗口最简单
API 限流、爬虫限频滑动窗口精度高,无边界问题
调用第三方固定速率 API漏桶强制平滑,保护合作方
API 网关、Nginx、Sentinel令牌桶兼顾平均 + 突发,工业标准
秒杀抢购令牌桶 + 排队削峰填谷
分布式系统令牌桶 + Redis/Lua多机共享状态

参考资料

使用 Hugo 构建
主题 StackJimmy 设计