上个月我们线上出了一个诡异的Bug,服务在升级到Go 1.16之后偶尔会出现内存泄漏,排查了很久都找不到原因。最后我花了一整夜的时间,终于找到了问题的根源。本文记录了这次Bug排查的完整过程,包括问题现象、排查思路、用到的工具、最终的原因和解决方案。如果你也在用Go做开发,或者对线上问题排查感兴趣,希望这篇文章能给你一些参考和启发。

一、问题现象

先说说问题是怎么发现的。

我们有一个核心的微服务,一直运行得很稳定。上个月我们把这个服务从Go 1.15升级到了Go 1.16,同时做了一些常规的功能迭代。升级之后刚开始一切正常,没有发现什么问题。

但是过了几天,运维同学发现这个服务的内存使用量在缓慢上升。刚开始的时候还不明显,每天只上升几十MB。但是过了一周之后,内存使用量已经从正常的500MB上升到了2GB,而且还在继续上升。如果不重启的话,最终会因为OOM被系统杀掉。

我们第一反应是代码里有内存泄漏。但是我们review了最近的代码变更,没有发现明显的问题。而且这个服务之前在Go 1.15上运行了很久,一直很稳定,内存使用量一直保持在500MB左右,没有出现过泄漏的情况。

更诡异的是,这个泄漏不是必现的。在测试环境中,我们跑了很久的压力测试,内存都很稳定,没有出现泄漏。但是一到线上,跑个几天就会出现泄漏。这种线上必现但是测试环境不复现的问题,是最难排查的。

我们尝试了回滚代码,把最近的功能变更都回滚了,只保留Go 1.16的升级。结果内存泄漏还是存在。这时候我们开始怀疑是不是Go 1.16本身的问题,或者是Go 1.16和我们的某些代码不兼容。

但是Go 1.16是一个正式发布的稳定版本,不太可能有这么明显的内存泄漏。而且如果是Go本身的问题,应该会有很多人遇到,网上应该会有相关的讨论。我们搜了一下,没有发现类似的问题报告。

这个问题就像一个幽灵,若隐若现,让人摸不着头脑。

二、初步排查

发现问题之后,我们开始了初步的排查。

1. 看监控指标

首先我们看了各种监控指标,包括CPU使用率、内存使用率、GC次数、GC停顿时间、goroutine数量等。

CPU使用率正常,没有异常。GC次数和停顿时间也正常,没有明显增加。goroutine数量也正常,没有出现goroutine泄漏。

唯一异常的就是内存使用率在缓慢上升。而且内存上升主要是堆内存(heap)在上升,栈内存和其他内存都正常。这说明确实是堆上的对象没有被回收,存在内存泄漏。

2. 用pprof看内存分布

Go语言自带了pprof性能分析工具,可以很方便地分析内存使用情况。我们在服务中引入了net/http/pprof包,通过HTTP接口来获取pprof数据。

我们在内存已经上升到1.5GB的时候,抓取了一个heap profile,看看内存都被什么对象占用了。

看了profile之后,我们发现大部分内存都被[]byte对象占用了。这些[]byte对象主要是在几个地方分配的:一个是HTTP请求体的读取,一个是数据库查询结果的序列化,还有一个是日志打印。

但是这些地方的代码看起来都很正常,没有明显的泄漏。HTTP请求体读取完之后会自动关闭,数据库查询结果用完之后会被回收,日志打印的字符串也应该很快被回收。

我们又抓取了几个不同时间点的profile,对比内存的增长情况。发现[]byte对象的数量在持续增长,而且这些对象的大小都差不多,都是几KB到几十KB。这说明有某种固定大小的[]byte对象在不断被创建但是没有被回收。

但是profile只能看到对象在哪里分配的,看不到是谁在引用这些对象导致它们不能被回收。要找到引用链,需要更深入的分析。

3. 怀疑是sync.Pool的问题

看到[]byte对象的泄漏,我们第一反应是怀疑sync.Pool的问题。

我们的代码中在几个地方用了sync.Pool来复用[]byte对象,减少内存分配。比如在HTTP请求处理的时候,从Pool中获取一个[]byte缓冲区,用完之后再放回Pool。

如果Pool的使用有问题,比如放回去的[]byte被其他地方引用了,或者Pool中的对象被意外持有了,就可能导致内存泄漏。

我们仔细review了所有使用sync.Pool的地方,但是没有发现明显的问题。每个地方都是正确地Get和Put,Put之前也都做了重置。而且这些代码在Go 1.15上运行了很久,一直没有问题,不太可能突然就出问题了。

