常见限流算法:令牌桶、漏桶、计数器、滑动窗口的原理与对比

限流算法的核心目标是在高并发场景下控制请求速率,防止系统过载。Go 语言在构建高吞吐微服务时,选择合适的限流算法直接决定了尾延迟的稳定性。本节将深入剖析四种经典限流算法的原理、实现要点与适用场景。

1. 计数器算法(固定窗口)

计数器算法是最直观的限流实现:将时间划分为固定大小的窗口(如 1 秒),每个窗口内维护一个计数器,请求到达时计数器加 1,若超过阈值则拒绝。

核心问题:临界突变。窗口切换瞬间可能出现两倍阈值的突发流量(例如窗口末尾 500ms 和下一窗口开头 500ms 各涌入阈值流量)。
维度说明
窗口粒度固定时间长度(如 1s、1min)
计数器原子整数(Go 中可用 atomic.Int64)
重置策略窗口结束时计数器归零
内存开销极低,仅需一个计数器和时间戳
适用场景对突发不敏感、允许短时毛刺的简单限流
// 计数器限流核心逻辑(伪代码)
type CounterLimiter struct {
    threshold int64
    window    time.Duration
    count     atomic.Int64
    lastReset time.Time
}

func (l *CounterLimiter) Allow() bool {
    now := time.Now()
    if now.Sub(l.lastReset) >= l.window {
        l.count.Store(0)
        l.lastReset = now
    }
    return l.count.Add(1) <= l.threshold
}

2. 滑动窗口算法

滑动窗口将固定窗口细分为多个小格子(如 1s 窗口拆成 10 个 100ms 格子),每个格子独立计数。窗口滑动时,丢弃过期格子并累加当前窗口内所有格子的计数,从而平滑流量突变。

优势:相比计数器算法,滑动窗口将临界突变问题从“两倍阈值”降低到“1/N 倍阈值”(N 为格子数)。格子越多,精度越高,但内存和计算开销也线性增长。
参数典型值影响
窗口大小1s / 10s限流时间跨度
格子数10 ~ 100精度与内存的权衡
格子粒度100ms / 1s窗口大小 ÷ 格子数
// 滑动窗口核心结构(Go 实现片段)
type SlidingWindow struct {
    size    int           // 格子数
    buckets []int64       // 每个格子的计数
    span    time.Duration // 每个格子的时间跨度
    head    int           // 当前窗口起始格子索引
    total   int64         // 当前窗口总计数
    mu      sync.Mutex
}
固定窗口 vs 滑动窗口:临界突变对比 固定窗口(1s) 窗口 1(计数 100) 窗口 2(计数 100) 临界点:200 请求/秒 滑动窗口(10 格子) 旧格子 新格子 窗口随时间平滑滑动,每次丢弃最旧格子 滑动窗口将临界突变从 2x 降低到 1.1x (10 格子时,最大突发 = 阈值 × 1.1)

3. 漏桶算法

漏桶算法将请求视为水滴,以恒定速率从桶底漏出(处理),桶满则溢出(拒绝)。它强制输出速率完全平滑,适合需要严格削峰填谷的场景。

  • 桶容量(burst):允许的瞬时最大积压请求数。
  • 漏出速率(rate):恒定处理速率,单位请求/秒。
  • 行为特征:无论输入多不均匀,输出始终平滑如流水。
注意:漏桶算法在 Go 中实现时,若使用 time.Ticker 驱动漏出,需注意 Ticker 的精度和 goroutine 泄漏问题。推荐使用基于时间戳计算的“虚拟漏桶”实现,避免常驻定时器。
// 虚拟漏桶实现(基于时间戳计算)
type LeakyBucket struct {
    rate   float64 // 漏出速率(请求/秒)
    cap    int64   // 桶容量
    water  float64 // 当前水量
    lastTs time.Time
    mu     sync.Mutex
}

func (b *LeakyBucket) Allow() bool {
    b.mu.Lock()
    defer b.mu.Unlock()
    now := time.Now()
    elapsed := now.Sub(b.lastTs).Seconds()
    b.water = math.Max(0, b.water - elapsed * b.rate) // 先漏水
    b.lastTs = now
    if b.water < float64(b.cap) {
        b.water++
        return true
    }
    return false
}

4. 令牌桶算法

令牌桶以固定速率向桶中添加令牌,请求到达时需获取一个令牌才能通过。与漏桶不同,令牌桶允许一定程度的突发——桶中累积的令牌可被一次性取走。

特性令牌桶漏桶
输出速率平均速率受限,允许突发严格恒定速率
桶含义存储令牌(许可)存储请求(积压)
突发控制桶容量决定最大突发桶容量决定最大积压
适用场景允许短时突发的业务(如 API 网关)必须平滑输出的场景(如数据库写入)
Go 标准库中的令牌桶:golang.org/x/time/rate 包提供了官方令牌桶实现(Limiter),支持 Wait/Allow/Reserve 三种模式,内部使用高效的时间戳计算,无需定时器。
// Go 标准库令牌桶使用示例
import "golang.org/x/time/rate"

limiter := rate.NewLimiter(rate.Limit(100), 200) // 速率 100/s,桶容量 200
if limiter.Allow() {
    // 处理请求
} else {
    // 限流拒绝
}
令牌桶 vs 漏桶:结构对比 令牌桶 令牌桶(容量 200) 令牌以 100/s 速率生成 请求 取令牌通过 漏桶 漏桶(容量 100) 请求积压等待漏出 请求 恒定速率漏出 令牌桶:允许突发,适合 API 网关、微服务调用 漏桶:强制平滑,适合数据库写入、消息队列消费

5. 四种算法综合对比

算法输出平滑度突发能力内存开销实现复杂度Go 生态支持
计数器(固定窗口)差(临界突变)高(窗口内任意突发)极低极低自行实现
滑动窗口中(取决于格子数)中(受格子粒度限制)低~中中自行实现 / 第三方库
漏桶极高(严格恒定)无(强制削峰)低低自行实现
令牌桶高(平均平滑)高(桶容量决定)低低golang.org/x/time/rate

6. 选型建议与 Go 实践

  • API 网关 / HTTP 服务:优先选择令牌桶(x/time/rate),支持突发且 Go 官方维护。
  • 数据库连接池 / 消息队列消费:推荐漏桶,强制平滑输出,避免下游被冲垮。
  • 简单场景 / 原型验证:计数器算法足够,但需注意临界突变对尾延迟的影响。
  • 高精度限流(如 1ms 粒度):滑动窗口配合原子操作,避免锁竞争。
性能优化提示:在 Go 中实现限流器时,优先使用 atomic 操作替代 sync.Mutex;对于令牌桶和漏桶,采用“时间戳计算”而非定时器,可避免 goroutine 泄漏和调度延迟。实测显示,基于时间戳的令牌桶在 10 万 QPS 下,单次 Allow() 耗时 < 50ns。