当前位置: 首页 > news >正文

【Golang】Golang内存泄漏问题排查(二)

一、警报突袭:内存飙升之谜

某个平静的下午,正当笔者写代码进入“心流”的境界时,监控系统突然发来一封血红色的告警信息:

■【腾讯云可观测平台告警】 
您好!您账号(账号ID: 00000000,昵称: xxxxxxx)的腾讯云可观测平台告警持续触发
告警内容: 容器服务(2.0)-pod | 内存利用率(占limit,不包含cache) >85%
当前数据: 94.674%(内存利用率(占limit,不包含cache) )
告警对象: 命名空间:xxxxx | Pod名称:xxxxx | 集群ID:xxxxx| 工作负载类型:Deployment | 工作负载名称:xxxxxx
项目|地域: xxxx | xxxx
告警策略: tke-生产-POD事件
触发时间: 2025-03-27 17:40:00 (UTC+08:00) 
持续时间: 6小时0分钟 
您可以登录腾讯云可观测平台控制台查看

作为后台开发老司机的笔者,第一反应是——流量洪峰来了?自动扩容翻车了?抄起键盘就要看扩容配置,却发现监控图表上演着诡异剧情:

请在此添加图片描述

同一服务的Pod们仿佛在玩跷跷板:大部分悠闲地喝着咖啡,几个倒霉蛋却被压得口吐白沫(内存飙升)。

更诡异的是:

  • 1.入口流量没有增加
  • 2.对应服务的日志没有异常的错误信息

请在此添加图片描述

此时运维小哥幽幽飘过一句:“要重启Ingress试试吗?”——这招虽然堪比"万能重启大法",但奇迹发生了!Ingress重启后内存曲线瞬间躺平,深藏功与名。

后记:最终定位是Ingress Pod异常+负载均衡节点过少…可以通过扩容和建立自动扩容机制解决,但这里不是的重点,只是故事的序章…

二、意外收获:潜伏一年的幽灵

正当我们准备结案时,监控图表突然开口说话:

请在此添加图片描述

这个优雅的30度斜线,像极了程序员日渐后移的发际线。原来之前的流量均衡把内存泄漏伪装成了岁月静好,直到这次意外让这只"内存貔貅"现出原形——只吃不吐,内存永远在涨!

三、破案进行时:福尔摩斯式排查

(一)完美复现:请开始你的表演

我们把线上流量克隆到测试环境中几个特定Pod,看着内存曲线复刻犯罪现场:

如图所示

请在此添加图片描述

这下实锤了——不是灵异事件,而是程序界的《无间道》

(二)时间穿梭:监控数据的记忆碎片

当试图追溯第一案发时间时,监控系统露出了尴尬而不失礼貌的微笑:

请在此添加图片描述

去年5月之前的记录?亲,我们监控系统也是要吃饭的呀~

请在此添加图片描述

再早的监控数据已经不可考证,看来通过问题发生时间倒查提交记录的方案走不通了——时间机器计划不行了!

(三)凶器锁定:pprof的照妖镜

祭出Go语言大杀器pprof,连续7天抓取内存快照(Heap)对比:

go tool pprof -http=:9999 -base before.out after.out

结果如下:

请在此添加图片描述

可以看到,结果很明显,内存的增长来源于md5-simd这个库的NewHash方法

恩,md5-simd库的NewHash方法正在上演"内存吃豆人"!

请在此添加图片描述

四、代码追凶:当sync.Pool变成内存黑洞

(一)业务代码排除嫌疑

首先看业务代码,这里主要是用到了一个MD5的工具函数,使用了md5-simd的NewHash方法

import ("encoding/hex"md5simd "github.com/minio/md5-simd"
)var server md5simd.Serverfunc init() {// Create serverserver = md5simd.NewServer()}func LibraryMD5(st string) string {// Create hashing object (conforming to hash.Hash)md5Hash := server.NewHash()defer md5Hash.Close()// Write one (or more) blocksmd5Hash.Write([]byte(st))return hex.EncodeToString(md5Hash.Sum([]byte{}))
}

