我司监控系统的底层存储架构使用的是 open-falcon,作为非常流行的中小企业监控解决方案,open-falcon非常优秀。

但在我司,指标量一度达到3亿+,且受限于成本等原因。falcon的存储组件就暴露出了一些性能问题。

本文从一个开发者的角度,阐述自本人接手以来对 graph(open-falcon的存储组件) 做过的一些优化工作,欢迎各位指正。

多路I/O

背景

2017年下半年。

当时我们看图的latency tp95一直在5s以上,为了提升用户体验,要将这个延迟降下来。此为背景。

分析

graph的查询逻辑是,先读cache,cache不满足再从RRD文件中获取。

因此我们在代码中埋点,分别统计了readFromCachereadFromRRD的latency,结果readFromCache的tp95在300ms左右,而readFromRRD的对应值则达到了1s+。

所以接下来重点跟进方向就是读取RRD文件的逻辑。从代码上看,从RRD文件获取数据的读路径如下:

1
2
api/graph.go::*Graph.Query(...)  
--> rrdtool/rrdtool.go::Fetch(...)  

参见 api/graph.gorrdtool/rrdtool.go

然后rrdtool.go/sync_disk.go::ioWorker()io_task_chan获取对应task后进行I/O操作。

参见 rrdtool/sync_disk.go

读路径经过pprof分析后,并没有耗时太多的地方。

而经过分析ioWorker()这个函数,发现 ioWorker() 只有1个goroutine在工作。而如果只是为了避免同时读写同一RRD文件这么做,似乎有些太低效了。

有了这样的思路后,问题就变成了如何拆分I/O,使得一把锁变成多把锁。当时在组内有2种思路:

  1. 进行读写拆分
  2. series ID进行拆分

如果按照读写拆分,就由1路I/O变成了2路I/O,读 + 写。这样的拆分,在读写拆分仍不能满足性能的需求时,不可避免的要进行再次拆分。而且,读写2路拆分,要考虑对一个RRD文件的同时读写问题,就变得更加复杂了。

基于上面的考虑,决定按照series ID进行I/O拆分。代码很简单,参见 这个diff

而优化后的效果,如下图所示。tp95 latency的值达到了预期。

多路I/O效果

小结

其实,理解多路I/O的优化思路很简单,如下图所示,从只能1个1个投入的存钱罐,变为多路分拣的硬币分拣机。

多路I/O思路

Sprint问题

背景

同样是2017年下半年。

早期graph为了方便,在代码的很多地方使用fmt.Sprintf函数,而这个函数的性能是很烂的。

分析

直接show一个benchmark:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func Benchmark_sprintf(b *testing.B) {
	for i := 0; i < b.N; i++ {
		a := fmt.Sprintf("a%s", "a")
		_ = a
	}
}

func Benchmark_add(b *testing.B) {
	for i := 0; i < b.N; i++ {
		a := "a" + "a"
		_ = a
	}
}

bench的结果:

1
2
3
4
5
6
7
$ go test -run '^$' -benchmem  -bench .
goos: darwin
goarch: amd64
Benchmark_sprintf-4   	20000000	        83.0 ns/op	       2 B/op	       1 allocs/op
Benchmark_add-4       	2000000000	         0.34 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	_/tmp/cat	2.484s

可以看出,对于字符串的连接,使用+比使用Sprintf性能要好上2个数量级,同时减少了内存分配。

小结

优化的效果参见 这个PR

对于golang等gc类语言来说,减少内存分配次数复用对象等,是减轻gc压力非常有效的手段。这个PR 也佐证了这个理论。

hash不均问题

背景

2018年上半年。

在解决了latency高等问题之后,又遇到了新的问题。graph的实例,流量非常不均匀。points_in这个代表每秒进入graph实例点数的指标,在不同实例间相差很大,最大的值是最小值的2倍以上。

分析

使用一个小demo描述这个问题:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
	"fmt"
	"strconv"

	"stathat.com/c/consistent"
)

func main() {
	t := consistent.New()

	t.NumberOfReplicas = 500

	nodeCnt := 50
	for i := 0; i < nodeCnt; i++ {
		t.Add("node-" + strconv.Itoa(i))
	}

	testNum := 5000000
	result := make(map[string]int)
	for i := 0; i < testNum; i++ {
		if node, err := t.Get(strconv.Itoa(i)); err == nil {
			result[node] = result[node] + 1
		}
	}

	for k, v := range result {
		fmt.Printf("node: %v, count: %v\n", k, v)
	}
}

了解 open-falcon 的同学可能知道,open-falcon 监控数据点的写路径,是将监控数据点的series ID通过一致性哈希计算,再push到对应的graph实例。而上述代码,就是写路径GetNodes(获取graph实例) 的核心逻辑。

