4. 限流算法(三):滑动窗口计数器(分桶)算法原理、精度与性能权衡、Go实现
固定窗口计数器在窗口边界处存在流量突刺的缺陷:若请求集中在窗口末尾与下一窗口开头,瞬间流量可达阈值的两倍。滑动窗口计数器通过将时间窗口细分为多个“桶”,让窗口以更细粒度平滑滑动,从而大幅提升限流精度。
4.1 核心原理:从“整窗跳跃”到“逐桶滑动”
滑动窗口计数器将固定时间窗口 T 划分为 N 个等长的时间片(桶),每个桶独立记录该时间片内的请求计数。窗口的滑动不再是整窗跳跃,而是每次前进一个桶的时长,窗口覆盖最新的 N 个桶。
关键概念: 窗口 = 最新的 N 个桶的计数之和。每经过一个桶的时间,窗口丢弃最旧的桶,加入最新的桶。
上图中,窗口大小为 6 个桶。当时间推进一个桶的时长后,窗口丢弃最旧的桶 1,加入最新的桶 8,始终保持覆盖最新的 6 个桶。桶的粒度越细,窗口滑动越平滑,限流精度越高。
4.2 精度与性能的权衡
桶的数量 N 是滑动窗口算法的核心参数,直接决定了精度与内存/计算开销的平衡。
| 桶数量 N | 精度表现 | 内存开销 | 计算开销 | 适用场景 |
|---|---|---|---|---|
| 1(退化固定窗口) | 低,存在边界突刺 | 极低(1 个计数器) | 极低 | 对精度无要求,资源极度受限 |
| 2 ~ 5 | 中等,突刺明显减弱 | 低 | 低 | 允许少量突刺,追求低内存 |
| 6 ~ 20 | 良好,突刺可接受 | 中等 | 中等 | 大多数生产环境(推荐 10 桶) |
| 50 ~ 100 | 高,接近理想滑动 | 较高 | 较高 | 高精度要求,内存充足 |
| > 100 | 极高,但收益递减 | 高 | 高 | 极少需要,通常用令牌桶替代 |
实践建议: 对于 1 秒窗口,10 个桶(每个桶 100ms)通常能在精度和性能之间取得良好平衡。若窗口为 1 分钟,可考虑 60 个桶(每桶 1 秒),内存仍可接受。
4.3 Go 语言实现:分桶滑动窗口
以下实现使用环形数组存储桶计数,避免每次滑动时移动数据。通过互斥锁保证并发安全。
package slidingwindow
import (
"sync"
"time"
)
// SlidingWindow 分桶滑动窗口限流器
type SlidingWindow struct {
mu sync.Mutex
buckets []int64 // 环形数组,每个桶存储该时间片内的请求数
bucketSize time.Duration // 每个桶的时间跨度
windowSize int64 // 窗口总大小(纳秒)
bucketCount int // 桶的数量
head int // 当前最旧桶的索引
lastTime time.Time // 上次更新时间
threshold int64 // 窗口内允许的最大请求数
}
// NewSlidingWindow 创建滑动窗口限流器
// window: 窗口总时长,如 1s
// bucketCount: 桶的数量,推荐 10
// threshold: 窗口内允许的最大请求数
func NewSlidingWindow(window time.Duration, bucketCount int, threshold int64) *SlidingWindow {
return &SlidingWindow{
buckets: make([]int64, bucketCount),
bucketSize: window / time.Duration(bucketCount),
windowSize: window.Nanoseconds(),
bucketCount: bucketCount,
head: 0,
lastTime: time.Now(),
threshold: threshold,
}
}
// Allow 判断是否允许当前请求通过
func (sw *SlidingWindow) Allow() bool {
sw.mu.Lock()
defer sw.mu.Unlock()
now := time.Now()
sw.advance(now)
// 计算当前窗口内总请求数
var total int64
for i := 0; i < sw.bucketCount; i++ {
total += sw.buckets[i]
}
if total >= sw.threshold {
return false
}
// 将请求计入当前桶
currentBucket := (sw.head + sw.bucketCount - 1) % sw.bucketCount
sw.buckets[currentBucket]++
return true
}
// advance 推进窗口,丢弃过期的桶
func (sw *SlidingWindow) advance(now time.Time) {
elapsed := now.Sub(sw.lastTime).Nanoseconds()
if elapsed < sw.bucketSize.Nanoseconds() {
return
}
// 计算需要跳过的桶数
skipBuckets := int(elapsed / sw.bucketSize.Nanoseconds())
if skipBuckets > sw.bucketCount {
skipBuckets = sw.bucketCount // 最多跳过所有桶
}
for i := 0; i < skipBuckets; i++ {
sw.head = (sw.head + 1) % sw.bucketCount
sw.buckets[sw.head] = 0 // 清空被丢弃的桶
}
sw.lastTime = now
}
4.4 实现要点与边界处理
- 环形数组索引:
head指向最旧的桶,新请求写入(head + bucketCount - 1) % bucketCount,避免数据搬移。 - 时间跳跃处理: 若两次请求间隔超过一个窗口,
advance会跳过所有桶,重置整个窗口,防止计数残留。 - 桶大小对齐:
bucketSize = window / bucketCount必须整除,否则最后一个桶的时间跨度不一致,影响精度。 - 原子操作替代锁: 高并发场景可使用
sync/atomic配合无锁环形队列,但实现复杂度显著增加。
注意: 上述实现中,
advance 仅在请求到达时被动推进。若长时间无请求,窗口不会自动滑动,但首次请求会一次性跳过所有过期桶,逻辑正确。若需要主动推进,可启动后台定时器定期调用 advance。
4.5 滑动窗口 vs 固定窗口:精度对比
| 对比维度 | 固定窗口计数器 | 滑动窗口计数器(10 桶) |
|---|---|---|
| 边界突刺 | 允许 2 倍阈值突刺 | 最多允许 (1 + 1/10) 倍阈值突刺 |
| 窗口滑动方式 | 整窗跳跃 | 逐桶平滑滑动 |
| 内存开销 | 1 个计数器 | N 个计数器 + 1 个时间戳 |
| 计算复杂度 | O(1) | O(N) 求和(可优化为 O(1) 缓存总和) |
| 适用场景 | 允许突刺,资源极受限 | 需要平滑限流,通用场景 |
优化技巧: 维护一个
total 变量缓存窗口总计数,每次滑动时 total = total - 丢弃桶计数 + 新桶计数,可将求和复杂度从 O(N) 降为 O(1)。上述示例代码为清晰展示原理未做此优化,生产环境建议实现。