基于时间轮的高性能时序统计组件

58 阅读3分钟

我曾写过一篇分析hystrix-go性能问题的文章[Golang]从源码分析hystrix-go的性能隐患 - 掘金 (juejin.cn),其中我曾经提到过,可以使用时间轮思想来实现QPS统计。本文便会介绍其实现。

我们继续使用hystrix-go​里的场景:比如我要分别统计某个path在近10s, 30s, 1m内的QPS。如果按hystrix-go​里的实现方法, 就是使用map来存QPS信息,时间戳为key, 值是这一秒的QPS。

这样做性能很差。一方面是大量 map+锁的读写操作,一方面是需要大量对map的遍历。

可能你会有这样的想法:如果能把这个场景里的时间戳用数组来存就好了。这样虽然还是需要锁,但由于没有了map, 遍历快了非常多,GC负担也变小了,性能会有明显提升。甚至,如果是精确度要求不高的统计场景,也可以选择不加锁(但map无论如何都只能加锁)。

如何把时间戳放到数组里,又能确保遍历的顺序就是时间戳的递增顺序呢?

可以对时间戳取模+取商。假设现在有一个长度100的 []int​,对于时间戳1700920000,它除以100的商=17009200, 模数=0。 那么, 把数组的下标0设置为 17009200, 就可以表示当前秒了。下面我们再举几个例子:

1700912345: 商=17009123, 模数=45: arr[45] = 17009123。

19999: 商=199, 模数=99: arr[99] = 199

用的时候,只要反过来计算即可。 比如19999=199*100+100。

这里我们可以把这个商称为周目数,即0-99是第0周目,100-199又从头放了一遍,也就是第1周目。19999就在第199周目上。

下面,把这个[]int​升级一下,变成如下的[]slot​,就可以用来保存数据了。

type Slot struct {
	value float64 // 需要存的数据
	gen  int64   // 周目数
}

比如我们使用这个 []slot​ 来存储系统的QPS。

在第19999秒,被请求了一次, 这时 []slot的下标99上的slot是slot{value:1, gen:199}

还是第19999秒,又请求了一次, 这时 []slot的下标99的slot变成了slot{value:2, gen:199}

在这一秒,又请求了100次, 变成slot{value:102, gen:199}​。

(注意,这里应使用原子操作进行累加操作)

那应该如何实现统计前10秒呢?

假设当前的时间戳是10000, 需要统计前10秒的数据,也就是时间戳[9990,9999]。 它们在这个数组里的下标分别是90-99, 周目都是99。 只要遍历数组的[90,99]下标,再检查其周目数,只要周目数=99就累加,便得到了要的结果。

但请注意,这个数组的长度只有100,而每个下标表示一秒,也就是说,只能支持储存前100秒的数据,且粒度为1秒。我们稍加改造便可以支持不同的时间范围和时间精度

例如, 想以200ms为单位统计前30min内的某个数据,要怎么做?

30x60x1000/200 = 9000,也就是说需要一个长度为9000的数组,每个下标表示200ms。

那么我想统计每个path在前面一段时间的QPS要怎么做呢?

首先把上面的数据结构封装好,假设叫 TimeWheelCounter​, 使用map[string]TimeWheelCounter​即可

在实践中,把上面的TimeWheelCounter 封装成库即可。具体实现 ‍