Code前端首页关于Code前端联系我们

从一场真实的灾难开始:Golang内存排查指南

terry 2年前 (2023-09-24) 阅读数 54 #后端开发

一天在日常解砖的时候,发现微服务bytedance.xiaoming内存过多,达到了80%。该服务已经很长时间没有发布新版本了,因此新代码带来的问题可以排除。 真实事故出发:golang 内存问题排查指北

当我们看到问题的时候,我们第一次动了。除1个预留故障处理案例外,其余案例均已迁移。迁移后,对新病例的记忆力较低。但发现随着时间的推移,迁移事件的记忆逐渐增多,说明记忆是存在的。

问题领域

推定一:怀疑goroutine出逃

排查故障

往往召回的主要原因是goroutine问题太多,所以我先来说说goroutine的问题。我去见goroutine,觉得很正常。成交量低,无持续增长。 (当时忘了截图,后来补了截图,但goroutine的号码没有变)真实事故出发:golang 内存问题排查指北

故障排除解答

goroutine逃跑没问题。

评估2:你的代码内存不足

故障排除技巧

使用pprof进行实时内存收集,并比较问题示例和正常示例的内存使用情况:示例问题: 真实事故出发:golang 内存问题排查指北

典型示例:真实事故出发:golang 内存问题排查指北

仔细看看示例问题的图表:真实事故出发:golang 内存问题排查指北

由此我们可以看到metircs.flushClients()占用的内存最多。找到源码:



func (c *tagCache) Set(key []byte, tt *cachedTags) {
        if atomic.AddUint64(&c.setn, 1)&0x3fff == 0 {
                // every 0x3fff times call, we clear the map for memory leak issue
                // there is no reason to have so many tags
                // FIXME: sync.Map don't have Len method and `setn` may not equal to the len in concurrency env
                samples := make([]interface{}, 0, 3)
                c.m.Range(func(key interface{}, value interface{}) bool {
                        c.m.Delete(key)
                        if len(samples) < cap(samples) {
                                samples = append(samples, key)
                        }
                        return true
                }) // clear map
                logfunc("[ERROR] gopkg/metrics: too many tags. samples: %v", samples)
        }
        c.m.Store(string(key), tt)

}

原来是为了避免内存泄漏,使用了枚举来确定数量。已清除的密钥存储在sync.Map中。理论上应该没有问题。

故障排除结果

没有导致内存丢失的代码错误。

评估3:怀疑RSS问题

故障排除步骤

此时我注意到了一些事情。在pprof中,我发现总指标为72MB,总内存只有170+MB。而我们的例子是2GB配置内存。占用 80% 内存意味着使用 RSS 需要 1.6GB。这两者非常不兼容(稍后会介绍这个问题的解决方法),这应该不会导致80%内存使用警报。因此,推测记忆无法及时恢复。

经过调查,我发现了这个神奇的事情:

Go运行时总是在内核中使用MADV_DONTNEED,退出时它会恢复到内核。虽然性能比较低,但它允许RSS(驻留内存分配)数量快速减少。然而,go 1.12 对其进行了显着改进。释放内存时,使用MADV_FREE,而不是之前的MADV_DONTNEED。详情请看这里:

https://go-review.googlesource.com/c/go/+/135395/

go1.12原始更新:真实事故出发:golang 内存问题排查指北

Go 1.1512~优化了GC策略当Linux内核版本(> 4.5)支持时,默认情况下将使用更“激进”的策略,以使内存的使用更高效,延迟更低并优化许多其他内容。负面影响是RSS不会立即下降,而是会延迟到记忆被按下为止。

我们的版本是1.15,内核版本是4.14,正是这样!

排查结果

go编译器版本+系统内核版本击败了go的gc运行时策略,这将防止RSS在处理bundle内存后崩溃。

问题解决方案

解决方案

