4. 限流基础:令牌桶、漏桶、固定窗口、滑动窗口算法原理与 Go 实现
限流是保障分布式系统稳定性的第一道防线。在生产环境中,突发的流量尖峰、异常的重试风暴或恶意的 DDoS 攻击,都可能导致后端服务雪崩。本章将深入剖析四种经典限流算法的核心原理、适用场景,并给出可直接用于压测诊断的 Go 实现。
压测视角: 限流算法的选择直接影响尾延迟(Tail Latency)的分布形态。例如,固定窗口的“临界突变”会在压测曲线中产生周期性的毛刺,而令牌桶的平滑特性则能显著降低 P99 延迟的抖动。
4.1 令牌桶算法(Token Bucket)
4.1.1 原理
令牌桶以固定速率向桶中放入令牌,每个请求必须获取一个令牌才能被处理。桶有容量上限,允许突发流量消耗积攒的令牌。当桶空时,请求被拒绝或等待。
- 关键参数: 速率
r(每秒放入令牌数)、容量b(最大突发量)。 - 行为特征: 长期平均速率被限制在
r,短期允许突发到b。 - 适用场景: 允许一定突发流量的网络接口、API 网关、流量整形。
算法核心: 令牌桶不记录请求时间戳,而是记录“上次填充时间”和“当前令牌数”,通过时间差计算应补充的令牌数,避免定时器开销。
4.1.2 Go 实现
package limiter
import (
"sync"
"time"
)
type TokenBucket struct {
rate float64 // 每秒放入令牌数
capacity float64 // 桶容量
tokens float64 // 当前令牌数
lastTime time.Time // 上次填充时间
mu sync.Mutex
}
func NewTokenBucket(rate, capacity float64) *TokenBucket {
return &TokenBucket{
rate: rate,
capacity: capacity,
tokens: capacity, // 初始满桶
lastTime: time.Now(),
}
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(tb.lastTime).Seconds()
// 补充令牌
tb.tokens += elapsed * tb.rate
if tb.tokens > tb.capacity {
tb.tokens = tb.capacity
}
tb.lastTime = now
if tb.tokens >= 1 {
tb.tokens--
return true
}
return false
}
压测陷阱: 上述实现使用
time.Now() 获取时间,在压测场景下(百万级 QPS),频繁调用 time.Now() 本身会成为性能瓶颈。生产环境可考虑使用 time.Now().UnixNano() 减少对象分配,或采用无锁化设计。
4.2 漏桶算法(Leaky Bucket)
4.2.1 原理
漏桶将请求视为水滴,以固定速率从桶底漏出处理。桶有容量上限,当桶满时新请求被丢弃。漏桶强制输出速率恒定,不允许突发。
- 关键参数: 漏出速率
r(每秒处理请求数)、桶容量b(最大排队数)。 - 行为特征: 输出速率严格恒定,请求延迟与桶内积压量成正比。
- 适用场景: 数据库连接池、下游处理能力固定的系统、需要平滑输出的场景。
与令牌桶对比: 令牌桶允许突发,漏桶强制平滑。在压测中,漏桶会导致请求排队延迟增加,而令牌桶则可能因突发导致下游瞬时压力。
4.2.2 Go 实现(基于队列)
type LeakyBucket struct {
rate time.Duration // 每个请求的处理间隔
capacity int // 桶容量
queue chan struct{} // 请求队列
}
func NewLeakyBucket(rate time.Duration, capacity int) *LeakyBucket {
lb := &LeakyBucket{
rate: rate,
capacity: capacity,
queue: make(chan struct{}, capacity),
}
go lb.leak()
return lb
}
func (lb *LeakyBucket) leak() {
ticker := time.NewTicker(lb.rate)
defer ticker.Stop()
for range ticker.C {
select {
case <-lb.queue:
// 处理请求(实际业务逻辑)
default:
// 队列为空,跳过
}
}
}
func (lb *LeakyBucket) Allow() bool {
select {
case lb.queue <- struct{}{}:
return true
default:
return false // 桶满,拒绝
}
}
4.3 固定窗口算法(Fixed Window)
4.3.1 原理
将时间划分为固定长度窗口(如 1 秒),每个窗口内允许通过的请求数上限为阈值。窗口切换时计数器重置。
- 关键参数: 窗口大小
w、窗口内阈值limit。 - 行为特征: 实现简单,但存在“临界突变”问题:窗口切换瞬间可能涌入 2 倍流量。
- 适用场景: 对精度要求不高的简单限流、非关键路径的速率限制。
压测诊断重点: 固定窗口的临界突变会在压测的每秒请求数(RPS)曲线上产生“锯齿”状波动。如果压测发现 P99 延迟在每秒边界处出现规律性尖峰,应优先怀疑固定窗口限流。
4.3.2 Go 实现
type FixedWindow struct {
limit int
window time.Duration
count int
startAt time.Time
mu sync.Mutex
}
func NewFixedWindow(limit int, window time.Duration) *FixedWindow {
return &FixedWindow{
limit: limit,
window: window,
startAt: time.Now(),
}
}
func (fw *FixedWindow) Allow() bool {
fw.mu.Lock()
defer fw.mu.Unlock()
now := time.Now()
if now.Sub(fw.startAt) >= fw.window {
fw.count = 0
fw.startAt = now
}
if fw.count < fw.limit {
fw.count++
return true
}
return false
}
4.4 滑动窗口算法(Sliding Window)
4.4.1 原理
滑动窗口将时间窗口细分为多个小格子(如 1 秒窗口分为 10 个 100ms 格子),每个格子独立计数。窗口滑动时,丢弃过期格子,累加当前窗口内所有格子的计数。
- 关键参数: 窗口大小
w、格子数n、窗口内阈值limit。 - 行为特征: 解决了固定窗口的临界突变问题,限流曲线平滑,精度由格子数决定。
- 适用场景: 对限流精度要求高的生产环境、微服务网关、实时流量控制。
精度与性能权衡: 格子数越多,限流越平滑,但内存占用和计算开销也越大。通常 10~20 个格子即可满足大部分生产需求。
4.4.2 Go 实现
type SlidingWindow struct {
limit int
window time.Duration
slots int
counts []int
slotDur time.Duration
lastTime time.Time
mu sync.Mutex
}
func NewSlidingWindow(limit int, window time.Duration, slots int) *SlidingWindow {
return &SlidingWindow{
limit: limit,
window: window,
slots: slots,
counts: make([]int, slots),
slotDur: window / time.Duration(slots),
lastTime: time.Now(),
}
}
func (sw *SlidingWindow) Allow() bool {
sw.mu.Lock()
defer sw.mu.Unlock()
now := time.Now()
elapsed := now.Sub(sw.lastTime)
// 滑动过期格子
if elapsed >= sw.slotDur {
slideCount := int(elapsed / sw.slotDur)
if slideCount > sw.slots {
slideCount = sw.slots
}
// 移除过期格子,前移
sw.counts = append(sw.counts[:0], sw.counts[slideCount:]...)
// 补零
for i := len(sw.counts); i < sw.slots; i++ {
sw.counts = append(sw.counts, 0)
}
sw.lastTime = now
}
// 计算当前窗口总计数
total := 0
for _, c := range sw.counts {
total += c
}
if total < sw.limit {
sw.counts[sw.slots-1]++
return true
}
return false
}
4.5 四种算法对比总结
| 算法 | 输出特性 | 突发能力 | 临界突变 | 实现复杂度 | 内存开销 | 压测推荐度 |
|---|---|---|---|---|---|---|
| 令牌桶 | 平均平滑,允许突发 | 是 | 无 | 低 | 低(仅计数器) | ★★★★★ |
| 漏桶 | 严格平滑 | 否 | 无 | 中(需队列) | 中(队列) | ★★★★ |
| 固定窗口 | 阶梯状 | 是(窗口内) | 严重 | 极低 | 极低 | ★★ |
| 滑动窗口 | 平滑 | 是(窗口内) | 无 | 中 | 中(格子数组) | ★★★★★ |
压测选型建议: 对于生产环境的限流组件,优先选择令牌桶或滑动窗口。固定窗口仅适用于非关键路径的简单限制。漏桶适合下游处理能力固定的场景,但需注意队列堆积导致的延迟增加。
4.6 压测诊断视角的算法选择
在《限流尾延迟Go压测诊断》课程中,我们后续的压测实验将主要基于令牌桶和滑动窗口进行。原因如下:
- 尾延迟稳定性: 令牌桶的突发特性在压测中会产生可预测的延迟分布,便于诊断系统瓶颈。
- 临界突变规避: 滑动窗口消除了固定窗口的周期性毛刺,使压测曲线更真实反映系统处理能力。
- Go 标准库兼容: Go 官方扩展包
golang.org/x/time/rate基于令牌桶实现,可直接用于压测工具链集成。