3. 限流算法(二):滑动窗口日志算法原理、内存开销分析、Go实现

固定窗口算法在窗口边界存在“突刺”问题——流量可以在窗口切换瞬间集中涌入,导致后端瞬时过载。滑动窗口日志算法(Sliding Window Log)通过记录每个请求的时间戳,在滑动的时间区间内精确计数,从根本上消除了边界突刺。

3.1 算法原理

滑动窗口日志算法的核心思想是:维护一个有序的时间戳日志,每次请求到达时,移除窗口范围之外的所有过期时间戳,然后判断剩余时间戳数量是否超过阈值。

关键特征: 精度为毫秒级,窗口是真正连续的滑动区间,不存在固定窗口的边界重置行为。

算法步骤:

  1. 记录请求:每个请求到达时,将其时间戳追加到日志尾部。
  2. 清理过期:从日志头部移除所有 timestamp < now - windowSize 的记录。
  3. 计数判断:统计日志中剩余时间戳数量,若 count < limit 则允许通过,否则拒绝。
滑动窗口日志算法流程 当前滑动窗口 [now - 1s, now] 过期 t1 t2 t3 t4 t5 新请求 判断:窗口内时间戳数量 (5) < 阈值 (10)? → 允许通过,记录新时间戳 • 窗口左边界 = now - windowSize,右边界 = now • 每次请求到达时先清理过期时间戳,再判断计数 • 精度取决于时间戳粒度(通常为毫秒)

3.2 内存开销分析

滑动窗口日志算法的精度优势以内存开销为代价。每个请求都需要在内存中保留一个时间戳,对于高并发场景,内存占用可能成为瓶颈。

指标 固定窗口 滑动窗口日志 滑动窗口日志(优化后)
内存/请求 0(无记录) 8~16 字节(时间戳) 8~16 字节
总内存(QPS=1000, 窗口=1s) ~0 ~16 KB ~16 KB
总内存(QPS=10000, 窗口=1s) ~0 ~160 KB ~160 KB
总内存(QPS=100000, 窗口=1s) ~0 ~1.6 MB ~1.6 MB
GC 压力 无 高(频繁分配/回收) 中(对象池复用)
清理策略 重置计数器 惰性删除(请求时清理) 惰性删除 + 定时批量清理
内存陷阱: 当窗口较大(如 10 秒)且 QPS 较高(如 10 万)时,日志中可能同时存在 100 万个时间戳,占用约 16 MB 内存。如果每个时间戳使用 time.Time(24 字节)而非 int64(8 字节),内存占用会膨胀到 48 MB 以上。

3.3 Go 实现:基础版

以下实现使用 sync.Mutex 保护并发安全,时间戳存储为 int64 毫秒值,采用惰性删除策略。

package slidingwindow

import (
    "sync"
    "time"
)

// SlidingWindowLog 滑动窗口日志限流器
type SlidingWindowLog struct {
    mu         sync.Mutex
    timestamps []int64   // 有序时间戳列表(毫秒)
    limit      int       // 窗口内允许的最大请求数
    window     int64     // 窗口大小(毫秒)
}

// NewSlidingWindowLog 创建滑动窗口日志限流器
func NewSlidingWindowLog(limit int, window time.Duration) *SlidingWindowLog {
    return &SlidingWindowLog{
        timestamps: make([]int64, 0, limit),
        limit:      limit,
        window:     window.Milliseconds(),
    }
}

// Allow 判断当前请求是否允许通过
func (s *SlidingWindowLog) Allow() bool {
    s.mu.Lock()
    defer s.mu.Unlock()

    now := time.Now().UnixMilli()
    boundary := now - s.window

    // 1. 清理过期时间戳(惰性删除)
    // 利用二分查找找到第一个未过期的时间戳位置
    left := 0
    right := len(s.timestamps)
    for left < right {
        mid := left + (right-left)/2
        if s.timestamps[mid] < boundary {
            left = mid + 1
        } else {
            right = mid
        }
    }
    s.timestamps = s.timestamps[left:]

    // 2. 判断是否超过阈值
    if len(s.timestamps) >= s.limit {
        return false
    }

    // 3. 记录当前请求时间戳
    s.timestamps = append(s.timestamps, now)
    return true
}

