mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
4009 字
11 分钟
限流器(Rate Limiter)
2026-06-13

一、为什么需要限流器#

你的 API 上线了,起初一切正常。然后某个大客户写了个脚本开始每秒发 1000 个请求。或者更糟,有人搞了个爬虫,把你的接口当自助餐。再或者,一个促销活动让流量暴增 10 倍,数据库扛不住了。

没有限流的时候,系统只能被动承受所有请求。流量正常时没问题,流量突增时系统就被压垮了——响应变慢、超时、最终崩溃。所有用户一起遭殃,包括那些正常使用的用户。

限流器的核心价值是主动防御:在系统被压垮之前,按预设的速率拒绝多余请求,保证已接受的请求能被正常处理。它让系统在面对突发流量时仍能保持稳定的服务质量。

但限流不只是”挡住多余请求”这么简单。实际场景中还有几个容易忽视的问题。

固定窗口的边界突发问题。 最朴素的限流思路是”每分钟最多 100 个请求”——用一个计数器,每到整分钟归零。看起来合理,但边界处会出大问题:客户端在 11:00<59> 发了 100 个请求,在 11:01<01> 又发了 100 个。2 秒内通过了 200 个请求,是限额的两倍。如果你的数据库每秒只能处理 50 个查询,这 2 秒内的 200 个请求足以把它打挂。固定窗口算法在窗口边界处形同虚设,这就是为什么生产环境很少直接用它。

分布式限流的一致性挑战。 单机限流很简单——一个进程内的计数器就够了。但当你有 10 台服务器时,每台各自维护一个计数器,全局的实际限制就变成了 10 倍。要执行全局速率,必须引入共享存储(比如 Redis),让所有节点读写同一个计数器。但这又带来了新问题:每次请求都要访问 Redis,延迟增加了;Redis 挂了怎么办?是放行所有请求还是拒绝所有请求?分布式限流本质上是在”精确性”和”可用性”之间做取舍,没有完美解。

突发与稳态的平衡。 有些系统需要容忍短时间的突发——用户打开页面时可能同时发 5 个请求,这很正常。但如果一直以这个速率请求,就不正常了。限流器需要区分”合理的突发”和”持续的滥用”,这正是令牌桶算法擅长的。

二、现实类比#

地铁站的闸机。每次刷卡放一个人通过,节奏可控。人群涌来时只能排队。闸机不会因为人多就加速——它强制执行一个稳定的通过速率。偶尔没什么人的时候,闸机也不会因为你刷卡快就一次放两个人进去。

换个角度想:如果闸机有一个”候车区”,空闲时可以攒几个名额,那当一小波人突然到来时,前面几个人可以快速通过(消耗攒下的名额),但名额用完后,后续的人还是得按固定节奏等待。这就是令牌桶的思路——允许短时间的突发,但长期速率受控。

三、核心思想#

令牌桶是限流器最常用的算法。桶初始满载 capacity 个令牌,以 rate 个/秒的速率持续补充。每个请求消耗一个令牌,桶空时请求被拒绝或延迟。这个设计天然允许短时间的突发(最多到 capacity),同时限制长期平均速率。

令牌桶 (capacity=5, rate=2/sec)
时间 0s: [●][●][●][●][●] 5 令牌 (满)
请求: [●][●][●][●][ ] 4 令牌 (消耗 1)
请求: [●][●][●][ ][ ] 3 令牌
请求: [●][●][ ][ ][ ] 2 令牌
+1 秒: [●][●][●][●][ ] 4 令牌 (补充 2)
+2 秒: [●][●][●][●][●] 5 令牌 (封顶,不超容量)
mermaid
flowchart LR
Request --> Check{令牌够吗?}
Check -->|够| Consume[消耗令牌\n放行请求]
Check -->|不够| Reject[拒绝 / 排队]
Timer[定时补充\nrate 个/秒] --> Bucket[令牌桶]
Bucket --> Check

关键数据结构:

属性说明
当前令牌数浮点数桶中可用令牌
容量整数令牌上限,允许最大突发量
补充速率浮点数每秒补充的令牌数
上次补充时间时间戳用于计算经过时间内应补充的令牌

操作复杂度:

操作复杂度说明
tryAcquire()O(1)计算已过时间、补充令牌、比较并扣减
空间O(1)每个限流器只需要一个令牌计数 + 一个时间戳

3.1 惰性补充 vs 定时补充#

令牌补充有两种实现方式。一种是定时补充:启动一个后台定时器,每隔固定间隔往桶里加令牌。这种方式直观,但问题不少——定时器有精度开销,多一个 goroutine/线程需要管理,而且定时器频率和补充速率的匹配也不自然(rate=0.7 个/秒时,你很难用 1 秒的定时器精确补充 0.7 个令牌)。

另一种是惰性补充(Lazy Refill):不跑定时器,只在请求到来时才计算”从上次到现在,应该补充多少令牌”。公式很简单:

新增令牌 = 经过秒数 × 补充速率
当前令牌 = min(容量, 当前令牌 + 新增令牌)

惰性补充的好处是零额外开销——没有后台线程,没有定时器,所有计算都在请求路径上完成,且只有 O(1) 的时间复杂度。Go 官方的 x/time/rate 和 Guava 的 RateLimiter 都采用这种方式。

3.2 令牌累积的数学#

令牌桶的长期平均速率严格等于 rate,这一点可以用简单的数学验证。假设桶初始为空,经过 T 秒后,最多补充 T × rate 个令牌。如果这段时间内持续有请求,最多也只能消耗 T × rate 个令牌(因为补充是上限)。所以长期来看,通过速率不会超过 rate

短期的突发量则受 capacity 约束。桶最多存 capacity 个令牌,所以即使桶是满的,一次最多也只能放行 capacity 个请求。突发过后,令牌耗尽,后续请求只能等补充——速率回到 rate。这就是令牌桶”允许突发,但限制持续速率”的数学基础。

3.3 容量上限为什么重要#

min(容量, 当前令牌 + 新增令牌) 中的 min 不是可有可无的。如果没有容量上限,令牌会无限累积——系统空闲 1 小时后,桶里攒了 3600 × rate 个令牌,突然来一波流量时,瞬间全部放行,等于没有限流。容量上限确保了”攒额度”是有界的,突发量被控制在合理范围内。

选择 capacity 的值需要权衡:太小,正常的短时突发(比如页面加载时的并发请求)会被误杀;太大,突发保护形同虚设。通常 capacity 设为 rate 的 1-3 倍,或者根据业务场景的典型突发量来定。

四、变体与对比#

算法行为突发容忍边界问题典型场景
令牌桶令牌累积,允许突发到容量上限允许API 限流、突发友好
漏桶请求以恒定速率流出,多余排队不允许流量整形、平滑输出
滑动窗口统计时间窗口内请求数受窗口限制精确计数场景
固定窗口按固定时间间隔重置计数器允许(边界双倍)有边界突发简单限流、配额管理

4.1 固定窗口的边界问题详解#

固定窗口是最容易实现的限流算法——一个计数器加一个定时重置。但它的边界突发问题在实际中很致命。来看一个具体例子:

固定窗口:每分钟 100 个请求
时间线:
11:00:00 ─────────── 11:00:59 ── 11:01:00 ─────────── 11:01:59
[ 窗口 1 ] [ 窗口 2 ]
↑ 重置计数器
客户端行为:
11:00:59 发送 100 个请求 → 全部通过(窗口 1 计数 = 100)
11:01:01 发送 100 个请求 → 全部通过(窗口 2 计数 = 100)
结果:2 秒内通过 200 个请求,是限额的两倍

这个问题在窗口越短时越严重。如果用 1 秒的窗口,边界处的突发窗口只有几毫秒,但那几毫秒内的双倍流量仍然可能打挂下游。滑动窗口通过在窗口内做加权统计解决了这个问题,但实现复杂度更高。令牌桶则从根本上避免了这个问题——令牌是连续补充的,不存在”窗口重置”这个概念。