有两种解决方案:

  1. 一个指定使用此方法的GODEBUGneed=mad1属性。 MADV_不需要。 (参考:https://github.com/golang/go/issues/28466)。但启动madvise后不需要,会引发TLB射击和很多页面错误。越敏感的企业可能受到的影响更大。所以这个环境变量要小心使用!
    1. 将go编译器版本更新到1.16或更高版本

    请参阅go 1.16的更新指南。这种GC策略已经被放弃,改为按时释放内存,而不是有内存压力时才懒惰地释放。 Go官方网站似乎也认为实时释放内存更好,并且在大多数情况下更合适。真实事故出发:golang 内存问题排查指北

    补充:为了解决pprof显示堆使用的内存小于RSS的问题,这个可以通过手动调用debug.FreeOSMemory来解决,但是有一个执行成本,这个作业。 真实事故出发:golang 内存问题排查指北

    目前,FreeOSMemory 不适用于版本 go1.13 (https://github.com/golang/go/issues/35858)。建议谨慎使用。

    申请结果

    我们选择了两个选项。升级到go1.16后,实例内存并没有出现快速增长。 真实事故出发:golang 内存问题排查指北

    再次使用pprof查看显示状态,发现占用内存的函数也发生了变化。之前占用内存的metrics.glob减少了。这个解决方案似乎有效。

    遇到的其他陷阱

    在调查过程中发现了另一个潜在的内存问题(缺少此服务)。当网格被禁用时,kitc 的服务发现组件具有内存。危险的。 真实事故出发:golang 内存问题排查指北

    从图中可以看到cache.(*Asynccache).refresh占用了大量的内存,并且随着业务处理量的增加,其使用量会不断增加。

    可以想象,在创建新的kiteclient时,客户端可能会被创建多次。所以我进行了代码审查,但找不到任何重复的构建。但是查看kitc源代码我们可以看到,在服务发现过程中,kitc中安装了asynccache缓存池来保存实例。该缓存池每 3 秒更新一次。刷新时,将调用 fetch,并且 fetch 将搜索服务。在服务发现过程中,总是会根据实例的主机、端口和标签(会根据环境env改变)来创建实例,然后将实例存储在 asynccache pool 缓存中。这些实例不会被清除,内存也不会被释放。所以这就是导致内存泄漏的原因。 真实事故出发:golang 内存问题排查指北真实事故出发:golang 内存问题排查指北真实事故出发:golang 内存问题排查指北真实事故出发:golang 内存问题排查指北

    解决方案

    这个项目很早,所以使用的系统很旧。这个问题可以通过更新最新系统来解决。

    思路总结

    首先定义什么是泄漏:

    内存泄漏是指分配给程序的配置内存没有被释放或者由于某种原因无法释放。系统内存垃圾,会造成程序崩溃甚至系统崩溃等严重后果。

    典型故事

    运行情况下,内存泄漏问题如下:

    1.goroutine造成记忆

    (1)goroutine申请过多

    问题解释:

    如果goroutine申请过多,释放速度快于释放增长速度,就会导致更多goroutine。

    示例场景:

    创建带有请求的新客户。当业务请求量很大时,创建的客户太多,没有时间释放。

    (2) 被goroutine屏蔽

    ① I/O 问题

    问题概述:

    I/O 通讯没有设置超时,导致goroutine等待。

    场景示例:

    请求连接第三方网络时,由于网络问题,未收到响应。如果没有设置时间限制,代码将保持锁定状态。

    ②互斥锁未释放

    问题概述:

    goroutine不了解锁来源,导致goroutine阻塞。

    示例场景:

    假设有一个公共变量。goroutine A 关闭了通用标准,而不是发布它。导致其他goroutineB、goroutineC、……、goroutineN无法获取锁源,造成其他goroutine的阻塞。

    ③ waitgroup 使用不当

    问题概述:

    waitgroup 中的 Add、Done 和 wait 的数量不匹配,导致 wait 等待。

    示例:

    WaitGroup 可以被视为goroutine的经理。他需要知道有多少goroutine在为他工作,他需要在完成后通知他,否则他会等到所有的工作都被他的下级完成。当我们添加 WaitGroup 时,程序将等待,直到收到足够的 Done() 标记。假设waitgroup Add(2),Finish(1),那么还有一个任务未完成,因此waitgroup正在等待。详细介绍请参见Goroutine退出机制中的group-wait章节。

    2。选择封锁

    概述问题:

    使用案件没有完全覆盖的选项,所以没有案件可以准备,最后goroutine被封锁了。

    示例场景:

    阻塞通常发生在所选案例的覆盖范围不完整并且没有默认值时。这是示例代码:

    func main() {
        ch1 := make(chan int)
        ch2 := make(chan int)
        ch3 := make(chan int)
        go Getdata("https://www.baidu.com",ch1)
        go Getdata("https://www.baidu.com",ch2)
        go Getdata("https://www.baidu.com",ch3)
        select{
            case v:=<- ch1:
                fmt.Println(v)
            case v:=<- ch2:
                fmt.Println(v)
        }
    }
    

    3。通道阻塞

    问题视图:

    • 写阻塞
      • 无缓冲通道阻塞通常是由写阻塞引起的,因为没有读取
      • 缓冲通道已满,因为缓冲通道已满,因为
    • 读阻塞
      • 期望从通道中读取数据,但没有goroutine写入

    字段中的示例:

    以上三个原因的代码错误将导致通道被阻塞。下面是一些生产环境中真实通道阻塞的例子:

    • lark_cipher机器故障总结
    • Cipher Goroutine泄漏分析

    4。定时器使用不当

    (一)time.after()使用不当

    问题概述:

    平时的time.After()会有内存问题,因为每次.After(xduration)都会创建NewTimer()。在x时间到期之前,新创建的定时器不会被GC,直到到期才被GC。

    随着时间的推移,特别是x的持续时间很大,就会出现内存泄漏。

    示例场景:

    func main() {
            ch := make(chan string, 100)
            go func() {
                    for {
                            ch <- "continue"
                    }
            }()
            for {
                    select {
                    case <-ch:
                    case <-time.After(time.Minute * 3):
                    }
            }
    }
    

    (2) time.ticker 不停止

    简单概述:

    使用 time.Ticker 时,需要手动调用 stop 方法,否则会造成持久内存。 。

    示例:

    func main(){
            ticker := time.NewTicker(5 * time.Second)
            go func(ticker *time.Ticker) {
                    for range ticker.C {
                            fmt.Println("Ticker1....")
                    }
    
                    fmt.Println("Ticker1 Stop")
            }(ticker)
    
            time.Sleep(20* time.Second)
            //ticker.Stop()
    }
    

    建议:始终建议在室外启动计时器,以便在计时器到期时停止计时器。 ?

示例场景:

  1. 直接添加代码。这样,b数组就不会被gc了。
var a []int

func test(b []int) {
        a = b[:3]
        return
}
  1. 另一个随机陷阱中提到的kitc服务检测代码就是这个问题的一个例子。

排查思路总结

如果以后遇到Golang内存使用问题,可以按照以下步骤排查解决:

  1. 查看服务器实例,查看内存使用情况,识别内存。泄漏问题;
  • 可以使用tce平台直接点击【实例列表】;
  • 可以在【时间追踪】中的ms字段中找到;
  1. 记住goroutine的问题;
  • 这里可以使用1中描述的控件来检查goroutines的数量,也可以使用pprof进行样本判断来判断goroutines的数量是否异常增加。
  1. 识别代码问题;
  • 使用pprof按函数名查找特定的代码行,可以使用pprof图表、源等找到;
  • 检查整个调用链是否存在上述情况的问题。对于选择阻塞、通道阻塞、误用切片等问题,优先考虑你的代码逻辑问题,其次考虑系统是否存在不逻辑的地方;
  1. 解决相关问题并在测试环境中检查,并到网上查看;

推荐的故障排除工具

  • pprof:用于分析程序性能的Go语言工具。可以提供包括CPU、堆、goroutine等多种数据,可以通过生成报告、Web界面、交互终端等方式进行配置 3 pprof使用模式有很多
  • Nemo:基于pprof封装,单步采样
  • ByteDog:基于pprof提供更多指标,对整个容器/物理机进行采样
  • Lidar:基于性能的采样结果ByteDog分类(目前平台推荐工具,相比nemo)
  • Smart oncall helper:风筝大师开发的故障排除工具。非常容易使用。在群机器人中输入Just podName

赵振宇字节跳动技术团队

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门