第1章:滑动窗口算法——基于时间轮的滑动窗口实现

大家好,我是你们的老朋友。今天咱们聊聊风控系统里一个特别核心的东西——滑动窗口算法。

说实话,我刚入行那会儿,对滑动窗口的理解也就停留在「统计最近N秒的请求数」这个层面。直到有一次线上事故,让我彻底重新审视了这个看似简单的算法。

那次事故是这样的:我们的限流策略用的是固定窗口,每分钟重置一次计数器。结果呢?用户在59秒的时候疯狂刷请求,然后下一秒计数器重置,又刷一波。峰值直接打穿了后端服务。嗯,从那以后,我再也不敢用固定窗口做高精度限流了。

为什么需要滑动窗口?

固定窗口的问题说白了就是「边界突变」。你想想看,窗口切换的那一瞬间,旧窗口的计数清零,新窗口从零开始。攻击者只要卡准这个时间点,就能轻松绕过限制。

滑动窗口就不一样了。它没有固定的边界,而是以当前时间为终点,往前推N秒形成一个动态窗口。每一秒都在移动,每一秒都在重新计算。这样就不会有「窗口切换」的漏洞。

我个人的习惯是:凡是涉及实时统计的场景,一律用滑动窗口。不管是限流、熔断,还是异常检测,滑动窗口都是最稳妥的选择。

时间轮模型:滑动窗口的底层实现

滑动窗口的实现方式有好几种,但我最推荐的是时间轮模型。为什么?因为它性能好、内存占用低,特别适合高并发场景。

时间轮的核心思想很简单:把时间切成一个个小格子(bucket),每个格子代表一个时间片段。比如我们要统计最近10秒的数据,就把10秒切成10个格子,每个格子1秒。请求来了,根据当前时间戳找到对应的格子,把计数累加进去。

这里有个关键点:格子是循环使用的。时间轮本质上是一个环形数组,指针每过一秒就往前移动一格。超过N秒的旧数据会被新数据覆盖掉。这样我们只需要维护N个格子的内存,而不是无限增长。

我画了一张图,帮你理解这个结构:

t0 t1 t2 t3 t4 t5 t6 t7 t8 t9 当前指针 时间轮环形数组结构(10个格子,循环使用)

你看,指针指向当前时间对应的格子。每过一秒,指针就顺时针移动一格。旧格子里的数据会被新数据覆盖。这样我们永远只保留最近N秒的数据。

代码实现:一个简洁的滑动窗口

光说不练假把式。我写了一个Go版本的滑动窗口实现,你可以直接拿去用:

package slidingwindow

import (
    "sync"
    "time"
)

// Bucket 时间格子
type Bucket struct {
    Success int64 // 成功计数
    Fail    int64 // 失败计数
}

// SlidingWindow 滑动窗口
type SlidingWindow struct {
    buckets    []*Bucket
    windowSize int           // 窗口大小(秒)
    bucketSize int           // 格子数量
    interval   time.Duration // 每个格子的时间跨度
    mu         sync.RWMutex
    lastTick   int64 // 上次更新的时间戳(秒)
}

// NewSlidingWindow 创建一个滑动窗口
// windowSize: 统计最近多少秒的数据
// bucketSize: 切成多少个格子
func NewSlidingWindow(windowSize, bucketSize int) *SlidingWindow {
    buckets := make([]*Bucket, bucketSize)
    for i := 0; i < bucketSize; i++ {
        buckets[i] = &Bucket{}
    }
    return &SlidingWindow{
        buckets:    buckets,
        windowSize: windowSize,
        bucketSize: bucketSize,
        interval:   time.Duration(windowSize/bucketSize) * time.Second,
        lastTick:   time.Now().Unix(),
    }
}