4.2 令牌桶 vs 漏桶#

令牌桶和漏桶经常被混淆,但它们的行为有本质区别。令牌桶允许突发:桶里有攒下的令牌时,请求可以瞬间通过。漏桶不允许突发:不管来了多少请求,输出速率始终恒定。

选择取决于下游的承受能力。如果下游是另一个 API,短时间的突发通常没问题——TCP 本身就有拥塞控制,HTTP 客户端也有连接池,用令牌桶就好。如果下游是一个不能承受任何突发的系统——比如数据库的写入速率有硬上限,或者消息队列的消费端需要严格匀速——那就用漏桶,把流量”抹平”后再输出。

4.3 滑动窗口的适用场景#

滑动窗口(尤其是滑动日志式)能精确统计时间窗口内的请求数,适合需要严格计数的场景,比如”每用户每天最多调用 1000 次”这种配额管理。但它的内存开销与窗口内的请求数成正比(需要记录每个请求的时间戳),高 QPS 场景下不太适用。滑动窗口计数器(Sliding Window Counter)是折中方案——用多个固定窗口做加权,精度接近滑动窗口,但内存开销是 O(窗口数) 而非 O(请求数)。

五、多语言实现#

5.1 Go 实现#

package ratelimiter
import (
"sync"
"time"
)
// TokenBucket 令牌桶限流器
type TokenBucket struct {
mu sync.Mutex
capacity float64 // 桶容量(最大令牌数)
refillRate float64 // 每秒补充的令牌数
tokens float64 // 当前令牌数
lastRefill time.Time // 上次补充时间
}
func NewTokenBucket(capacity, refillRate float64) *TokenBucket {
return &TokenBucket{
capacity: capacity,
refillRate: refillRate,
tokens: capacity, // 初始满载
lastRefill: time.Now(),
}
}
func (tb *TokenBucket) TryAcquire(n float64) bool {
tb.mu.Lock()
defer tb.mu.Unlock()
// 惰性补充:按经过时间计算应补充的令牌
now := time.Now()
elapsed := now.Sub(tb.lastRefill).Seconds()
tb.tokens = min(tb.capacity, tb.tokens+elapsed*tb.refillRate)
tb.lastRefill = now
// 判断令牌是否足够
if tb.tokens >= n {
tb.tokens -= n
return true
}
return false
}
func min(a, b float64) float64 {
if a < b {
return a
}
return b
}

Go 版本的关键在于”惰性补充”——不在后台跑定时器往桶里加令牌,而是在每次 TryAcquire 时才计算应该补充多少。这样实现简单,不需要额外的 goroutine,也不会有定时器的精度问题。

注意 sync.Mutex 的使用。令牌桶的状态(tokenslastRefill)必须作为一个整体来读写,否则会出现竞态条件:两个 goroutine 同时读到相同的 lastRefill,各自计算补充量,导致令牌被重复补充。Mutex 保证了”补充 + 扣减”是一个原子操作。

TryAcquire 的参数 n 允许一次消耗多个令牌,这对”不同操作消耗不同配额”的场景很有用——比如读操作消耗 1 个令牌,写操作消耗 5 个令牌。

5.2 TypeScript 实现#

class TokenBucket {
private tokens: number;
private lastRefill: number;
constructor(
private capacity: number, // 桶容量
private refillRate: number, // 每秒补充令牌数
) {
this.tokens = capacity; // 初始满载
this.lastRefill = Date.now();
}
/** 惰性补充:计算从上次到现在应补充的令牌 */
private refill(): void {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(
this.capacity,
this.tokens + elapsed * this.refillRate,
);
this.lastRefill = now;
}
tryAcquire(tokens = 1): boolean {
this.refill();
if (this.tokens >= tokens) {
this.tokens -= tokens;
return true;
}
return false;
}
}