我们甚至尝试把所有的sync.Pool都去掉,直接用make分配[]byte。结果内存泄漏还是存在,只是泄漏的速度稍微慢了一点。这说明sync.Pool不是根本原因,只是可能加剧了泄漏的程度。

4. 怀疑是第三方库的问题

排除了自己代码的问题之后,我们开始怀疑是第三方库的问题。

我们的服务依赖了很多第三方库,比如HTTP框架、数据库驱动、消息队列客户端、日志库等等。如果某个第三方库在Go 1.16下有兼容性问题,就可能导致内存泄漏。

我们列了一下最近升级过的第三方库,逐个排查。但是排查了一圈,没有发现明显的问题。而且这些第三方库都是比较流行的,很多人都在用,如果有Go 1.16的兼容性问题,应该早就有人发现了。

我们甚至尝试把几个核心的第三方库都回退到之前的版本,但是内存泄漏还是存在。这时候我们有点束手无策了。

三、深入排查

初步排查没有找到原因,问题还在持续。这时候已经是下午了,领导说这个问题必须今晚解决,因为明天有一个大的活动,服务不能出问题。于是我泡了一杯咖啡,开始了通宵排查。

1. 用GODEBUG分析

我想到Go语言有一个GODEBUG环境变量,可以开启一些调试选项。比如gctrace=1可以打印GC的详细信息,allocfreetrace=1可以跟踪每个对象的分配和释放。

我先在测试环境中用gctrace=1运行了服务,看看GC的情况。但是测试环境中没有复现泄漏,所以GC信息看起来都很正常。

然后我尝试在测试环境中模拟线上的流量,看看能不能复现问题。我用压测工具模拟了线上的请求量和请求模式,跑了几个小时,但是内存还是很稳定,没有出现泄漏。

这说明这个问题和线上的某种特定条件有关,可能是某种特定的请求模式,或者是某种特定的数据,或者是某种特定的并发情况。这种条件在测试环境中很难完全模拟。

2. 在线上开启pprof持续采样

既然测试环境复现不了,那我就只能在线上分析了。

我在一台线上机器上开启了pprof的持续采样,每隔半个小时抓取一次heap profile,然后把这些profile保存下来,事后对比分析。

同时我还开启了goroutine profile和block profile,看看有没有goroutine泄漏或者阻塞的情况。

跑了几个小时之后,我收集到了多个时间点的profile。对比这些profile,我发现了一个有趣的现象:内存的增长主要集中在某一个特定的时间段。这个时间段正好是每天晚上的高峰期,请求量比较大,而且有一些特定的请求在这个时间段比较多。

这个发现很重要,它说明内存泄漏和某种特定的请求有关。我开始分析这个时间段的请求日志,看看和其他时间段有什么不同。

3. 分析请求日志

我把这个时间段的请求日志都拉了下来,和其他时间段的日志做对比。

对比之后我发现,这个时间段的请求中,有一类请求的比例明显比其他时间段高。这类请求是文件上传请求,用户会上传一些图片或者文档文件。这些文件的大小从几百KB到几MB不等。

文件上传请求?这和内存泄漏有什么关系呢?

我们的文件上传逻辑是这样的:用户上传文件,服务读取文件内容,然后把文件上传到对象存储,最后返回文件的URL。整个过程中,文件内容会被读取到内存中处理。

但是文件处理完之后,相关的内存应该会被回收啊。除非有什么地方持有了这些文件内容的引用,导致它们不能被回收。

我开始仔细review文件上传的代码。看了一遍之后,我发现了一个可疑的地方。

4. 发现可疑代码

在文件上传的代码中,有一段是这样的:我们用了一个第三方的文件处理库来处理上传的文件。这个库有一个特性,就是可以注册一个全局的回调函数,在文件处理完成之后调用。我们在这个回调函数中做了一些日志记录和统计。

代码大概是这样的:

func init() {
    fileprocessor.OnComplete(func(data *FileData) {
        // 记录日志
        log.Printf("文件处理完成: %s, 大小: %d", data.Name, data.Size)
        // 记录统计
        stats.RecordFile(data.Size)
        // 把文件数据保存到一个全局的缓存中,用于后续处理
        globalCache.Put(data.ID, data)
    })
}

看到globalCache.Put(data.ID, data)这一行的时候,我心里咯噔了一下。这里把整个FileData对象(包含文件内容的[]byte)放到了一个全局的缓存中。

这个全局缓存是我们自己实现的一个简单的LRU缓存。它的作用是缓存最近处理过的文件数据,避免重复处理。但是这个缓存有一个问题:它的容量限制是按条目数来算的,不是按内存大小来算的。

