4. 滑动窗口限流算法:解决固定窗口边界突刺问题,基于环形数组的Go实现

固定窗口算法(如每秒允许100个请求)存在一个经典缺陷:边界突刺。假设窗口大小为1秒,阈值100。如果请求在第0.9秒到第1.0秒之间集中发送100个,紧接着在第1.0秒到第1.1秒之间又集中发送100个,那么在这0.2秒的时间段内,系统实际承受了200个请求的冲击,远超阈值。滑动窗口算法通过将时间窗口细分为多个小格子,让窗口平滑移动,从而消除边界突刺。

核心思想: 将固定窗口划分为 N 个更小的子窗口(例如将1秒划分为10个100ms的格子)。每个子窗口独立计数。滑动窗口的总计数等于当前时间点所在窗口覆盖的所有子窗口计数之和。窗口每经过一个子窗口的时间,就向前滑动一格,丢弃最旧的一格,加入最新的一格。

4.1 滑动窗口 vs 固定窗口:边界突刺对比

对比维度 固定窗口算法 滑动窗口算法
窗口粒度 一个粗粒度窗口(如1s) 细分为多个子窗口(如10个100ms)
边界突刺 严重:窗口切换瞬间可涌入2倍流量 消除:窗口平滑移动,流量被均匀分摊
精度 低,仅能保证窗口内总量 高,能限制任意连续子窗口时间段的流量
实现复杂度 低,仅需一个计数器+时间戳 中等,需要环形数组或链表维护多个计数器
内存开销 O(1) O(N),N为子窗口数量
关键结论: 滑动窗口通过增加子窗口数量(N),将边界突刺的影响限制在单个子窗口的粒度内。N 越大,流量整形越平滑,但内存和计算开销也相应增加。实践中 N 通常取 10~100。

4.2 环形数组实现原理

使用环形数组(Circular Array / Ring Buffer)是实现滑动窗口最高效的方式之一。它避免了频繁的数组元素移动,仅通过移动头尾指针来维护窗口。

  1. 数据结构: 一个固定长度的数组 buckets[N],每个元素存储一个子窗口的请求计数。两个指针:head(指向最旧的子窗口)、tail(指向最新的子窗口)。一个变量 totalCount 缓存当前窗口总计数。
  2. 滑动逻辑: 当时间前进到新的子窗口时,tail 指针向前移动一位(取模)。如果 tail 追上了 head,说明窗口已满,需要将 head 也向前移动一位(丢弃最旧子窗口),并从 totalCount 中减去被丢弃子窗口的计数。
  3. 请求到达: 计算当前时间所属的子窗口索引。如果该子窗口已过期(被 head 跳过),则重置其计数为0。递增该子窗口计数,同时递增 totalCount。
  4. 限流判断: 检查 totalCount 是否超过阈值。若超过,则拒绝请求。
环形数组滑动窗口结构图 12 8 15 10 22 5 18 7 11 9 T-9 T-8 T-7 T-6 T-5 (head) T-4 T-3 T-2 T-1 T (tail) 当前滑动窗口(覆盖10个子窗口) 时间前进方向 → 窗口向右滑动 丢弃 新格子加入 新 • head 指向最旧子窗口(T-5),tail 指向最新子窗口(T) • 当 tail 前进时,若覆盖 head,则 head 前移,旧格子计数从 totalCount 中减去

4.3 Go 语言实现:基于环形数组的滑动窗口限流器

以下实现使用环形数组存储子窗口计数,通过 sync.Mutex 保证并发安全。核心方法包括 Allow() 和 AllowN()。

package slidingwindow

import (
    "sync"
    "time"
)

// SlidingWindow 滑动窗口限流器
type SlidingWindow struct {
    mu          sync.Mutex
    buckets     []int64        // 环形数组,存储每个子窗口的计数
    head        int            // 指向最旧子窗口
    tail        int            // 指向最新子窗口
    totalCount  int64          // 当前窗口总计数
    windowSize  time.Duration  // 窗口总大小(如 1s)
    bucketSize  time.Duration  // 子窗口大小(如 100ms)
    threshold   int64          // 阈值
    lastTime    time.Time      // 上次请求时间,用于计算偏移
}

// NewSlidingWindow 创建一个滑动窗口限流器
// windowSize: 窗口总大小,如 1*time.Second
// bucketCount: 子窗口数量,如 10
// threshold: 阈值
func NewSlidingWindow(windowSize time.Duration, bucketCount int, threshold int64) *SlidingWindow {
    bucketSize := windowSize / time.Duration(bucketCount)
    return &SlidingWindow{
        buckets:    make([]int64, bucketCount),
        head:       0,
        tail:       bucketCount - 1,
        windowSize: windowSize,
        bucketSize: bucketSize,
        threshold:  threshold,
        lastTime:   time.Now(),
    }
}

