3. 限流基础理论:令牌桶算法、漏桶算法、计数器算法原理与对比
在深入 etcd 客户端限流实战之前,必须理解三种最经典的限流算法。etcd 的 --experimental-enable-request-limiter 和客户端侧的限流器均基于这些理论实现。本节将剖析令牌桶、漏桶、计数器算法的核心原理、适用场景及本质差异。
3.1 计数器算法(Counter Algorithm)
计数器是最直观的限流实现,在固定时间窗口内维护一个计数器,请求到达时计数器加 1,超过阈值则拒绝。
核心逻辑: 将时间划分为固定大小的窗口(如 1 秒),每个窗口内记录请求总数,窗口结束时计数器归零。
3.1.1 原理与实现
- 数据结构: 一个原子整数 + 窗口起始时间戳。
- 判定流程: 请求到达 → 检查当前时间是否在窗口内 → 是则计数器 +1 并判断是否超限 → 否则重置窗口。
- 临界问题: 窗口切换瞬间可能出现两倍阈值的突发流量(如 0:59 秒 100 个请求 + 1:00 秒 100 个请求,实际 1 秒内 200 个请求)。
// 简化版计数器限流(Go 伪代码)
type CounterLimiter struct {
mu sync.Mutex
limit int
window time.Duration
count int
startAt time.Time
}
func (c *CounterLimiter) Allow() bool {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
if now.Sub(c.startAt) > c.window {
c.count = 0
c.startAt = now
}
if c.count >= c.limit {
return false
}
c.count++
return true
}
3.2 漏桶算法(Leaky Bucket)
漏桶将请求视为水滴,桶以固定速率漏水(处理请求),桶满则新请求被丢弃。核心是 强制平滑流量,不允许任何突发。
类比: 水龙头以任意速率向桶中注水,桶底小孔以恒定速率漏水。桶满时水溢出(请求被拒绝)。
3.2.1 两种实现方式
| 实现方式 | 描述 | 特点 |
|---|---|---|
| 队列 + 定时消费 | 请求入队,后台协程以固定速率从队列取出处理 | 实现简单,但队列长度受内存限制 |
| 漏桶算法(数学计算) | 记录上次漏水时间和水量,计算当前桶内剩余水量 | 无队列,无锁竞争,性能高 |
// 数学计算版漏桶(Go 伪代码)
type LeakyBucket struct {
mu sync.Mutex
rate float64 // 每秒漏出速率
capacity float64 // 桶容量
water float64 // 当前水量
lastTime time.Time
}
func (l *LeakyBucket) Allow() bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
elapsed := now.Sub(l.lastTime).Seconds()
// 先漏水
l.water = math.Max(0, l.water - elapsed * l.rate)
l.lastTime = now
if l.water < l.capacity {
l.water++
return true
}
return false
}
3.3 令牌桶算法( Bucket)
令牌桶是 etcd 客户端限流器 rate.Limiter 的底层实现。它以固定速率向桶中放入令牌,请求到达时消耗一个令牌,桶空则请求等待或拒绝。与漏桶不同,令牌桶 允许一定程度的突发。
关键参数: 令牌生成速率
r(个/秒)、桶容量 b(最大突发量)。突发时最多可一次性消耗 b 个令牌。
3.3.1 工作原理
- 令牌生成: 后台或惰性计算方式,以速率
r向桶中添加令牌,桶满则停止。 - 请求处理: 从桶中取出 1 个令牌(或 N 个),成功则放行,失败则阻塞或拒绝。
- 突发能力: 桶中最多积累
b个令牌,空闲时桶满,突发时可一次性消耗所有令牌。
// Go 标准库 rate.Limiter 核心逻辑(简化)
type Limiter struct {
limit Limit // 每秒令牌数
burst int // 桶容量
mu sync.Mutex
tokens float64
lastTime time.Time
}
func (lim *Limiter) reserveN(now time.Time, n int) bool {
lim.mu.Lock()
defer lim.mu.Unlock()
// 计算时间差,补充令牌
elapsed := now.Sub(lim.lastTime).Seconds()
lim.tokens += elapsed * float64(lim.limit)
if lim.tokens > float64(lim.burst) {
lim.tokens = float64(lim.burst)
}
lim.lastTime = now
if lim.tokens < float64(n) {
return false // 令牌不足
}
lim.tokens -= float64(n)
return true
}
3.4 三种算法对比
| 维度 | 计数器 | 漏桶 | 令牌桶 |
|---|---|---|---|
| 突发处理 | 窗口内允许突发,但窗口边界有漏洞 | 完全禁止突发 | 允许突发(最多消耗桶容量) |
| 流量平滑度 | 窗口内不平滑 | 严格平滑 | 短期可突发,长期平滑 |
| 实现复杂度 | 低 | 中 | 中高(需处理时间精度) |
| 内存开销 | 低(1 个计数器) | 低(队列版需内存) | 低(2 个浮点数) |
| 适用场景 | 简单 API 限流,可接受边界毛刺 | 数据库连接池、网络带宽控制 | etcd 客户端、微服务网关、通用限流 |
| etcd 使用 | etcd 服务端 --max-request-bytes 等简单限制 |
不直接使用 | 客户端 rate.Limiter、服务端限流器 |
3.5 算法选择建议
etcd 客户端限流场景: 推荐使用令牌桶算法。原因:etcd 客户端需要容忍短时突发(如 Watch 重连、批量写入),同时保证长期平均 QPS 可控。漏桶过于严格,计数器存在窗口边界问题。
- 需要严格平滑: 选择漏桶(如网络带宽限速、数据库连接池)。
- 需要简单实现且能接受毛刺: 选择计数器(如非关键路径的日志限流)。
- 需要兼顾突发与长期限制: 选择令牌桶(etcd 客户端、API 网关、微服务限流)。
3.6 三种算法核心流程对比(SVG)
3.7 本章小结
- 计数器 适合简单场景,但存在窗口边界漏洞。
- 漏桶 强制平滑,适合带宽控制等不允许突发的场景。
- 令牌桶 兼顾突发与长期限制,是 etcd 客户端限流的最佳选择。
- etcd 的
rate.Limiter基于令牌桶实现,后续章节将深入其源码与调优。