在开发完一个类似于prometheus的metrics收集系统软件后,经过n多次debug,软件终于run起来了。因为对于我们的系统,指标不需要非常频繁的获取(事实上对于prometheus而言,也基本都是5秒、10秒或者更长时间的获取时间间隔),所以我首先调整了获取频率到50ms,准备先一次性看一下有没有性能问题。
果不其然,CPU处于合理的范围(30%的样子),但是内存,每秒大几MB的涨,不一会儿就涨到了1G多,这里说的内存时实际使用内存,也就是RES,并不是虚拟内存。而换算来看,节点的数据都还没获取到多少呢。
这明显时不能接受的。因为这个程序会像prometheus一样,作为守护进程长期运行。于是开始调查。利器上来了,pprof
。追了一会后,发现是我们存储每个metrics的label_name-label_value的那个map,分配了非常大量的内存,这个内存在本次数据被序列化到文件之前,是常驻的。只能进行优化处理了。(对于pprof关于内存leak的定位和过程,就不在这里赘述了,分享一篇文章:How I investigated memory leaks in Go using pprof on a large codebase)。
找到问题后,没有立即下手对该mmp,哦不对,是map进行开刀,而是仍然感觉即便是频繁的创建,非常不合理。仔细回顾了整个过程,一个细节引起了我的注意。就是由于程序是有很多锁、原子操作、goroutine的较为复杂的程序,在编译时用了-race
的选项,以便在运行的时候观察是否有资源的不合理竞争。
马上,将该选项移除后编译运行,内存增速果然急剧下降,这里开心一秒钟。但马上就变脸了,因为观察发现内存的增速还是有点猛。依旧用pprof定位到是该map。
没想到golang的map这么RG,到这里,我总结了下该地方使用map的原因,以及特点:
- 该map存储了是metrics的label。对于我们的metrics,目前只有很少的label,而且可预见的未来,也只会有几个,不到5个吧。
- 使用map,是为了加快搜索。
想到这,我想到了2种解决方案:
a. 将该map用slice替换,因为存储的数量很少,甚至都可以在搜索的时候暴力遍历,连二分查找都不用;
b. 在一个统一的地方用一个数据结构维护所遇到的所有label name和label value,也可以理解为symbol吧。我们系统的label name就是固定的几个,而label value,感觉顶多也就几千台机器,几万个吧。这个数据结构包含了map[string]uint16
和symbols的slice
。然后在每个metrics种依然用map,只是不再用string
,key和value都换成uint16
,指示在slice
中的下标。这样,不管是从id找string,还是string找id,都是O(1)的操作。
方案a没有去尝试,但一定是一个非常节省内存的方式。没有尝试的理由是不想引入复杂度和太多的数据结构,暴力搜索又显得不是很优雅。上面分享的那个文章中,采用的是这个方法。
采用了方案b,最终执行下来的结果还是比较满意。
其实具体怎么做,是和业务息息相关的。
总结
- golang的map是hash存储,查找速度是O(1)的,但是内部采用了空间换时间的做法,维护2个hash表,再加上一些辅助信息,占用的内存还是挺高的,不建议在程序中被频繁分配且被长时间持有。而且一个比较蛋疼的事情是它不像c++的map,是采用二叉树存储从而做到有序,golang的map是无须的,c++中专门又
unorderd_map
去做这个事情。使用的时候要注意了,可能会带来一些测试上面的不便捷。 go build -race
是个神器,但是它会带来额外的内存和cpu的增长。golang的map是非线程安全的,目前来看,编译器会分配额外的资源去监控这个map。