也就是说,这个缓存最多缓存1000个条目。如果每个条目都很大(比如几MB的文件内容),那1000个条目就可能占用几GB的内存!

但是这也不对啊。这个缓存已经用了很久了,在Go 1.15上一直没有问题。为什么升级到Go 1.16之后就出问题了呢?

我继续看这个缓存的实现。看了之后,我终于发现了问题所在。

四、找到根本原因

这个LRU缓存的实现是这样的:它用一个map来存储数据,用一个双向链表来维护访问顺序。当缓存满了之后,会删除链表头部(最久未访问)的条目。

代码大概是这样的:

type LRUCache struct {
    capacity int
    items    map[string]*list.Element
    list     *list.List
    mu       sync.Mutex
}

type cacheItem struct {
    key   string
    value interface{}
}

func (c *LRUCache) Put(key string, value interface{}) {
    c.mu.Lock()
    defer c.mu.Unlock()

    if elem, ok := c.items[key]; ok {
        c.list.MoveToBack(elem)
        elem.Value.(*cacheItem).value = value
        return
    }

    item := &cacheItem{key: key, value: value}
    elem := c.list.PushBack(item)
    c.items[key] = elem

    if c.list.Len() > c.capacity {
        // 删除最久未访问的条目
        oldest := c.list.Front()
        if oldest != nil {
            c.list.Remove(oldest)
            item := oldest.Value.(*cacheItem)
            delete(c.items, item.key)
            // 注意:这里没有显式地释放value
        }
    }
}

看到问题了吗?在删除最久未访问的条目的时候,代码从map和链表中删除了条目,但是没有显式地把value设置为nil。

这在Go 1.15及之前的版本中可能不是问题,因为GC会自动回收不再被引用的对象。但是在Go 1.16中,有一个变化导致了这个问题。

Go 1.16对内存分配器和GC做了一些优化,其中一个变化是对大对象(大于32KB的对象)的分配和回收策略做了调整。在Go 1.15中,大对象是直接从堆上分配的,回收的时候也会直接归还给操作系统。但是在Go 1.16中,大对象的分配和回收策略有了一些变化,导致某些情况下大对象的内存不会被及时归还给操作系统,看起来就像是内存泄漏一样。

具体来说,我们的文件内容[]byte通常都是几KB到几MB的大对象。当这些对象从LRU缓存中被删除的时候,虽然逻辑上已经没有引用了,但是由于Go 1.16的内存分配器的某些特性,这些大对象占用的内存页不会被立即归还给操作系统,而是被保留在Go的内存池中,用于后续的大对象分配。

如果后续没有足够多的大对象分配来复用这些内存页,这些内存就会一直被持有,看起来就像是内存泄漏一样。而在我们的场景中,文件上传请求是间歇性的,高峰期的时候会分配很多大对象,高峰期过后这些大对象被回收了,但是内存页没有被归还给操作系统,导致内存使用量降不下来。

而且由于我们的LRU缓存删除条目的时候没有显式释放value,这些value对象可能还被某些内部结构引用着(比如链表的Element对象可能还持有对value的引用,虽然Element已经从链表中移除了,但是Element对象本身可能还没有被GC回收),导致这些大对象不能被GC回收,内存泄漏就更加明显了。

在Go 1.15中,由于内存分配器的策略不同,这些内存页会被更及时地归还给操作系统,所以内存泄漏不明显。升级到Go 1.16之后,内存分配器的策略变化让这个问题暴露了出来。

所以根本原因是:我们的LRU缓存实现有问题,删除条目的时候没有正确释放资源,这个问题在Go 1.15中被掩盖了,升级到Go 1.16之后因为内存分配器的变化而暴露了出来。

找到原因的时候,已经是凌晨三点了。我看着屏幕上的分析结果,长长地舒了一口气。

五、解决方案

找到原因之后,解决方案就很简单了。

1. 修复LRU缓存

首先修复LRU缓存的实现。在删除条目的时候,显式地把value设置为nil,确保没有任何引用持有这个对象。

修改后的代码:

if c.list.Len() > c.capacity {
    oldest := c.list.Front()
    if oldest != nil {
        c.list.Remove(oldest)
        item := oldest.Value.(*cacheItem)
        delete(c.items, item.key)
        // 显式释放value
        item.value = nil
        oldest.Value = nil
    }
}

同时,在Get方法中,如果缓存未命中,也要确保不会意外持有引用。

2. 按内存大小限制缓存容量

除了修复释放逻辑,我们还改进了缓存的容量限制方式。从按条目数限制改成按内存大小限制。

