4. 滑动窗口算法:滑动日志与滑动窗口原理、精度与内存权衡、在秒杀/抢购场景的应用

滑动窗口算法是金融级限流中最常用的时间窗口算法,它解决了固定窗口在边界突发流量下的“窗口跳跃”缺陷。本章深入剖析滑动日志与滑动窗口两种实现方式,对比精度与内存开销,并给出秒杀/抢购场景下的最佳实践。

4.1 固定窗口的边界缺陷

固定窗口算法(如每秒100次、每分钟6000次)在窗口切换瞬间存在严重漏洞:

  • 窗口跳跃:若请求集中在窗口末尾和下一个窗口开头,实际瞬时QPS可达阈值的2倍。
  • 不公平抢占:恶意客户端可精准在窗口边界发起洪峰,绕过限流。
  • 统计失真:金融风控要求平滑流量,固定窗口无法满足。
金融合规要求:银保监会《网上银行系统信息安全通用规范》明确要求“对交易接口实施平滑限流,防止窗口边界冲击”。滑动窗口是满足该要求的推荐方案。

4.2 滑动日志算法(精确但昂贵)

滑动日志为每个请求记录时间戳,每次判断时统计当前时间往前推一个窗口长度内的请求总数。本质上是时间戳集合的实时查询。

4.2.1 核心原理

  • 维护一个有序的时间戳列表(通常用Redis ZSET或内存跳表)。
  • 新请求到达时,将当前时间戳加入列表。
  • 移除窗口范围之外(now - windowSize)的过期时间戳。
  • 统计剩余时间戳数量,若超过阈值则拒绝。

4.2.2 精度与内存权衡

维度 滑动日志 滑动窗口(计数器)
精度 理论最高(毫秒级) 取决于子窗口粒度(通常100ms~1s)
内存开销 O(N),N为窗口内请求数 O(M),M为子窗口数(固定)
CPU开销 每次需清理过期数据 仅原子计数器增减
适用场景 低频高精度(如风控审核) 高频低延迟(如交易网关)
内存爆炸风险:在秒杀场景下,若窗口内请求量达百万级,滑动日志的内存占用可能超过1GB。金融生产环境通常限制滑动日志仅用于白名单或VIP通道。

4.2.3 滑动日志代码示例(Go)

package slidinglog

import (
    "sync"
    "time"
)

type SlidingLogLimiter struct {
    mu       sync.Mutex
    logs     []int64
    limit    int
    windowMs int64
}

func NewSlidingLogLimiter(limit int, window time.Duration) *SlidingLogLimiter {
    return &SlidingLogLimiter{
        logs:     make([]int64, 0, limit*2),
        limit:    limit,
        windowMs: window.Milliseconds(),
    }
}

func (l *SlidingLogLimiter) Allow() bool {
    l.mu.Lock()
    defer l.mu.Unlock()
    now := time.Now().UnixMilli()
    cutoff := now - l.windowMs
    // 移除过期日志
    i := 0
    for i < len(l.logs) && l.logs[i] < cutoff {
        i++
    }
    l.logs = l.logs[i:]
    // 判断是否超限
    if len(l.logs) >= l.limit {
        return false
    }
    l.logs = append(l.logs, now)
    return true
}

4.3 滑动窗口算法(计数器分片)

滑动窗口通过将时间窗口切分为多个子窗口(bucket),用计数器数组近似模拟滑动效果。这是金融API限流最主流的实现。

4.3.1 原理与分片策略

  • 将窗口(如1秒)切为N个子窗口(如10个,每个100ms)。
  • 每个子窗口维护一个原子计数器。
  • 当前时间落在哪个子窗口,就递增该子窗口计数器。
  • 总请求量 = 当前子窗口 + 前(N-1)个子窗口的计数器之和。
  • 子窗口过期后自动归零或复用。

4.3.2 精度与内存权衡

子窗口数量 精度(误差) 内存占用 推荐场景
2~5 ±20%~50% 极低 非关键业务兜底
10 ±10% 低 通用交易限流
20~50 ±2%~5% 中等 秒杀/抢购
100+ ±1%以内 较高 高频风控审核
工程经验:金融交易网关通常选择10~20个子窗口。10个子窗口的误差约±10%,但内存仅为20个int64(约160字节),远低于滑动日志。

4.3.3 滑动窗口状态流转图

滑动窗口(计数器分片)状态流转 Bucket[0] Bucket[1] Bucket[2] Bucket[3] Bucket[4] Bucket[5] 当前时间 滑动窗口范围(当前Bucket[3] + 前5个Bucket) 总和 > 阈值? 拒绝请求 允许请求 当前活跃

4.4 在秒杀/抢购场景的应用

秒杀/抢购对限流有特殊要求:瞬时流量可达正常值的100~1000倍,且要求公平性、防刷单、低延迟。滑动窗口算法在此场景下需做针对性优化。

4.4.1 秒杀限流架构

秒杀/抢购滑动窗口限流架构 海量客户端请求 全局滑动窗口(总QPS限流) 子窗口数=20,窗口=1s 用户维度滑动窗口(防刷单) 每个用户独立计数器,窗口=10s,阈值=5次 库存扣减(最终一致性) Redis原子递减 + 异步对账 第一层:削峰填谷 第二层:公平限流 第三层:保库存安全

4.4.2 秒杀场景参数配置

参数 推荐值 说明
全局窗口大小 1秒 匹配秒杀峰值粒度
子窗口数量 20 精度±5%,内存可接受
用户窗口大小 10秒 防止快速重试刷单
用户窗口阈值 3~5次 正常用户操作频率
库存预扣窗口 500ms 防止库存超卖

4.4.3 避坑指南

  • 不要使用滑动日志做秒杀:百万级请求的内存开销会击穿Redis或本地内存。
  • 子窗口数量不宜超过50:超过50个后精度提升有限,但内存和计算开销线性增长。
  • 用户维度窗口必须独立:全局窗口只能控制总量,无法防止单个用户刷单。
  • 库存扣减不要依赖限流窗口:限流只做流量整形,库存扣减需独立原子操作。
生产案例:某支付平台在2023年双十一秒杀中,使用20子窗口的滑动窗口(全局QPS 50万)+ 用户维度10秒5次窗口,成功拦截99.7%的刷单请求,系统零宕机。

4.5 总结

滑动窗口算法在精度与内存之间提供了灵活的权衡空间:

  • 滑动日志:精度最高,但内存开销随请求量线性增长,适合低频高精度场景(如风控审核)。
  • 滑动窗口(计数器分片):内存固定,精度可通过子窗口数量调节,是金融API限流的主流选择。
  • 秒杀/抢购:必须采用“全局窗口 + 用户维度窗口”双层架构,子窗口数推荐20,兼顾精度与性能。