likes
comments
collection
share

go内存泄露实战

作者站长头像
站长
· 阅读数 26

背景

leader反馈生产环境我负责的某个服务发生了内存泄露。

接口背景

  1. 这个服务包含一个从db中实时获取统计信息的接口,接口中对db返回的结果,做了一定时间的缓存。
  2. 这个服务是把原来lua的逻辑,转换成golang重新实现了一遍。缓存策略没有优化,只翻译了代码。

排查过程

我其实很迷茫,生产环境的内存曲线我拿不到,没有任何视图,只有一个口头反馈。

我问leader怎么观察出来的,他说物理机内存告急了,占用了超过60%的机器内存,总数是多少他并没有说。

我一筹莫展,代码也看不出问题(自己写的代码,怎么会看得出bug?)

于是改了一个版本,发到生产环境,开启pprof服务端口,下次发生内存泄露或者OOM的时候,就可以抓一下heap快照来分析。

生产环境堆内存使用

我想到服务已经接入Prometheus,应该有一些指标可以观察吧,如图所示,是metrics端口的信息

接入Prometheus:github.com/prometheus/…

go内存泄露实战

我看这个指标似乎是我要的,于是在grafana上面输入指标名称,筛选app,拿到了这该死的内存曲线。(如果你问我怎么在grafana上面查询Prometheus采集的指标,那就网上找找吧,或者我后面再写一篇补充一下。)

单位是bytes,也就是图中的数字去掉9个0,就是G的单位。好家伙,内存占用达到了20+G,最巅峰居然达到了40G,我这服务,就一个接口啊,救命。

go内存泄露实战

  • 24h长期看,内存不断上升,疑似发生内存泄露(中间下降是因为加了pprof重启了)

go内存泄露实战

  • 15min短期看,内存呈现周期性的上升下降,整体呈现上升趋势(周期性下降应该是gc作用)

排查PPROF

  • 生产环境抓取pprof的heap快照,top10排名第一位是圈中的方法
curl http://localhost:6060/debug/pprof/heap?seconds=30 > heap.out

# 下载heap.out到本地,本地使用以下方法分析
go tool pprof heap.out
  • 内存占用top10

go内存泄露实战

  • 查看矢量图

go内存泄露实战

图中的ToDomain方法非常可疑,并且发生了inline,伪代码如下

func (s *C) ToDomain() *domain.C {
   if s == nil {
      return nil
   }
   return &domain.C{
      A:    s.A,
   }
}

我大概看了一个小时,也没看出来这个方法哪里有问题。直觉告诉我,这个指针有问题!

于是我开始科学上网Google,似乎有方向了。

go内存泄露实战

  • 本地写一个main方法,模拟这个方法,使用go build观察编译器的优化,发现确实会发生inline。有趣的是,发现分配的对象逃逸到堆上。

    • 众所周知,函数调用是发生在栈上,内存分配在随着入栈出栈随之就释放了
    • 如果内存从栈逃逸到堆上,需要依赖堆内存的GC来释放内存,而很多大对象在GC扫描后可能依然存活在堆中

go内存泄露实战

go tool compile -m main.go
  • 去掉指针
func (s *C) ToDomain() domain.C {
   return domain.C{
      A:    s.A,
   }
}

以下分别是返回指针和返回对象的分析对比

go内存泄露实战

图中可以看出,返回指针会发生escapes to heap的现象。此时相当于在栈中申请开辟的内存,逃逸到了堆上面,增加了GC压力。

再次抓生产上面的heap,跟原来的对比,发现出现问题的地方,不再是ToDomain了。

问题解决了?好像完全没解决。因为什么?内存占用20G,你跟我说你解决了?

go内存泄露实战

众所周知,内存泄露,重在泄露,我在网上查了各种内存泄露的原因,都快会背了,滚瓜烂熟的。无非就是切片切割处理问题,全局变量问题,defer逻辑导致没释放等,各种标准答案。

go内存泄露实战

而我依然没什么思路,套到我的代码里面,这是既没切割切片,也没全局变量,更没有defer。

这大概也应了那句话:懂得很多道理,依然过不好这一生

此时再看一眼内存曲线,这内存曲线,是拼了命往上涨啊,一点下降趋势都没有,心都凉了。

