4. 滑动窗口限流算法:原理详解、Go语言实现滑动窗口计数器、内存与精度权衡
4.1 滑动窗口算法原理
滑动窗口(Sliding Window)限流算法是对固定窗口算法的改进,旨在解决固定窗口在窗口边界处的“突发流量”问题。其核心思想是将时间窗口细分为多个更小的“子窗口”(slot),并让窗口随着时间推移平滑地向前滑动,而非在整点时刻重置计数器。
核心改进点: 固定窗口在窗口切换瞬间允许 2 倍于阈值的突发请求(例如 0:59 和 1:00 各 100 个请求,若阈值为 100,则 1 秒内实际通过 200 个)。滑动窗口通过子窗口的连续滑动,将突发容忍度限制在单个子窗口的容量内。
4.2 滑动窗口计数器模型
滑动窗口计数器通常采用“环形数组”或“时间轮”数据结构。我们将一个完整的时间窗口(如 1 秒)划分为 N 个等长的子窗口(如 10 个,每个 100ms)。每个子窗口维护一个独立的计数器。窗口的滑动通过移动起始指针实现,每次请求到来时,计算当前时间所属的子窗口索引,并累加该子窗口的计数。同时,需要将已过期(超出当前窗口范围)的子窗口计数器归零。
| 参数 | 说明 | 典型值 |
|---|---|---|
| 窗口大小 (WindowSize) | 限流的时间跨度 | 1 秒 / 10 秒 / 1 分钟 |
| 子窗口数量 (SlotCount) | 窗口被划分的份数 | 10 / 20 / 50 |
| 子窗口时长 (SlotDuration) | 每个子窗口的时间跨度 | WindowSize / SlotCount |
| 阈值 (Threshold) | 窗口内允许的最大请求数 | 100 / 1000 |
4.3 Go 语言实现滑动窗口计数器
以下实现采用环形数组(slice)存储子窗口计数器,配合互斥锁保证并发安全。核心逻辑包括:计算当前子窗口索引、清理过期子窗口、累加计数并判断是否超过阈值。
package slidingwindow
import (
"sync"
"time"
)
// SlidingWindow 滑动窗口限流器
type SlidingWindow struct {
mu sync.Mutex
windowSize time.Duration // 窗口总大小,如 1s
slotCount int // 子窗口数量
slotDuration time.Duration // 每个子窗口时长
slots []int64 // 环形数组,存储每个子窗口的计数
cursor int // 当前写入的子窗口索引
lastTime time.Time // 上次更新时间
threshold int64 // 阈值
}
// NewSlidingWindow 创建滑动窗口限流器
func NewSlidingWindow(windowSize time.Duration, slotCount int, threshold int64) *SlidingWindow {
return &SlidingWindow{
windowSize: windowSize,
slotCount: slotCount,
slotDuration: windowSize / time.Duration(slotCount),
slots: make([]int64, slotCount),
cursor: 0,
lastTime: time.Now(),
threshold: threshold,
}
}
// Allow 判断是否允许请求通过
func (sw *SlidingWindow) Allow() bool {
sw.mu.Lock()
defer sw.mu.Unlock()
now := time.Now()
// 1. 计算当前时间对应的子窗口索引
elapsed := now.Sub(sw.lastTime)
if elapsed < 0 {
elapsed = 0
}
moveSteps := int(elapsed / sw.slotDuration)
if moveSteps > sw.slotCount {
moveSteps = sw.slotCount // 最多移动整个窗口
}
// 2. 滑动窗口:将跳过的子窗口计数器归零
for i := 0; i < moveSteps; i++ {
sw.cursor = (sw.cursor + 1) % sw.slotCount
sw.slots[sw.cursor] = 0
}
sw.lastTime = now
// 3. 累加当前子窗口计数
sw.slots[sw.cursor]++
// 4. 计算窗口内总计数
var total int64
for _, count := range sw.slots {
total += count
}
// 5. 判断是否超过阈值
if total > sw.threshold {
// 超过阈值,回滚当前计数
sw.slots[sw.cursor]--
return false
}
return true
}
实现要点:
- 时间跳跃处理: 当系统时间发生跳跃(如 NTP 同步)时,
moveSteps可能很大,需限制最大移动步数为slotCount,避免数组越界或长时间循环。 - 原子性与锁: 使用
sync.Mutex保证计数器的并发安全。在高并发场景下,可考虑使用sync/atomic或分段锁优化性能。 - 精度与性能权衡: 子窗口数量越多,限流精度越高,但计算总计数时的循环开销也越大。
4.4 内存与精度权衡
滑动窗口算法的核心权衡在于子窗口数量(slotCount)的选择。子窗口数量直接影响限流的平滑度和内存占用。
| 子窗口数量 | 精度(突发容忍度) | 内存占用 | 计算开销 | 适用场景 |
|---|---|---|---|---|
| 少(如 5-10) | 低(允许 1/5 ~ 1/10 窗口大小的突发) | 低(几十字节) | 低(循环次数少) | 对精度要求不高的场景,如简单 API 限流 |
| 中(如 20-50) | 中(突发控制在 1/20 ~ 1/50 窗口大小) | 中(几百字节) | 中 | 大多数生产环境,如网关限流 |
| 多(如 100-1000) | 高(接近连续滑动,突发极小) | 高(几 KB ~ 几十 KB) | 高(每次请求需遍历较多子窗口) | 金融交易、实时竞价等对流量平滑度要求极高的场景 |
性能陷阱: 当子窗口数量超过 100 时,每次请求都需要遍历所有子窗口计算总和,在 QPS 超过 10 万时可能成为瓶颈。优化方案包括:
- 增量计算: 维护一个
total变量,在滑动时减去过期子窗口的计数,加上新子窗口的计数,避免全量遍历。 - 使用原子操作: 对单个子窗口的计数使用
atomic.AddInt64,减少锁竞争。 - 分层滑动窗口: 结合固定窗口和滑动窗口,在毫秒级使用固定窗口,在秒级使用滑动窗口。
4.5 滑动窗口 vs 固定窗口 vs 令牌桶
| 特性 | 固定窗口 | 滑动窗口 | 令牌桶 |
|---|---|---|---|
| 突发容忍度 | 高(窗口边界 2 倍阈值) | 低(仅单个子窗口容量) | 中(受桶容量限制) |
| 实现复杂度 | 低 | 中 | 中 |
| 内存占用 | 低(1 个计数器) | 中(N 个子窗口计数器) | 低(2 个变量) |
| 平滑度 | 差(锯齿状) | 好(接近连续) | 好(匀速 + 突发) |
| 适用场景 | 简单限流,可接受突发 | 对流量平滑度有要求的场景 | 需要控制平均速率和突发流量的场景 |
选型建议: 滑动窗口算法在精度和实现复杂度之间取得了良好的平衡,是大多数微服务网关和 API 限流的首选方案。当业务对突发流量极其敏感时,可考虑使用滑动窗口 + 令牌桶的混合策略。