当前位置: 首页 > 科技观察

从真实事故说起:Golang内存问题排查指南

时间:2023-03-13 20:16:03 科技观察

作者|赵振宇报告问题报警!!!一天天天搬砖的时候,发现一个微服务字节跳动.xiaoming的某些实例内存太大,达到了80%。而且这个服务已经很久没有推出新版本了,所以可以排除新代码引入的问题。发现问题后,首先迁移了实例。除保留一个实例用于故障排除外,其余实例均已迁移。迁移后,新实例的内存不足。但是发现随着时间的推移,被迁移实例的内存也慢慢增加,出现内存泄漏。问题定位推测1:我怀疑goroutine逃逸排查过程通常是内存泄露的主要原因,因为goroutine太多了。因此,我首先怀疑是不是goroutines有问题。我检查了goroutines,发现都正常,总量偏低,没有持续增长的现象。(当时忘记截图了,后来补上了图,但是goroutine的个数没有变化。)排查结果没有goroutineescape问题。猜测二:怀疑是代码有内存泄漏。排查过程中,通过pprof实时获取内存,对比问题实例和正常实例的内存使用情况:问题实例:正常实例:进一步看问题实例的图:从中,我们可以发现meircs.flushClients()占用内存最多,定位源码:func(c*tagCache)Set(key[]byte,tt*cachedTags){ifatomic.AddUint64(&c.setn,1)&0x3fff==0{//每0x3fff次调用,我们清除内存泄漏问题的映射//没有理由有这么多标签//FIXME:sync.Map没有Len方法并且`setn`可能不等于并发环境样本中的len:=make([]interface{},0,3)c.m.Range(func(keyinterface{},valueinterface{})bool{c.m.Delete(key)iflen(samples)4.5),它会默认采用更“积极”的策略,使内存重用更高效,延迟更低以及许多其他优化。带来的负面影响是RSS不会立即掉线,而是会延迟到内存有一定压力后才掉线。我们的go版本是1.15,内核版本是4.14,正好中招!排查结果表明,go编译器版本+系统内核版本命中了go运行时gc策略,会阻止RSS在堆内存回收后掉线。问题解决方案有两种解决方法:1)一种是在环境变量中指定GODEBUG=madvdontneed=1。此方法可以强制运行时继续使用MADV_DONTNEED。(参考:https://github.com/golang/go/issues/28466)。但是启动madvisedontneed后,会触发TLBshootdown和更多的pagefaults。对延迟敏感的业务可能会受到更大的影响。所以这个环境变量需要慎用!2)升级go编译器版本到1.16或更高版本,参见go1.16更新说明。这种GC策略已经被废弃,改为在内存压力大时及时释放内存,而不是懒惰释放。好像go官网也认为及时释放内存的方式更可取,在大多数情况下更合适。附:解决pprof看到heap使用的内存比RSS小很多的问题,可以通过手动调用debug.FreeOSMemory来解决,但是执行这个操作是有代价的。同时FreeOSMemory在go1.13版本(https://github.com/golang/go/issues/35858)中不起作用,建议谨慎使用。作为实施的结果,我们选择了第二种方案。升级go1.16后,实例并没有出现内存持续高速增长的情况。再次使用pprof查看实例,发现占用内存的函数也发生了变化。曾经占用内存的metrics.glob已经减少。看来此解决方法有效。遇到的其他陷阱在排查过程中,发现了另一个可能导致内存泄漏的问题(这个服务没有命中)。如果不开启mesh,kitc的服务发现组件存在内存泄露的风险。从图中可以看出,cache.(*Asynccache).refresher占用内存较大,随着业务处理量的增加,其内存占用会不断增长。很自然地想到,在创建一个新的kiteclient时,可能会重复进行client的构建。于是查了下代码,没有发现重复构造。但是查看kitc的源码可以发现,在服务发现的时候,kitc中会建立一个缓存池asyncache来存放实例。此缓存池将每3秒刷新一次。刷新时会调用fetch,fetch会进行服务发现。服务发现时,会根据实例的host、port、tag(会根据环境env变化)不断创建新的实例,然后将实例存放到缓存池asyncache中。这些实例不会被清理,内存也不会被释放。所以这就是导致内存泄漏的原因。解决方案项目比较早,所以使用的框架比较老。这个问题可以通过升级最新的框架来解决。思考总结首先定义什么是内存泄漏:内存泄漏(MemoryLeak)指的是程序中已经动态分配的堆内存。系统崩溃等严重后果。常见场景在go场景中,常见的内存泄漏问题如下:1、goroutines导致内存泄漏(1)goroutine应用过多问题概述:goroutine应用过多,增长速度快于释放速度,会导致goroutine越来越多。场景示例:为一个请求创建一个新的客户端。当有大量业务请求时,创建的客户端太多,来不及释放。(2)goroutine阻塞①I/O问题问题概述:I/O连接没有设置超时时间,导致goroutine一直等待。场景示例:请求第三方网络连接接口时,由于网络问题,没有收到返回结果。如果不设置超时时间,代码会一直阻塞。②未释放互斥量问题概述:goroutine无法获取锁资源,导致goroutine阻塞。场景示例:假设有一个共享变量,goroutineA对共享变量加了锁,没有释放,导致其他goroutineB,goroutineC,...,goroutineN无法获取到锁资源,导致其他goroutine阻塞。③waitgroup使用不当问题概述:waitgroup中Add、Done、wait的个数不匹配,会导致wait永远等待。场景示例:WaitGroup可以理解为一个goroutinemanager。他需要知道有多少个goroutines在为他工作,完成后需要通知他,否则他会等到所有小弟的工作都完成了。添加WaitGroup后,程序将等待,直到它收到足够数量的Done()信号。假设waitgroupAdd(2),Done(1),那么此时还有一个任务没有完成,所以waitgroup会一直等待下去。详细介绍参见Goroutine退出机制中waitgroup章节。2.select阻塞问题概述:使用select但case没有完全覆盖,导致caseready不全,最终导致goroutine阻塞。场景示例:通常发生在select的case覆盖不全,同时没有default的情况下,会发生阻塞。示例代码如下:funcmain(){ch1:=make(chanint)ch2:=make(chanint)ch3:=make(chanint)goGetdata("https://www.baidu.com",ch1)goGetdata("https://www.baidu.com",ch2)goGetdata("https://www.baidu.com",ch3)select{casev:=<-ch1:fmt.Println(v)casev:=<-ch2:fmt.Println(v)}}3.Channel阻塞问题概述:写阻塞Unbufferedchannel阻塞通常是写操作因为没有读和bufferedchannel因为缓冲区满而阻塞,写操作阻塞,读阻塞,期望从通道中读取数据,但是没有goroutine来写入它。例:以上三种原因导致的代码bug,都会造成通道阻塞。下面是几个生产环境真实通道阻塞的例子:lark_cipher库机器故障总结CipherGoroutine泄漏分析4.timer使用不当(1)time.after()使用不当问题概述:默认的time.After()会有内存泄漏问题,因为每次.After(duratiuonx)都会生成NewTimer(),在durationx到期之前,新创建的timer不会被GC,到期后才会被GC。那么久而久之,尤其是durationx很大的话,就会出现内存泄露的问题。场景示例:funcmain(){ch:=make(chanstring,100)gofunc(){for{ch<-"continue"}}()for{select{case<-ch:case<-time.After(time.Minute*3):}}}(2)time.ticker没有停止问题概述:使用time.Ticker需要手动调用stop方法,否则会造成永久内存泄漏。场景示例:funcmain(){ticker:=time.NewTicker(5*time.Second)gofunc(ticker*time.Ticker){forrangeticker.C{fmt.Println("Ticker1....")}fmt.Println("Ticker1Stop")}(ticker)time.Sleep(20*time.Second)//ticker.Stop()}建议:始终建议在for外初始化一个定时器,for时手动初始化ends停止定时器。5.slice引起的内存泄漏概述:两个slice共享地址,其中一个是全局变量,另一个不能gc;appendslice后已经用过了,还没有清理干净。场景示例:直接上传代码,这样b数组就不会被gc。vara[]intfunctest(b[]int){a=b[:3]return}其他遇到的坑中提到的kitc的服务发现代码就是这个问题的例子。排查思路总结以后如果遇到golang内存泄露问题,可以按照以下步骤排查解决:观察服务器实例,查看内存使用情况,确定内存泄露问题;可以直接点击tce平台的【实例列表】;也可以在ms平台点击勾选【运行时监控】;判断goroutine问题;这里可以使用1中提到的监控来观察goroutines的数量,或者使用pprof进行采样判断goroutines的数量是否异常增加。确定代码问题;使用pprof通过函数名定位具体代码行数,可以使用pprof的图、源码等方式定位;检查整个调用链是否存在上述场景的问题,如select阻塞、channel阻塞、slice使用不当等问题,优先考虑自己的代码逻辑问题,再考虑框架是否存在不合理的地方;解决相应问题并在测试环境中观察,通过后上线观察;推荐的排查工具pprof:是分析Go语言程序运行性能的一个工具,可以提供包括cpu、heap、goroutine等各种性能数据。pprof可以通过三种方式使用:报告生成、web可视化界面、和交互式终端。nemo:基于pprof封装,对单个进程进行采样ByteDog:在pprof对整个容器/物理机进行采样的基础上,提供了更多的指标Lidar:基于ByteDog(目前是平台方推荐的工具,比较tonemo)智能oncall助手:kiteboss研究用的排错小工具,用起来很方便,在群里at机器人里输入podName即可。