上面将实例数设置为50,样本数设置为500w,虚拟节点则与open-falcon的官方设置相同,设置为500。那么这50个实例,分配结果是如何的呢?

为了方便,直接看最大值和最小值的差异。结果如下,最小值为65286,最大值为134657。相差较大,graph实例的流量差异,就是从这里产生的。

1
2
3
$ go run hash.go | sort -nk 4 | gsed -n -e '1p' -e '$p'
node: node-18, count: 65286
node: node-7, count: 134657

而经过验证,简单的将虚拟节点的数量提高,并不会提高均匀度。以下是将虚拟节点设置为10000的结果:

1
2
3
$ go run hash.go | sort -nk 4 | gsed -n -e '1p' -e '$p'
node: node-15, count: 80273
node: node-27, count: 126070

open-falcon 使用的一致性哈希实现是 stathat/consistent,一个非常简单的一致性hash实现,其hash算法使用的是 crc32

在非加密hash算法的实现中,crc32无论是在性能还是均匀度上都不是优选。下面对比了几种常见的hash算法在性能上的差异:

1
2
3
4
5
6
7
8
9
$ go test -run '^$' -benchmem  -bench .
goos: darwin
goarch: amd64
Benchmark_crcHashKeyByString-4        	20000000	        74.0 ns/op	      64 B/op	       1 allocs/op
Benchmark_fnv32HashKeyByString-4      	500000000	         3.76 ns/op	       0 B/op	       0 allocs/op
Benchmark_murmur32HashKeyByString-4   	100000000	        23.8 ns/op	       0 B/op	       0 allocs/op
Benchmark_sha1-4                      	 5000000	       392 ns/op	      96 B/op	       2 allocs/op
PASS
ok  	_/tmp/hash	8.541s

可以看到,相对fnv32murmur32crc32在性能方面完败。

最后还加入了sha1的对比,相对于非加密hash算法,sha1的性能更弱一些。

除了考量性能,还需要看各个hash算法均匀度,是否满足需求。以下是在上述模拟场景之下,几个流行hash算法的均匀度对比结果,仍是模拟500w样本,使用50个node,计算结果:

1
murmur32 > fnv1 > fnv1a > xxHash > crc32

上述结果,表现最差的是crc32,表现最好的是murmur32。因为时间久远,具体的结果没有记录下来,感兴趣的同学可以自行验证。

BTW,上述结果只代表本文所述环境,不具通用性。

小结

综合性能和均匀度2个方面的考量,最终我们选用了murmur32替换原有的crc32

虚拟节点数则设置为10000。经过验证,虚拟节点数从500到10000,计算进入graph实例的样本数的方差值,后者是前者的1/5,会更均匀一些。而更高的虚拟节点数,虽然能得到更均匀的结果,但初始化的时间开销会增加很多,例如10000个虚拟节点需要13s左右,而20000个虚拟节点则需要28s,设置为50000个虚拟节点,则需要75s。

下图是优化的效果对比,可以看到在切换前后,进入graph实例的点数均匀程度,有了明显的提升。

hash优化

替换的过程也不麻烦,感兴趣的可以参考 open-falcon 更换hash算法 一文。

内存"泄漏"问题

背景

2018年下半年。

经过上述的几番优化,graph已经解决了因单路I/O导致的I/O瓶颈,替换掉了浪费性能的Sprintf,更换了更优更均匀的murmur32 hash算法。

但仍有一个问题一直在困扰着我,如鲠在喉。那就是随着时间的增长,graph实例的内存使用量会慢慢的涨上来。

分析

既然是慢慢的涨,那首先考虑到的肯定是内存泄漏问题。

经过对heap的inuse分析之后,发现问题出在graph的cache逻辑。graph的cache是只增加不减少的,也就是说,所有series,无论是活跃的还是不活跃的,都会一直常驻于堆内存中。

找到问题点,就很好解决了。解决方案如下:

  1. 干掉无用的 History store
  2. cache对象,做定期清理,例如每30分钟清理过去12小时不活跃的series

如下图描述的是过去4个月graph实例的内存使用情况,前面3个红框的内存都是缓慢增长的,而绿框位置则是优化后的效果,已经相对平稳:

graph内存

小结

优化要结合业务特点进行。

是的,这个小结只有上面这么一句话 :)。

结尾了

对于监控系统的优化,还在进行中。当然也有很多case没有放在这里。

例如使用基于 Gorilla 开发的cacheserver,承载了50%以上的查询请求,latency却仅是graph的1/10等。

这些优化留待后续再慢慢分享吧。

-EOF-