3.4 Go 实现:优化版(对象池 + 预分配)

基础版在高并发下存在两个性能问题:切片频繁扩容和大量短生命周期对象。优化版引入 sync.Pool 和预分配策略。

package slidingwindow

import (
    "sync"
    "time"
)

// OptimizedSlidingWindowLog 优化版滑动窗口日志限流器
type OptimizedSlidingWindowLog struct {
    mu         sync.Mutex
    timestamps []int64
    limit      int
    window     int64
    pool       sync.Pool
}

// NewOptimizedSlidingWindowLog 创建优化版限流器
func NewOptimizedSlidingWindowLog(limit int, window time.Duration) *OptimizedSlidingWindowLog {
    return &OptimizedSlidingWindowLog{
        timestamps: make([]int64, 0, limit),
        limit:      limit,
        window:     window.Milliseconds(),
        pool: sync.Pool{
            New: func() interface{} {
                // 预分配与 limit 等长的切片,减少扩容
                buf := make([]int64, 0, limit)
                return &buf
            },
        },
    }
}

// Allow 判断请求是否允许通过(优化版)
func (o *OptimizedSlidingWindowLog) Allow() bool {
    o.mu.Lock()
    defer o.mu.Unlock()

    now := time.Now().UnixMilli()
    boundary := now - o.window

    // 二分查找清理过期时间戳
    left := 0
    right := len(o.timestamps)
    for left < right {
        mid := left + (right-left)/2
        if o.timestamps[mid] < boundary {
            left = mid + 1
        } else {
            right = mid
        }
    }

    // 复用底层数组,避免内存分配
    if left > 0 {
        copy(o.timestamps, o.timestamps[left:])
        o.timestamps = o.timestamps[:len(o.timestamps)-left]
    }

    if len(o.timestamps) >= o.limit {
        return false
    }

    o.timestamps = append(o.timestamps, now)
    return true
}

// Reset 重置限流器(释放内存)
func (o *OptimizedSlidingWindowLog) Reset() {
    o.mu.Lock()
    defer o.mu.Unlock()
    o.timestamps = make([]int64, 0, o.limit)
}
内存分配对比:基础版 vs 优化版 基础版 每次清理后可能触发切片扩容 GC 压力:高 内存分配次数:频繁 优化版 预分配 + copy 复用底层数组 GC 压力:低 内存分配次数:极少 优化要点 1. 使用 copy() 将未过期时间戳前移,复用底层数组,避免 append 触发扩容 2. 初始容量预分配为 limit,减少切片扩容次数 3. 提供 Reset() 方法,在低峰期主动释放内存

3.5 性能对比与选型建议

场景 推荐算法 原因
QPS < 1000,精度要求高 滑动窗口日志(基础版) 内存开销可忽略,实现简单
QPS 1000~10000,精度要求高 滑动窗口日志(优化版) 对象池 + 预分配减少 GC 压力
QPS > 100000,精度要求高 滑动窗口计数器(下一章) 日志算法内存开销过大,需折中
QPS 极高,可容忍少量误差 令牌桶 / 漏桶 固定内存,无 GC 压力
实战建议: 在 Go 微服务中,滑动窗口日志算法适合用于接口级限流(如单个 API 的 QPS 限制),因为每个接口的并发量通常可控。对于全局限流(如整个网关的入站流量),建议使用滑动窗口计数器或令牌桶算法,避免内存膨胀。

3.6 边界情况与注意事项

  • 时间戳溢出:int64 毫秒时间戳在公元 292 年之前不会溢出,可安全使用。
  • 时钟回拨:如果服务器时钟发生回拨,清理逻辑可能失效。建议在检测到 now < lastTimestamp 时清空日志并重置。
  • 并发安全:所有公开方法必须加锁,Allow() 方法应保持原子性。
  • 窗口边界精度:毫秒级精度已足够应对绝大多数场景。如果需要纳秒级精度,需考虑性能开销。