一、为什么需要限流器
你的 API 上线了,起初一切正常。然后某个大客户写了个脚本开始每秒发 1000 个请求。或者更糟,有人搞了个爬虫,把你的接口当自助餐。再或者,一个促销活动让流量暴增 10 倍,数据库扛不住了。
没有限流的时候,系统只能被动承受所有请求。流量正常时没问题,流量突增时系统就被压垮了——响应变慢、超时、最终崩溃。所有用户一起遭殃,包括那些正常使用的用户。
限流器的核心价值是主动防御:在系统被压垮之前,按预设的速率拒绝多余请求,保证已接受的请求能被正常处理。它让系统在面对突发流量时仍能保持稳定的服务质量。
但限流不只是”挡住多余请求”这么简单。实际场景中还有几个容易忽视的问题。
固定窗口的边界突发问题。 最朴素的限流思路是”每分钟最多 100 个请求”——用一个计数器,每到整分钟归零。看起来合理,但边界处会出大问题:客户端在 11:00<59>59> 发了 100 个请求,在 11:01<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 令牌 (封顶,不超容量)mermaidflowchart 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 的使用。令牌桶的状态(tokens 和 lastRefill)必须作为一个整体来读写,否则会出现竞态条件:两个 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,在等待期间其他代码可能修改 tokens 和 lastRefill。所以限流器的核心逻辑应该保持同步,不要在里面放异步操作。
5.3 分布式限流的挑战
单机限流器只能保护单个节点。当你的服务部署了多台机器时,每台各自维护一个令牌桶,全局的实际限制变成了 rate × 节点数。要执行全局速率,必须引入共享存储。
Redis 是最常见的选择。基本思路是把令牌桶的状态存到 Redis 里,每次请求时用 Lua 脚本原子地执行”补充 + 判断 + 扣减”。Lua 脚本在 Redis 中是原子执行的,不会被打断,所以不会出现竞态条件。
但分布式限流引入了新的权衡。每次请求都要访问 Redis,延迟从纳秒级(内存操作)变成了毫秒级(网络往返)。如果 Redis 不可用,你要么放行所有请求(限流失效),要么拒绝所有请求(服务降级),没有中间选项。这是典型的 CAP 取舍——在”一致性”(精确的全局限流)和”可用性”(Redis 挂了仍能服务)之间,你只能选一个。
实践中常见的折中方案是”本地 + 全局”双层限流:每个节点维护一个本地令牌桶做粗粒度限流(比如全局限额的 1/N),同时用 Redis 做更精确的全局限流。本地限流作为第一道防线,即使 Redis 挂了也能提供基本保护。
六、生产验证
| 项目 | 源码位置 | 用途 |
|---|---|---|
| Go x/time/rate | Limiter 结构体 | 含 tokens、limit、burst 和 last 时间戳。reserveN(L337-L381)是核心算法:按经过时间推进令牌,减去请求的 n,计算等待时长。整个 Go 生态广泛使用 |
| Nginx limit_req | ngx_http_limit_req_lookup | 漏桶实现。L454:excess = lr->excess - rate * ms / 1000 + 1000,按经过时间排空 excess 并添加一个请求。驱动数百万 Nginx 服务器的限流指令 |
| Guava RateLimiter | SmoothBursty / SmoothWarmingUp | Java 生态标准限流器。支持预热的平滑限流——冷启动时逐渐放开速率,避免系统被瞬间流量击穿 |
七、小结
何时使用:
- API 限流——保护端点免受滥用。GitHub、Stripe 等公开 API 的标配。没有限流,一个恶意用户就能耗尽所有资源,影响其他正常用户
- 网络流量整形——控制带宽分配。Linux tc、Nginx 的核心能力。漏桶更适合严格匀速的场景,令牌桶则允许合理的突发
- 资源保护——限制数据库查询频率、文件 I/O 速率或 CPU 密集操作的并发度。下游系统有处理上限时,限流器是上游的”安全阀”
- 多租户公平使用——确保每个租户获得合理的资源份额。没有限流,一个租户的异常流量会挤占其他租户的配额
何时不用:
- 二元访问控制——如果只需要”允许/拒绝”两种状态,用认证而非限流。限流是速率控制,不是权限控制
- 精确计数——令牌桶是近似算法,浮点数计算有精度损失。如果需要精确限制”每天恰好 1000 次”,用计数器或滑动窗口
- 无协调的分布式限流——每节点独立的令牌桶无法执行全局速率。要么接受不精确(本地限流 + 按比例分配),要么引入 Redis 等共享存储(增加延迟和故障点)
- 极端延迟敏感路径——每次请求的补充计算虽然只有 O(1),但涉及时间戳读取和浮点运算,在纳秒级敏感的热路径上可能不可接受
八、参考资料
- Go x/time/rate 源码 - Go 官方限流器实现,惰性补充的范本
- Nginx Rate Limiting - Nginx 限流模块文档,漏桶算法的生产实践
- Guava RateLimiter - 带预热的平滑限流器,Java 生态标准
- Stripe Rate Limiting Guide - Stripe 工程团队关于限流策略的实践经验
- AWS API Gateway Throttling - AWS 的令牌桶限流实现,含突发与稳态设计
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