我们给缓存设置了一个最大内存使用量(比如500MB),每次Put的时候计算value的大小,如果总内存使用量超过了限制,就删除最久未访问的条目,直到内存使用量降到限制以下。

这样可以确保缓存的内存使用量不会无限增长,即使有泄漏也不会太严重。

3. 给大对象设置超时

对于文件内容这种大对象,我们还设置了一个超时时间。即使缓存容量还没满,超过一定时间(比如5分钟)的条目也会被自动删除。这样可以确保大对象不会在缓存中停留太久,减少内存占用。

4. 降级Go版本(临时方案)

在修复代码上线之前,我们先临时把服务降级回了Go 1.15。降级之后内存泄漏就消失了,内存使用量恢复到了正常的500MB左右。这也验证了我们的分析是正确的。

等修复代码测试通过之后,我们再重新升级到Go 1.16。升级之后观察了几天,内存使用量一直很稳定,没有再出现泄漏的情况。

六、经验总结

这次通宵排查Bug的经历,让我总结了一些经验。

1. 升级Go版本要谨慎

虽然Go语言的版本兼容性做得很好,大部分情况下升级版本不会有问题,但是也不能掉以轻心。尤其是像内存分配器、GC、调度器这些底层组件的变化,可能会在某些特定场景下导致行为变化,暴露之前被掩盖的问题。

升级Go版本之后,要充分测试,尤其是要做长时间的稳定性测试和压力测试,观察内存、CPU、GC等指标有没有异常。不要只做功能测试就直接上线。

2. 资源释放要显式

虽然Go语言有GC,不需要手动管理内存,但是在某些场景下,显式地释放资源还是很有必要的。比如在缓存、对象池、连接池这些场景中,删除对象的时候显式地把引用设置为nil,可以确保对象能够被GC及时回收,避免内存泄漏。

尤其是对于大对象,显式释放更加重要。大对象占用的内存多,如果不能及时回收,对内存的影响会很大。

3. 缓存的容量限制要合理

缓存是内存泄漏的高发区。在实现缓存的时候,容量限制一定要合理。不要只按条目数限制,还要考虑每个条目的大小。如果缓存的对象大小差异很大,按条目数限制可能会导致内存使用量不可控。

最好是按内存大小来限制缓存的容量,同时设置超时时间,确保缓存的对象不会无限期地停留。

4. 线上问题排查要有耐心

线上问题往往是复杂的,尤其是这种测试环境不复现的问题,排查起来非常困难。这时候一定要有耐心,不要轻易放弃。

排查的时候要系统化,从现象出发,一步步缩小范围。先看监控指标,再用pprof等工具分析,再看代码,再做实验验证。每一步都要有依据,不要凭猜测。

有时候问题的原因可能很隐蔽,需要深入到Go语言的底层实现才能找到。这时候需要有扎实的基础知识,对Go语言的内存模型、GC、调度器等有深入的理解。

5. 用好pprof等工具

Go语言自带的pprof是一个非常强大的性能分析工具。在排查内存泄漏、CPU占用高、goroutine泄漏等问题的时候,pprof是必不可少的。

要熟练掌握pprof的使用,包括heap profile、CPU profile、goroutine profile、block profile等。要学会对比不同时间点的profile,找到内存增长的来源。要学会用go tool pprof的各种命令来深入分析profile数据。

除了pprof,GODEBUG、trace等工具也很有用。在排查复杂问题的时候,要综合运用各种工具,从多个角度分析问题。

七、写在最后

这次Bug排查从下午一直持续到第二天凌晨,花了将近12个小时。虽然过程很辛苦,但是最终找到了问题的根源,也学到了很多东西。

这次经历让我深刻地认识到,作为一个工程师,不仅要会写代码,还要会排查问题。线上问题排查是工程师的核心能力之一,它需要扎实的基础知识、系统化的思维方式、熟练的工具使用能力,以及足够的耐心和毅力。

同时,这次经历也让我对Go语言有了更深的理解。以前我只关注Go语言的上层用法,对底层的内存分配器、GC、调度器等了解不深。这次问题逼着我去深入学习这些底层知识,也让我意识到,只有深入理解了这些底层机制,才能写出真正高质量、高性能的Go代码。

最后,我想对每一个工程师说:线上问题不可怕,可怕的是遇到问题就放弃,或者不去深入分析根本原因。每一个线上问题都是一次学习的机会,只要你认真对待,深入分析,就一定能从中学到很多东西,让自己变得更强。

用一句话结束本文:"每一个熬过夜排查的Bug,都是工程师成长路上的垫脚石。"愿每一个工程师都能在排查问题的过程中,不断成长,不断进步。