详解go-zero中滑动窗口的实现

为什么需要滑动窗口

常常有这样的需求:统计过去一段时间内的请求总数,过去一段时间内的平均值

这种需求用滑动窗口来实现相当适合

滑动窗口实现

本文介绍的实现基于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
}

主要干了以下几件事:

  1. 计算span:从上一次操作滑动窗口到当前时刻,应该划过多少个窗口

  2. 如果当前时间和上次操作时在相同窗口:

    1. 直接返回,因此没有窗口滑动
  3. 从上次操作的窗口开始数span个,将这些窗口都清空

    1. 由于在过去这段时间内没有请求,这些窗口理应被清空
  4. 更新新的offset,即当前窗口的位置

  5. 设置lastTime为当前时间,但需要对齐,使lastTime始终在窗口的边界位置、

    1. 为啥需要边界对齐?主要是为了方便计算当前请求和上一个请求在不在同一个桶中:如果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,即忽略当前的桶

    • 为啥忽略?因为当前桶中的数据可能很少,将这部分数据加入可能会影响结果的正确性
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值