第1章:滑动窗口算法——基于时间轮的滑动窗口实现
大家好,我是你们的老朋友。今天咱们聊聊风控系统里一个特别核心的东西——滑动窗口算法。
说实话,我刚入行那会儿,对滑动窗口的理解也就停留在「统计最近N秒的请求数」这个层面。直到有一次线上事故,让我彻底重新审视了这个看似简单的算法。
那次事故是这样的:我们的限流策略用的是固定窗口,每分钟重置一次计数器。结果呢?用户在59秒的时候疯狂刷请求,然后下一秒计数器重置,又刷一波。峰值直接打穿了后端服务。嗯,从那以后,我再也不敢用固定窗口做高精度限流了。
为什么需要滑动窗口?
固定窗口的问题说白了就是「边界突变」。你想想看,窗口切换的那一瞬间,旧窗口的计数清零,新窗口从零开始。攻击者只要卡准这个时间点,就能轻松绕过限制。
滑动窗口就不一样了。它没有固定的边界,而是以当前时间为终点,往前推N秒形成一个动态窗口。每一秒都在移动,每一秒都在重新计算。这样就不会有「窗口切换」的漏洞。
我个人的习惯是:凡是涉及实时统计的场景,一律用滑动窗口。不管是限流、熔断,还是异常检测,滑动窗口都是最稳妥的选择。
时间轮模型:滑动窗口的底层实现
滑动窗口的实现方式有好几种,但我最推荐的是时间轮模型。为什么?因为它性能好、内存占用低,特别适合高并发场景。
时间轮的核心思想很简单:把时间切成一个个小格子(bucket),每个格子代表一个时间片段。比如我们要统计最近10秒的数据,就把10秒切成10个格子,每个格子1秒。请求来了,根据当前时间戳找到对应的格子,把计数累加进去。
这里有个关键点:格子是循环使用的。时间轮本质上是一个环形数组,指针每过一秒就往前移动一格。超过N秒的旧数据会被新数据覆盖掉。这样我们只需要维护N个格子的内存,而不是无限增长。
我画了一张图,帮你理解这个结构:
你看,指针指向当前时间对应的格子。每过一秒,指针就顺时针移动一格。旧格子里的数据会被新数据覆盖。这样我们永远只保留最近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 / 10到windowSize / 5之间。比如10秒窗口,切10个格子,每个格子1秒,效果最好。 - 并发安全:别以为加了锁就万事大吉。我遇到过一种情况:多个协程同时调用
getCurrentBucket(),导致格子被重复清理。解决方案是在清理逻辑里加一个版本号判断。
⚠️ 特别注意:
如果你的系统时间会被修改(比如NTP同步时往回跳),滑动窗口的统计会出大问题。我建议在初始化时记录一个基准时间,后续所有计算都基于这个基准时间的相对偏移,而不是直接使用系统时间戳。
性能对比:滑动窗口 vs 固定窗口
我拿一个实际压测数据给你看:
| 指标 | 固定窗口 | 滑动窗口(时间轮) |
|---|---|---|
| 内存占用 | 2个计数器 | N个格子(N=窗口大小/粒度) |
| 单次操作耗时 | ~50ns | ~200ns(含锁开销) |
| 边界突变问题 | 严重 | 无 |
| 抗突发流量 | 差 | 好 |
| 实现复杂度 | 低 | 中 |
你看,滑动窗口虽然单次操作比固定窗口慢一点,但也就几百纳秒的差距。对于风控系统来说,这点开销完全可以接受。换来的却是更平滑的限流效果和更强的抗攻击能力。
💡 我的建议:
如果你的QPS在10万以下,直接用滑动窗口没问题。如果QPS超过100万,可以考虑用原子操作替代锁,或者用无锁队列实现。不过那是进阶话题了,咱们后面再聊。
好了,滑动窗口的核心原理和实现就讲到这里。记住一句话:时间轮的本质是用空间换时间,用固定的内存开销换取平滑的统计能力。下一章咱们聊聊怎么把这个滑动窗口用到熔断器里,实现真正的毫秒级熔断。