// Allow 判断是否允许一个请求通过
func (sw *SlidingWindow) Allow() bool {
    return sw.AllowN(1)
}

// AllowN 判断是否允许 N 个请求通过
func (sw *SlidingWindow) AllowN(n int64) bool {
    sw.mu.Lock()
    defer sw.mu.Unlock()

    now := time.Now()
    sw.advance(now)

    if sw.totalCount+n > sw.threshold {
        return false
    }

    // 更新当前子窗口计数
    sw.buckets[sw.tail] += n
    sw.totalCount += n
    return true
}

// advance 推进窗口到当前时间
func (sw *SlidingWindow) advance(now time.Time) {
    elapsed := now.Sub(sw.lastTime)
    if elapsed < sw.bucketSize {
        return // 仍在当前子窗口内,无需滑动
    }

    // 计算需要滑动的格子数
    steps := int(elapsed / sw.bucketSize)
    bucketCount := len(sw.buckets)

    for i := 0; i < steps; i++ {
        // tail 前进一格
        sw.tail = (sw.tail + 1) % bucketCount

        // 如果 tail 追上了 head,说明窗口已满,需要丢弃最旧子窗口
        if sw.tail == sw.head {
            // 丢弃 head 指向的旧格子
            sw.totalCount -= sw.buckets[sw.head]
            sw.buckets[sw.head] = 0
            sw.head = (sw.head + 1) % bucketCount
        }

        // 重置新 tail 格子(可能被旧数据占据)
        sw.buckets[sw.tail] = 0
    }

    sw.lastTime = now
}

// CurrentCount 返回当前窗口总计数
func (sw *SlidingWindow) CurrentCount() int64 {
    sw.mu.Lock()
    defer sw.mu.Unlock()
    sw.advance(time.Now())
    return sw.totalCount
}
注意事项:
  • 时间精度: 如果请求间隔远大于 bucketSize,advance 方法会一次性滑动多个格子,这是正确的行为。
  • 并发安全: 使用 sync.Mutex 保护共享状态。在高并发场景下,可考虑使用 sync.RWMutex 或原子操作优化读性能。
  • 子窗口数量选择: 建议在 10~100 之间。太少则边界突刺抑制效果差;太多则内存开销大,且频繁滑动可能影响性能。
  • 初始化状态: 所有子窗口计数为0,totalCount 为0,窗口从第一个请求到达时开始滑动。

4.4 使用示例与测试

package main

import (
    "fmt"
    "time"
    "your-project/slidingwindow"
)

func main() {
    // 创建滑动窗口限流器:窗口1秒,10个子窗口,阈值100
    sw := slidingwindow.NewSlidingWindow(1*time.Second, 10, 100)

    // 模拟请求
    for i := 0; i < 120; i++ {
        if sw.Allow() {
            fmt.Printf("请求 %d: 通过\n", i+1)
        } else {
            fmt.Printf("请求 %d: 被限流\n", i+1)
        }
        time.Sleep(10 * time.Millisecond) // 每10ms发一个请求
    }

    // 输出最终计数
    fmt.Printf("当前窗口计数: %d\n", sw.CurrentCount())
}
运行结果预期: 前100个请求通过,第101~120个请求被限流。由于窗口是滑动的,当旧请求逐渐离开窗口时,新的请求会重新获得通过机会。与固定窗口不同,不会出现窗口切换瞬间的流量突刺。

4.5 滑动窗口 vs 令牌桶 vs 漏桶:选型建议

算法 核心机制 适用场景 优点 缺点
滑动窗口 细分子窗口,平滑移动 需要精确控制任意时间段流量 消除边界突刺,精度可调 内存开销随子窗口数增加
令牌桶 匀速生成令牌,突发可消费 允许一定突发流量,如API调用 支持突发,实现简单 无法精确限制短时间窗口
漏桶 恒定速率出水,强制整形 需要严格平滑流量,如数据库写入 输出流量绝对平滑 无法应对突发,可能丢弃请求
实践建议: 对于大多数业务接口限流场景,滑动窗口算法是“精度”与“复杂度”的最佳平衡点。如果业务允许少量突发,令牌桶更简单;如果要求输出绝对平滑,漏桶更合适。建议在项目初期使用滑动窗口,后续根据监控数据调整。