背景
一个稳定的后端服务,需要具备相当的容灾能力。假设因为有事故出现,或者是由于活动等原因,导致有突发大量流量请求服务,此时要保证系统不会被打垮,依然能在力所能及的范围内提供服务。
要应付以上场景,也就意味着系统具备限流能力。一些常用的限流方法有: 计数器算法,令牌桶算法,漏桶算法等。
然而在工作中,我们可能希望做一些更加复杂的操作。 因此如果能够实时计算到当前系统的QPS,根据QPS判断进行不同操作,就能实现在不同的流量中的分级处理。
Qps计算
Qps即每秒查询率,表示每一秒钟的请求量。明白了其含义,很容易就能得出计算Qps的方法:
- 定义时间区间 t i m e I n t e r v a l timeInterval timeInterval,其跨度为 t i m e I n t e r v a l S i z e timeIntervalSize timeIntervalSize
- 统计时间区间内的请求量总数 t o t a l R e q u e s t totalRequest totalRequest
- 使用公式计算可得: Q p s = t o t a l R e q u e s t t i m e I n t e r v a l S i z e Qps =\frac{totalRequest}{timeIntervalSize} Qps=timeIntervalSizetotalRequest
其中,需要确定的事情有:
- 确定 t i m e I n t e r v a l S i z e timeIntervalSize timeIntervalSize的大小
- 计算请求量总数
1. timeIntervalSize
不难看出 t i m e I n t e r v a l S i z e timeIntervalSize timeIntervalSize的设定会对结果产生影响。如果设定过大:
优点:计算较少的时间区间数据,节省空间
缺点:无法很好的反应出实时的Qps值
设定的过小:
优点:计算出的Qps与实时性较高
缺点:计算更多时间区间数据,需要占用更多的资源。
因此设定timeIntervalSize时,可以根据系统的需求,以及当前所拥有的资源进行综合考虑。如果当前的资源充足,可以考虑减少timeIntervalSize的值,以更细的粒度计算请求,获得更准确的Qps值。反之系统资源较为紧张,对数据的实时性,精确性要求没有太高,可以适当扩大timeIntervalSize的值。
2. 请求量计算
确定了timeIntervalSize的值后,我们要做的就是将任意时间内的请求,划分到相应的timeInterval中,最终统计总的请求数。
2.1 timeInterval计算
当一个请求到达时,能够获取到当前的时间戳timestamp,此后只要通过以下公式计算即可得到相应的timeInterval,其中timestamp/timeIntervalSize为整数除:
t
i
m
e
I
n
t
e
r
V
a
l
=
t
i
m
e
s
t
a
m
p
/
t
i
m
e
I
n
t
e
r
v
a
l
S
i
z
e
∗
t
i
m
e
I
n
t
e
r
v
a
l
S
i
z
e
timeInterVal = timestamp/timeIntervalSize * timeIntervalSize
timeInterVal=timestamp/timeIntervalSize∗timeIntervalSize
举个简单得例子,当前时间戳为1642001018,timeIntervalSize设定为60秒,则可以计算得:
t
i
m
e
I
n
t
e
r
V
a
l
=
1642001018
/
60
∗
60
=
1642000980
timeInterVal = 1642001018/60 * 60 =1642000980
timeInterVal=1642001018/60∗60=1642000980
通过这种方法,就能够将不同时间得请求,划分到一个一个得timeInterval中。
2.2 总请求数计算
为了计算某个timeInterval得请求总和,可以定义一个map,以timeInterval为key,请求总数为value,存储不同timeInterval得数据。同时为了防止并发问题,我们给map加上锁,因此很容以能得到计数器得逻辑为:
// 假设timeIntervalSize为60
const TIME_INTERVAL_SIZE = 60
type Counter struct {
data map[int64]int64
*sync.Mutex
}
func NewCounter() *Counter {
return &Counter{
data: make(map[int64]int64),
Mutex: &sync.Mutex{},
}
}
func (c *Counter)addNumber(timestamp int64, count int64) {
timeInterVal := timestamp/TIME_INTERVAL_SIZE * TIME_INTERVAL_SIZE
c.Lock()
defer c.Unlock()
c.data[timeInterVal] += count
}
这种方式简单有效,但是存在着两个比较明显得缺点:
- 使用Mutex控制并发,粒度太粗,当系统并发度较高时,会使系统得性能大大降低。
- map中的数据会越来越多。不清除的话最后会导致内存耗尽。
如何进行优化呢?可以一个点一个点的看。
摆脱Mutex
进行计数的时候,加锁是为了防止并发度过高,从而导致某一些计数失效,最后计算出的结果会低于真实值。那么如何能够摆脱Mutex,使用比较轻量的方法实现并发场景下准确计数,即lock free编程呢?
这种情况下很自然的就会想到原子编程,atomic。原子操作即是进行过程中不能被中断的操作,针对某个值的原子操作在被进行的过程中,仅会由一个独立的CPU指令代表和完成。原子操作是无锁的,常常直接通过CPU指令直接实现。
常用的一些atomic函数有:
func LoadInt64(addr *int64) (val int64) 读取操作
func StoreInt64(addr *int64, val int64) 写入操作
func AddInt64(addr *int64, delta int64) (new int64) 修改操作
除此之外还有如CAS的一些操作。在这里,只需要进行简单的加法,因此当有请求过来时,使用AddInt64进行相应的计数即可。
至此,计数时不再需要Mutex进行加锁。但是Mutex依旧无法抛弃掉。 因为我们的设计中使用map作为存储结构,当新的timeInterval出现时,需要插入新的key,从而会有并发读写情况。众所周知,Go的map不是线程安全,出现并发读写时会导致代码panic。所以这里Mutex仍然是必须的。
摆脱map
map的并发读写操作需要Mutex协助,尽管我们可以使用RWMutex或者sync.Map等更复杂的map进行优化,但是仍然存在一定的性能消耗。同时Map的key越来越多,不及时清理,慢慢占用内存,也是一个令人头疼的问题。
回头来思考问题,不难发现,我们需要的是实时的Qps,因此一些之前的timeInterval的数据,其实是不需要的。
假设当前处于timeInterval1,所有的数据都会落到相应的同一个key中,一旦过渡到下一个timeInterval,即timeInterval2,此时timeInterval1的数据不会再变更,所有新的请求都会加在timeInterval2中,此时通过timeInterval1的数据即可计算出Qps。而到了timeInterval3的时候,同理可得通过timeInterval2的数据能够计算出Qps。此时不难发现,timeInterval1的数据已经是无用的,可以抛弃掉。如下:
timeInterval1时,数字加在timeInterval1上
timeInterval1 | timeInterval2 | timeInterval3 |
---|---|---|
1432442 | 0 | 0 |
timeInterval2时,数字加在timeInterval2上,此时timeInterval1的数据不在变化,可以计算出Qps
timeInterval1 | timeInterval2 | timeInterval3 |
---|---|---|
1432442 | 43234 | 0 |
timeInterval3时,数字加在timeInterval2上,此时timeInterval2的数据不在变化,可以计算出Qps,timeInterval1的数据不再需要,可删除。
timeInterval1 | timeInterval2 | timeInterval3 |
---|---|---|
1432442 | 43234 | 432454 |
既然在计算timeInterval3的数据时,timeInterval1的数据已经不需要了,是否可以将timeInterval3或者后续的timeInterval4的数据直接覆盖在timeInterval1上了?答案是肯定的,这也就意味着,不需要使用map存储不同时间段的timeInterval,只需要用一个固定大小的循环队列,存储最近的几个timeInterval即可。 这样一来,key一直增长的问题解决了,同时也不需要担心map的读写并发问题,从而可以完全摆脱掉mutex,单纯使用原子操作进行计数。
循环队列
这里又引出了另外一个问题:循环队列的大小设定为多少合适?
从以上的例子,似乎大小只要设定为2就可以了。一个格子存放当前timeInterval的计数,一个格子存放上一个timeInterval的计数。但是这样的设计其实是有隐患的。
假设当前队列大小为2。timeInterval1时,记录数据到下标0中:
0 | 1 |
---|---|
1432442 | 0 |
timeInterval2时,记录数据到下标1中:
0 | 1 |
---|---|
1432442 | 32344324 |
到timeInterval3的时候,要开始写下标0了。在这之前需要先把下标0的数据清空。如果在timeInterval2的时候清空下标0的数据,则可能这部分数据还需要。当在timeInterval3开始写入数据的时候清空,则可能有并发问题:即在从timeInterval2到timeInterval3的一瞬间,会有多个请求去检测下标0,发现其中有数据,对其进行清零。这就会导致部分请求已经写入了,但是依然被别的请求清除掉了。
因此为了解决以上问题,一个解决方法是将队列大小至少设为4,并且在计数的时候,要把当前格子往后两个时间段的格子的数据清空。举个简单的例子
timeInterval1时,记录数据到下标0中,并且清空(0+2)%4=2中的数据:
0 | 1 | 2 | 3 |
---|---|---|---|
1432442 | 0 | 0 | 0 |
timeInterval2时,记录数据到下标1中并且清空(1+2)%4=3中的数据:
0 | 1 | 2 | 3 |
---|---|---|---|
1432442 | 3123 | 0 | 0 |
timeInterval3时,记录数据到下标2中并且清空(2+2)%4=0中的数据:
0 | 1 | 2 | 3 |
---|---|---|---|
0 | 3123 | 4234234 | 0 |
timeInterval4时,记录数据到下标3中并且清空(3+2)%4=1中的数据:
0 | 1 | 2 | 3 |
---|---|---|---|
0 | 0 | 4234234 | 1234324 |
以此类推,无论何时,都能保证下一个要写入的格子能够被提前清空,也就能保证不会有数据被误删。
总结
综上所诉,可以使用循环队列+原子编程的形式,减少锁竞争,并且防止内存使用率过大。
3.代码示例
const TIME_INTERVAL_SIZE = 60
type Counter struct {
data []*int64
}
func NewCounter() *Counter {
dataList:= make([]*int64, 4)
for i := 0; i < 4; i++ {
var zero int64
dataList[i] = &zero
}
return &Counter{
data: dataList,
}
}
func (c *Counter)addNumber(timestamp int64, count int64) {
// 这里忽略一些对timestamp的校验
timeInterVal := timestamp/TIME_INTERVAL_SIZE * TIME_INTERVAL_SIZE
// 这里上下两个式子可以合并,不过为了直观点,就这么写了
// 计算出下标,进行原子加数
slotNum := (timeInterVal /TIME_INTERVAL_SIZE) % 4
atomic.AddInt64(c.data[slotNum], count)
// 重置后两个时间段的下标
resetNum := (slotNum + 2) %4
atomic.StoreInt64(c.data[resetNum], 0)
}
func (c *Counter)getNumber(timestamp int64) int64 {
// 这里忽略一些对timestamp的校验
timeInterVal := timestamp/TIME_INTERVAL_SIZE * TIME_INTERVAL_SIZE
// 这里上下两个式子可以合并,不过为了直观点,就这么写了
slotNum := (timeInterVal /TIME_INTERVAL_SIZE) % 4
return *c.data[slotNum]
}