对比下github的用法:

请在此添加图片描述

几乎一致,唯一不同的地方在于init()的方法没有做defer操作,不过这里应该不是问题的重点,因为这里只会导致一次性泄漏

请在此添加图片描述

(二)库代码的罗生门

如果业务代码没有调用错,那看看库代码

首先go mod vendor下,拉取下引用库的源代码,看到逻辑如下:

// md5Wrapper is a wrapper around the builtin hasher.
type md5Wrapper struct {hash.Hash
}
...
var md5Pool = sync.Pool{New: func() interface{} {return md5.New()
}}...// NewHash -- return regular Golang md5 hashing from crypto
func (s *fallbackServer) NewHash() Hasher {return &md5Wrapper{Hash: md5Pool.New().(hash.Hash)}
}

可以看到,这里NewHash方法主要做的逻辑是生成一个md5Wrapper结构体返回回去,md5Wrapper结构体里面有个hash.Hash的对象,用于生成一个MD5值

其中第7行用到了一个sync.Pool,这里猜测是为了优化每次NewHash() 时,生成hash.Hash的重复调用md5.New()性能过重的问题,所以用sync.Pool来复用md5.New()的方法

但这里确实是内存泄漏的指向点,所以为了确认这里的合理性,查看下sync.Pool文档:

https://victoriametrics.com/blog/go-sync-pool/

其中有一句话引起了笔者的注意:

To create a pool, you can provide a New() function that returns a new object when the pool is empty. This function is optional, if you don’t provide it, the pool just returns nil if it’s empty.

看起来,New()是直接生成一个对象,貌似并没有做内存复用,那应该用哪个函数呢,继续查找

So the Get() flow could be simply imagined like this:
请在此添加图片描述ared pool chain is empty as well, sync.Pool will either create a new object (assuming you’ve provided a New() function) or just return nil. And, by the way, there’s also a victim mechanism inside the shared pool, but we’ll also cover that in the last.

看起来从pool获取可复用的对象应该用Get()方法

这里使用了下Copilot来double check下,结果如下:

用户: 51行md5Pool.New()的用法是对的吗?

**Copilot:**在51行中,md5Pool.New().(hash.Hash)的用法是正确的。以下是具体原因:
1. sync.Pool的作用md5Pool是一个对象池,用于缓存和复用hash.Hash对象。当调用md5Pool.New()时,如果池中没有可用对象,会调用md5.New()创建一个新对象;否则,会复用池中的对象。
2. 类型断言md5.New()返回的是hash.Hash接口类型,因此md5Pool.New()返回的对象可以直接断言为hash.Hash。这种断言是安全的,因为md5Pool``New字段明确返回md5.New()*的结果。
3. 性能优化:通过复用对象,可以减少内存分配和垃圾回收的开销,提高性能。
总结:这种用法是标准且高效的,符合sync.Pool的设计初衷。

看起来是笔者理解错了?那再问下Get方法

用户: 我看文档用get方法更好?
Copilot:根据文档和sync.Pool的设计初衷,使用Get方法确实比直接调用New字段更合适。以下是具体原因和改进建议:
问题分析
1. 当前代码
gomd5Pool.New().(hash.Hash)
这种方式直接调用了New字段定义的函数,绕过了sync.Pool的对象复用机制。
2.**Get**方法的优势:
Get方法会优先从池中获取对象,只有在池为空时才调用New字段创建新对象。
这样可以最大化复用对象,减少内存分配和垃圾回收的压力。
改进建议
将代码修改为:
gomd5Pool.Get().(hash.Hash)
为什么更优?
性能提升:通过复用池中的对象,减少了内存分配和GC开销。
符合设计:**sync.Pool**的核心功能是通过GetPut方法管理对象复用,直接调用New字段会绕过这一机制。
示例
当前代码:goreturn &md5Wrapper{Hash: md5Pool.New().(hash.Hash)}改进后:goreturn &md5Wrapper{Hash: md5Pool.Get().(hash.Hash)}总结:使用Get方法更符合sync.Pool的设计目标,能够更好地发挥对象复用的性能优势。

