go: 使用简单的计数器计算请求的qps

7 篇文章 1 订阅
7 篇文章 3 订阅

背景

一个稳定的后端服务,需要具备相当的容灾能力。假设因为有事故出现,或者是由于活动等原因,导致有突发大量流量请求服务,此时要保证系统不会被打垮,依然能在力所能及的范围内提供服务。

要应付以上场景,也就意味着系统具备限流能力。一些常用的限流方法有: 计数器算法,令牌桶算法,漏桶算法等。

然而在工作中,我们可能希望做一些更加复杂的操作。 因此如果能够实时计算到当前系统的QPS,根据QPS判断进行不同操作,就能实现在不同的流量中的分级处理。

 

Qps计算

Qps即每秒查询率,表示每一秒钟的请求量。明白了其含义,很容易就能得出计算Qps的方法:

  1. 定义时间区间 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
  2. 统计时间区间内的请求量总数 t o t a l R e q u e s t totalRequest totalRequest
  3. 使用公式计算可得: 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/timeIntervalSizetimeIntervalSize

举个简单得例子,当前时间戳为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/6060=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
}

这种方式简单有效,但是存在着两个比较明显得缺点:

  1. 使用Mutex控制并发,粒度太粗,当系统并发度较高时,会使系统得性能大大降低。
  2. 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上

timeInterval1timeInterval2timeInterval3
143244200

timeInterval2时,数字加在timeInterval2上,此时timeInterval1的数据不在变化,可以计算出Qps

timeInterval1timeInterval2timeInterval3
1432442432340

timeInterval3时,数字加在timeInterval2上,此时timeInterval2的数据不在变化,可以计算出Qps,timeInterval1的数据不再需要,可删除。

timeInterval1timeInterval2timeInterval3
143244243234432454

既然在计算timeInterval3的数据时,timeInterval1的数据已经不需要了,是否可以将timeInterval3或者后续的timeInterval4的数据直接覆盖在timeInterval1上了?答案是肯定的,这也就意味着,不需要使用map存储不同时间段的timeInterval,只需要用一个固定大小的循环队列,存储最近的几个timeInterval即可。 这样一来,key一直增长的问题解决了,同时也不需要担心map的读写并发问题,从而可以完全摆脱掉mutex,单纯使用原子操作进行计数。

循环队列

这里又引出了另外一个问题:循环队列的大小设定为多少合适?

从以上的例子,似乎大小只要设定为2就可以了。一个格子存放当前timeInterval的计数,一个格子存放上一个timeInterval的计数。但是这样的设计其实是有隐患的。

假设当前队列大小为2。timeInterval1时,记录数据到下标0中:

01
14324420

timeInterval2时,记录数据到下标1中:

01
143244232344324

到timeInterval3的时候,要开始写下标0了。在这之前需要先把下标0的数据清空。如果在timeInterval2的时候清空下标0的数据,则可能这部分数据还需要。当在timeInterval3开始写入数据的时候清空,则可能有并发问题:即在从timeInterval2到timeInterval3的一瞬间,会有多个请求去检测下标0,发现其中有数据,对其进行清零。这就会导致部分请求已经写入了,但是依然被别的请求清除掉了。

因此为了解决以上问题,一个解决方法是将队列大小至少设为4,并且在计数的时候,要把当前格子往后两个时间段的格子的数据清空。举个简单的例子

timeInterval1时,记录数据到下标0中,并且清空(0+2)%4=2中的数据:

0123
1432442000

timeInterval2时,记录数据到下标1中并且清空(1+2)%4=3中的数据:

0123
1432442312300

timeInterval3时,记录数据到下标2中并且清空(2+2)%4=0中的数据:

0123
0312342342340

timeInterval4时,记录数据到下标3中并且清空(3+2)%4=1中的数据:

0123
0042342341234324

以此类推,无论何时,都能保证下一个要写入的格子能够被提前清空,也就能保证不会有数据被误删。

总结

综上所诉,可以使用循环队列+原子编程的形式,减少锁竞争,并且防止内存使用率过大。

 

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]
}


  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值