// getCurrentBucket 获取当前时间对应的格子
func (sw *SlidingWindow) getCurrentBucket() *Bucket {
    now := time.Now().Unix()
    elapsed := now - sw.lastTick
    
    // 如果时间跳跃超过窗口大小,重置所有格子
    if elapsed > int64(sw.windowSize) {
        for i := 0; i < sw.bucketSize; i++ {
            sw.buckets[i].Success = 0
            sw.buckets[i].Fail = 0
        }
        sw.lastTick = now
        return sw.buckets[0]
    }
    
    // 推进指针,清理过期格子
    steps := int(elapsed / int64(sw.interval.Seconds()))
    if steps > 0 {
        for i := 0; i < steps && i < sw.bucketSize; i++ {
            idx := (int(sw.lastTick/int64(sw.interval.Seconds())) + i) % sw.bucketSize
            sw.buckets[idx].Success = 0
            sw.buckets[idx].Fail = 0
        }
        sw.lastTick = now
    }
    
    idx := int(now/int64(sw.interval.Seconds())) % sw.bucketSize
    return sw.buckets[idx]
}

// AddSuccess 增加成功计数
func (sw *SlidingWindow) AddSuccess() {
    sw.mu.Lock()
    defer sw.mu.Unlock()
    bucket := sw.getCurrentBucket()
    bucket.Success++
}

// AddFail 增加失败计数
func (sw *SlidingWindow) AddFail() {
    sw.mu.Lock()
    defer sw.mu.Unlock()
    bucket := sw.getCurrentBucket()
    bucket.Fail++
}

// GetStats 获取当前窗口内的统计数据
func (sw *SlidingWindow) GetStats() (success, fail int64) {
    sw.mu.RLock()
    defer sw.mu.RUnlock()
    
    now := time.Now().Unix()
    for i := 0; i < sw.bucketSize; i++ {
        bucketTime := sw.lastTick - int64(sw.windowSize) + int64(i)*int64(sw.interval.Seconds())
        if now-bucketTime <= int64(sw.windowSize) {
            success += sw.buckets[i].Success
            fail += sw.buckets[i].Fail
        }
    }
    return
}

核心要点:

  • getCurrentBucket() 负责定位当前格子,同时清理过期数据
  • 时间跳跃超过窗口大小时,直接重置所有格子——这是防攻击的关键
  • 使用读写锁 RWMutex,读多写少的场景性能更好

避坑指南:我踩过的那些坑

我曾经在一个高并发项目里直接用这个代码,结果线上出了几个问题。分享出来,你们别重蹈覆辙:

  • 时间戳精度问题:我一开始用的毫秒级时间戳,结果格子切得太细,频繁加锁导致性能下降。后来改成秒级,问题解决。建议:统计窗口大于5秒的,用秒级就够了
  • 格子数量选择:格子太多浪费内存,格子太少统计不精确。我一般取 windowSize / 10windowSize / 5 之间。比如10秒窗口,切10个格子,每个格子1秒,效果最好。
  • 并发安全:别以为加了锁就万事大吉。我遇到过一种情况:多个协程同时调用 getCurrentBucket(),导致格子被重复清理。解决方案是在清理逻辑里加一个版本号判断。

⚠️ 特别注意:

如果你的系统时间会被修改(比如NTP同步时往回跳),滑动窗口的统计会出大问题。我建议在初始化时记录一个基准时间,后续所有计算都基于这个基准时间的相对偏移,而不是直接使用系统时间戳。

性能对比:滑动窗口 vs 固定窗口

我拿一个实际压测数据给你看:

指标 固定窗口 滑动窗口(时间轮)
内存占用 2个计数器 N个格子(N=窗口大小/粒度)
单次操作耗时 ~50ns ~200ns(含锁开销)
边界突变问题 严重
抗突发流量
实现复杂度

你看,滑动窗口虽然单次操作比固定窗口慢一点,但也就几百纳秒的差距。对于风控系统来说,这点开销完全可以接受。换来的却是更平滑的限流效果和更强的抗攻击能力。

💡 我的建议:

如果你的QPS在10万以下,直接用滑动窗口没问题。如果QPS超过100万,可以考虑用原子操作替代锁,或者用无锁队列实现。不过那是进阶话题了,咱们后面再聊。

好了,滑动窗口的核心原理和实现就讲到这里。记住一句话:时间轮的本质是用空间换时间,用固定的内存开销换取平滑的统计能力。下一章咱们聊聊怎么把这个滑动窗口用到熔断器里,实现真正的毫秒级熔断。


专注资料整理