额,似乎在笔者的引导下,又给出了期望的结果,当然,笔者也不确定这里是否存在人情世故的因素在里面,最好的办法还是验证一下吧:

(三)性能测试实锤凶手

既然copilot直接出结论不容易,那就帮忙写几个测试用例

首先写三个对比的测试用例

用户:帮我写两个测试用例,新建一个sync.pool的结构体,然后初始化一个固定的字符串,然后测试用例是分别调用New()、Get()来生成字符串往一个data结构体里面写

结果如下:

package mainimport ("runtime""sync""testing"
)var (stringPool = sync.Pool{New: func() interface{} {return "default_string"},}
)type data struct {values []string
}func (d *data) write(s string) {d.values = append(d.values, s)
}// 保留用户要求的测试用例:直接调用New()
func BenchmarkNewAlloc(b *testing.B) {d := &data{}b.ReportAllocs() // 报告内存分配情况b.ResetTimer()for i := 0; i < b.N; i++ {s := stringPool.New().(string) // 直接调用New()d.write(s)                     // 写入操作}runtime.KeepAlive(d) // 防止编译器优化
}// 补充其他对比测试用例
func BenchmarkGetReuse(b *testing.B) {d := &data{}b.ReportAllocs()b.ResetTimer()for i := 0; i < b.N; i++ {s := stringPool.Get().(string) // 通过Get获取d.write(s)stringPool.Put(s) // 放回池中}runtime.KeepAlive(d)
}

跑了下结果:

BenchmarkNewAlloc-12 36500701 28.95 ns/op 86 B/op 0 allocs/op
BenchmarkGetReuse-12 21071977 59.86 ns/op 111 B/op 1 allocs/op

居然Get比New还要高

请在此添加图片描述

啊!想起来了

在某个之后,Golang字符串对象太小被编译器优化这就会导致——sync.Pool对小对象复用反而增加开销

那就把字符串弄的大一些,找copilot再修改下:

type testObj struct {data [1024]byte
}var (stringPool = sync.Pool{New: func() interface{} {return &testObj{}},}
)type data struct {objs []*testObj
}func (d *data) write(obj *testObj) {d.objs = append(d.objs, obj)
}// 保留用户要求的测试用例:直接调用New()
func BenchmarkNewAlloc(b *testing.B) {d := &data{}b.ReportAllocs() // 报告内存分配情况b.ResetTimer()for i := 0; i < b.N; i++ {s := stringPool.New().(*testObj) // 直接调用New()d.write(s)                       // 写入操作}runtime.KeepAlive(d) // 防止编译器优化
}// 补充其他对比测试用例
func BenchmarkGetReuse(b *testing.B) {d := &data{}b.ReportAllocs()b.ResetTimer()for i := 0; i < b.N; i++ {s := stringPool.Get().(*testObj) // 通过Get获取d.write(s)stringPool.Put(s) // 放回池中}runtime.KeepAlive(d)
}

结果如下:

BenchmarkNewAlloc
BenchmarkNewAlloc-12 11414361 98.03 ns/op 1070 B/op 1 allocs/op
BenchmarkGetReuse
BenchmarkGetReuse-12 24063990 42.47 ns/op 42 B/op 0 allocs/op

啊,结果对了!

当然为了确保这里的结果最终正确,找了下同事帮忙DoubleCheck并手撸了用例

用例如下:

var bufferPool = sync.Pool{New: func() interface{} {return &bytes.Buffer{}},
}var data = make([]byte, 10000)func BenchmarkBufferWithPoolGet(b *testing.B) {for n := 0; n < b.N; n++ {buf := bufferPool.Get().(*bytes.Buffer)buf.Write(data)buf.Reset()bufferPool.Put(buf)}
}func BenchmarkBufferWithPoolNew(b *testing.B) {for n := 0; n < b.N; n++ {buf := bufferPool.New().(*bytes.Buffer)buf.Write(data)buf.Reset()bufferPool.Put(buf)}
}func BenchmarkBuffer(b *testing.B) {for n := 0; n < b.N; n++ {var buf bytes.Bufferbuf.Write(data)}
}

结果如下:

BenchmarkBufferWithPoolGet
BenchmarkBufferWithPoolGet-12 8709902 137.0 ns/op 0 B/op 0 allocs/op
BenchmarkBufferWithPoolNew
BenchmarkBufferWithPoolNew-12 1584344 770.4 ns/op 10314 B/op 2 allocs/op
BenchmarkBuffer
BenchmarkBuffer-12 1642616 758.8 ns/op 10240 B/op 1 allocs/op

完美,用bytes.Buffer更直接的体现出这里用Get的优势,只有0B/op;而New方法使用10314 B/op,源生方法使用 10240 B/op,从这个结果来看,New的方法反而内存消耗变多了,而且因为在pool里面生成,pool一直在引用着这些对象,反而会导致内存泄漏!

请在此添加图片描述

五、完美收官:PR拯救世界

把代码修正为:

// NewH// NewHash -- return regular Golang md5 hashing from crypto
func (s *fallbackServer) NewHash() Hasher {return &md5Wrapper{Hash: md5Pool.Get().(hash.Hash)}
}

提交PR!

https://github.com/minio/md5-simd/issues/36

github审核通过!

请在此添加图片描述

六、线上验证:

发布之后,结果如下:

请在此添加图片描述

md5的内存占用已经从400MB+降低到了57MB,且不再持续增长

指标上:

请在此添加图片描述

内存曲线立刻从过山车模式切换成佛系模式:

完美,又是工程师拯救世界的一天

请在此添加图片描述

后记:程序员的自我修养

这次经历让我们深刻领悟:

1.监控系统不是八卦记者,要给足它爆料素材

2.第三方库就像前任——看起来很美好,深入了解可能吓一跳

3.性能优化不是玄学,要用数据说话,要用用例作证

4.永远对sync.Pool保持敬畏——用对了是神器,用错了就是内存黑洞

最后温馨提示:当觉得代码完美无缺时,不妨想想这个关于缓冲池的寓言故事。毕竟,在编程世界里,最危险的bug往往穿着"最佳实践"的外衣。

http://www.lryc.cn/news/620696.html

相关文章:

  • OpenCv(三)——图像平滑处理
  • 8. 函数简介
  • OpenCV中对图像进行平滑处理的4种方式
  • HarmonyOS AI辅助编程工具(CodeGenie)智慧调优
  • 力扣(LeetCode) ——225 用队列实现栈(C语言)
  • 信息vs知识:人类学习与AI规则提取
  • 异步编程的 8 种实现方式:疑难点与注意事项解析
  • 《疯狂Java讲义(第3版)》学习笔记ch4
  • 安全加固4(K8S最小化微服务安全)
  • C++ 中的元控制流与概念化类型擦除
  • Elasticsearch 中如何配置 RBAC 权限-实现安全的访问控制
  • 论郑和下西洋元素融入课件编辑器的意义与影响​
  • 智能门锁:安全与便捷的现代家居入口
  • UE小:编辑器模式下「窗口/鼠标不在焦点」时仍保持高帧率
  • UE5配置MRQ编解码器输出MP4视频
  • Mybatis学习笔记(三)
  • PostgreSQL 免安装
  • AXI GPIO 2——ZYNQ学习笔记
  • 相较于传统AR作战环境虚拟仿真系统,其优势体现在哪些方面?
  • Mysql基本使用语句(一)
  • 生成和发布博客的工作流
  • 力扣(串联所有单词的子串)
  • ChatECNU 边缘 AI 智能体对话
  • 在线进销存系统高效管理网站源码搭建可二开
  • 倾斜按钮(径向渐变详细介绍)
  • MCU中的LTDC(LCD-TFT Display Controller)
  • 项目日志框架与jar中日志框架冲突 解决
  • 20. 了解过尾递归优化吗
  • 1780. 判断一个数字是否可以表示成三的幂的和
  • 大模型工程化落地:从模型选择到性能优化的实战指南