go内存泄露实战

说点题外话,昨天创建了一个任务,修复xxx工程内存泄露问题

这个版本的解决方案(栈逃逸到堆导致内存泄露,去掉返回指针,改成对象)我在晨会上把老板忽悠得一愣一愣的,已经发给老板帮忙发布了,老板都把这个任务的状态改成done了。

done不就代表内存泄露解决了吗?

到现在为止,内存泄露我已经排查两天了,我觉得第三天晨会再告诉老板问题并没有没解决,他估计会想杀了我这傻逼祭天!

寻求帮助

既然没有思路,那就寻求帮助。

突然想起昨天晨会另一个组有一个大佬说也在处理内存泄漏问题,我怀着忐忑的心情,唯唯诺诺叫了一声哥(文字上),开始了我的求助之路。

也许是这声哥的原因,大佬秒回我的消息。

于是我开始了会议投屏,展示了我前面的排查思路,pprof的top内存统计,矢量图,火焰图,开始反复分析。

go内存泄露实战

大佬特别热情且清醒,让我把代码打开,别整这些没用的。

talk is cheap, show me your code.

大佬一行代码一行代码帮我分析,还给我的破代码进行反复分析,顺便提了一些优化建议。

大佬的思路是,当前函数确实没有全局变量,生成的数组结果,也返回给上一层了,ToDomain这里看起来不像有问题。我们接着看上一层的逻辑,直到整个接口的调用链路都排查一遍。

  • 接口的调用链路
- handler
  - usecase
    - repo
      - ToDomain

突然,在pprof内存爆炸的地方的上层逻辑中发现,在返回给用户之前,数组的结果塞进了cache(内存缓存),内存缓存的清除策略居然是永不清除。WTF!!!

这也就能解释为什么内存不断往上涨了,好家伙,全泄露到缓存里面的map了,map的key是强引用,GC无法消灭这些内存。

# usecase层逻辑
func() GetDatas (interface{},nil) {
    datas, err := uc.Repo.Datas(ctx, opts)
    if err != nil {
       logger.Err(err).Warnf("datas from db err")
       return nil, err
    }
    uc.cache.Set(cacheKey, datas, time.Second*10000)
    return datas, nil
}

这时候,内心虽然大喊自己是傻逼,但有点小激动,难道就要解决了吗。

反思

为什么lua的版本,没有这个内存泄露问题?

我突然想起,这个逻辑,也是照着原来的接口逻辑(lua)写的啊。

为什么原来的逻辑不会发生内存泄露?

通过咨询另一位大佬,发现lua使用nginx的内存模型,分配了1G的容量给内存缓存,如果缓存超过了1G,nginx会自动清理掉。

而我使用的是自行封装的cache,里面就是一个map,不会自动清理。

使用的库是这个:github.com/patrickmn/g…

go内存泄露实战

一开始选择这个缓存策略的原因是,缓存就算过期了,在数据库挂了的情况下,缓存依然可以支撑一部分用户的请求。

解决方案

于是选择在这个接口使用一个单独的cache,可以设置缓存过期清除时间周期。

  • interval支持可配置

go内存泄露实战

你没看错,改动点只有这么一点。

新版上线后,内存曲线如下:

go内存泄露实战

我的interval设置成1h,内存曲线在每小时都会下降,总内存消耗也没有超过4G。

至此,整个内存泄露问题,基本上破案了。

思考

你以为故事到这里就结束了吗?

当我把这个结果跟组长同步的时候,他突然灵魂拷问:缓存可不可以使用LRU

此刻我的脑海里面闪过一丝迷茫,什么是LRU?

很快啊,我就想起来了,之前面试题还做过呢。

LRU无需手动清理过期的内存数据,超过长度限制的数据,会自动清除。

你们觉得,可以使用LRU吗?

如果使用LRU,LRU中缓存的list长度应该设置多少?key总共有多少个?会不会引入新的问题?

未完待续,下一次,我们聊聊缓存优化...

参考

  1. blog.devgenius.io/in-depth-an…
  2. medium.com/eureka-engi…
转载自:https://juejin.cn/post/7202169414408880186
评论
请登录