为什么需要滑动窗口
常常有这样的需求:统计过去一段时间内的请求总数,过去一段时间内的平均值
这种需求用滑动窗口来实现相当适合
滑动窗口实现
本文介绍的实现基于go-zero的RollingWindow
数据结构
先不看具体的数据结构,想想滑动窗口应该要维护哪些信息,一个滑动窗口必须有以下信息:
- 有多少个窗口
- 每个窗口代表的时间间隔
- 上次操作到哪个窗口了
- 上次操作滑动窗口的时间
- 每个窗口中的数据
type RollingWindow struct {
lock sync.RWMutex
// 有多少个窗口
size int
// 窗口,具体定义在下面
win *window
// 每个窗口的时间间隔
interval time.Duration
// 最近一次使用了哪个窗口
offset int
// 是否忽略当前窗口
ignoreCurrent bool
// 最近一次使用窗口的时间
lastTime time.Duration
}
// window的定义如下:一个桶数组
type window struct {
buckets []*Bucket
size int
}
// 每个桶包含累加和sum,和个数count
type Bucket struct {
Sum float64
Count int64
}
在每个桶中维护了2个值:
count
:有多少个数sum
:每个数代表的业务值的累加和
go-zero中对Bucket的使用如下:
使用场景 | count的含义 | sum的含义 | 能计算什么? |
---|---|---|---|
google sre熔断器 | 请求总数 | 有多少成功的请求 | 桶中的请求成功率 |
自适应过载保护 | 请求总数 | 响应时间加和 | 桶中请求的平均响应时间 |
如果一个业务场景想记录多组数据,可以同时使用多个滑动窗口,用sum代表不同的含义就好了
滑动是怎么发生的
go-zero会预先创建所有的桶,请求到来时,通过一个算法根据当前时间定位到bucket
,并记录请求状态
此时会调用Add
func (rw *RollingWindow) Add(v float64) {
rw.lock.Lock()
defer rw.lock.Unlock()
// 计算并更新应该使用哪个窗口
rw.updateOffset()
// 更新窗口内的值
rw.win.add(rw.offset, v)
}
首先调用updateOffset
:
func (rw *RollingWindow) updateOffset() {
span := rw.span()
// ...
}
其中span
方法计算从上一次操作滑动窗口到当前时刻,应该滑过多少个窗口
func (rw *RollingWindow) span() int {
// 窗口个数 = 经过的时间 / 每个窗口的时间
offset := int(timex.Since(rw.lastTime) / rw.interval)
if 0 <= offset && offset < rw.size {
return offset
}
// 最大为滑动窗口个数
return rw.size
}
继续执行updateOffset
:
func (rw *RollingWindow) updateOffset() {
// ...
// 还是在offset窗口,没变化,返回
if span <= 0 {
return
}
// offset:上次操作的窗口
// 从上次操作的窗口开始数span个,将这些窗口都清空
offset := rw.offset
for i := 0; i < span; i++ {
rw.win.resetBucket((offset + i + 1) % rw.size)
}
// offset更新为当前窗口的位置
rw.offset = (offset + span) % rw.size
now := timex.Now()
// 对齐
rw.lastTime = now - (now-rw.lastTime)%rw.interval
}
主要干了以下几件事:
-
计算
span
:从上一次操作滑动窗口到当前时刻,应该划过多少个窗口 -
如果当前时间和上次操作时在相同窗口:
- 直接返回,因此没有窗口滑动
-
从上次操作的窗口开始数span个,将这些窗口都清空
- 由于在过去这段时间内没有请求,这些窗口理应被清空
-
更新新的offset,即当前窗口的位置
-
设置lastTime为当前时间,但需要对齐,使lastTime始终在窗口的边界位置、
- 为啥需要边界对齐?主要是为了方便计算当前请求和上一个请求在不在同一个桶中:如果now和lastTime的差距小于interval,那么肯定在同一个桶中,此时span为0。但如果没有边界对齐就不能这么简单的判断。
当定位好要操作哪个桶后,最后执行add
,将数据真正放入桶中
func (w *window) add(offset int, v float64) {
// 在offset对应的桶中:
w.buckets[offset%w.size].add(v)
}
func (b *Bucket) add(v float64) {
// 累加v
b.Sum += v
// 计数++
b.Count++
}
统计滑动窗口的数据
当滑动窗口中有数据时,如何统计其中的数据呢?
我们以goole sre熔断器为例:当判断请求是否应该被抛弃时,需要获取过去一段时间内的请求总数,请求成功数:
func (b *googleBreaker) history() (accepts, total int64) {
b.stat.Reduce(func(b *collection.Bucket) {
accepts += int64(b.Sum)
total += b.Count
})
return
}
Ruduce方法将fn作用在每个有效的桶中:
func (rw *RollingWindow) Reduce(fn func(b *Bucket)) {
rw.lock.RLock()
defer rw.lock.RUnlock()
var diff int
// span:相对于上次请求滑动了多少个桶
span := rw.span()
if span == 0 && rw.ignoreCurrent {
diff = rw.size - 1
} else {
diff = rw.size - span
}
// diff:有效的桶个数
if diff > 0 {
offset := (rw.offset + span + 1) % rw.size
rw.win.reduce(offset, diff, fn)
}
}
func (w *window) reduce(start, count int, fn func(b *Bucket)) {
for i := 0; i < count; i++ {
fn(w.buckets[(start+i)%w.size])
}
}
-
注意,这里不是简单的将
fn
作用在所有桶中,而是只作用在有效的桶中- 因为从上次请求时间到当前时间,可能有些桶的数据已经过期了,需要跳过这些桶,对应到代码中就是跳过
offset到offset+span
这个区间的桶
- 因为从上次请求时间到当前时间,可能有些桶的数据已经过期了,需要跳过这些桶,对应到代码中就是跳过
-
rw.ignoreCurrent默认为true,即忽略当前的桶
- 为啥忽略?因为当前桶中的数据可能很少,将这部分数据加入可能会影响结果的正确性