TypeScript 版本逻辑完全一致。refill() 是私有方法,在每次 tryAcquire 前调用。浮点数精度在 JavaScript 里不是问题——限流器本身就是近似算法,不需要精确到小数点后 N 位。

JavaScript 的单线程模型意味着不需要显式加锁——tryAcquire 不会被两个”线程”同时执行。但要注意 async 场景:如果 tryAcquire 内部有 await,在等待期间其他代码可能修改 tokenslastRefill。所以限流器的核心逻辑应该保持同步,不要在里面放异步操作。

5.3 分布式限流的挑战#

单机限流器只能保护单个节点。当你的服务部署了多台机器时,每台各自维护一个令牌桶,全局的实际限制变成了 rate × 节点数。要执行全局速率,必须引入共享存储。

Redis 是最常见的选择。基本思路是把令牌桶的状态存到 Redis 里,每次请求时用 Lua 脚本原子地执行”补充 + 判断 + 扣减”。Lua 脚本在 Redis 中是原子执行的,不会被打断,所以不会出现竞态条件。

但分布式限流引入了新的权衡。每次请求都要访问 Redis,延迟从纳秒级(内存操作)变成了毫秒级(网络往返)。如果 Redis 不可用,你要么放行所有请求(限流失效),要么拒绝所有请求(服务降级),没有中间选项。这是典型的 CAP 取舍——在”一致性”(精确的全局限流)和”可用性”(Redis 挂了仍能服务)之间,你只能选一个。

实践中常见的折中方案是”本地 + 全局”双层限流:每个节点维护一个本地令牌桶做粗粒度限流(比如全局限额的 1/N),同时用 Redis 做更精确的全局限流。本地限流作为第一道防线,即使 Redis 挂了也能提供基本保护。

六、生产验证#

项目源码位置用途
Go x/time/rateLimiter 结构体tokenslimitburstlast 时间戳。reserveN(L337-L381)是核心算法:按经过时间推进令牌,减去请求的 n,计算等待时长。整个 Go 生态广泛使用
Nginx limit_reqngx_http_limit_req_lookup漏桶实现。L454:excess = lr->excess - rate * ms / 1000 + 1000,按经过时间排空 excess 并添加一个请求。驱动数百万 Nginx 服务器的限流指令
Guava RateLimiterSmoothBursty / SmoothWarmingUpJava 生态标准限流器。支持预热的平滑限流——冷启动时逐渐放开速率,避免系统被瞬间流量击穿

七、小结#

何时使用:

  • API 限流——保护端点免受滥用。GitHub、Stripe 等公开 API 的标配。没有限流,一个恶意用户就能耗尽所有资源,影响其他正常用户
  • 网络流量整形——控制带宽分配。Linux tc、Nginx 的核心能力。漏桶更适合严格匀速的场景,令牌桶则允许合理的突发
  • 资源保护——限制数据库查询频率、文件 I/O 速率或 CPU 密集操作的并发度。下游系统有处理上限时,限流器是上游的”安全阀”
  • 多租户公平使用——确保每个租户获得合理的资源份额。没有限流,一个租户的异常流量会挤占其他租户的配额

何时不用:

  • 二元访问控制——如果只需要”允许/拒绝”两种状态,用认证而非限流。限流是速率控制,不是权限控制
  • 精确计数——令牌桶是近似算法,浮点数计算有精度损失。如果需要精确限制”每天恰好 1000 次”,用计数器或滑动窗口
  • 无协调的分布式限流——每节点独立的令牌桶无法执行全局速率。要么接受不精确(本地限流 + 按比例分配),要么引入 Redis 等共享存储(增加延迟和故障点)
  • 极端延迟敏感路径——每次请求的补充计算虽然只有 O(1),但涉及时间戳读取和浮点运算,在纳秒级敏感的热路径上可能不可接受

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

限流器(Rate Limiter)
https://blog.souloss.com/posts/programming/system-patterns/system-patterns-rate-limiter/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时