实操使用 go pprof 对生产环境进行性能分析(问题定位及代码优化)
问题背景
上周五下午快下班的时候,运维同事突然在群里发消息说服务器内存告警了。我打开监控一看,心里咯噔一下——有个认证服务的内存占用飙到了 120MB。
这个服务平时就处理一些用户登录、token 校验这些简单请求,代码量也不多,按理说不该吃这么多内存。关键是这台服务器本来就只有 2GB 内存,跑了好几个小服务,现在整体使用率已经超过 85% 了,再这样下去服务器随时可能被打挂。
之前一直听说 pprof 这个性能分析工具很强大,但说实话我还没在生产环境真正用过。既然碰上了,正好趁这次机会学学。我给自己定了个目标:争取把内存占用优化到 50MB 以内。
下面是完整的排查和优化过程,记录下来给自己做个备份,也希望能帮到有类似问题的朋友。
第一步:集成 pprof
首先要在代码里加上 pprof 的支持。这个其实很简单,Go 标准库自带了,只需要导入包然后起个 HTTP 服务就行。
我在 main 函数启动的时候加了这么几行代码:
import _ "net/http/pprof"
func StartPprof() {
go func() {
// 这里我用的端口是 30552,你可以换成其他的
logs.Info(http.ListenAndServe(":30552", nil))
}()
}
func main(){
// ... 其他初始化代码
StartPprof() // 启动 pprof
err = router.Run(address)
if err != nil {
panic(err)
}
}代码部署上去之后,就可以通过 http://服务器IP:30552/debug/pprof/ 访问 pprof 的界面了。
小提示:为了方便后续使用,我把常用的 pprof 命令都写成注释放在代码里了,需要的时候复制出来就能用:
// 查看内存占用(堆栈分析)
// go tool pprof -http=localhost:8081 http://服务器IP:30552/debug/pprof/heap
// 查看内存分配情况
// go tool pprof -http=localhost:8081 http://服务器IP:30552/debug/pprof/allocs
// 查看 CPU 耗时
// go tool pprof -http=localhost:8081 -seconds=60 http://服务器IP:30552/debug/pprof/profile
// 查看协程情况
// go tool pprof -http=localhost:8081 http://服务器IP:30552/debug/pprof/goroutine第二步:内存堆栈分析(找出常驻内存的大户)
代码部署好之后,我在本地终端执行了这条命令来分析内存堆栈:
go tool pprof -http=0.0.0.0:8081 http://服务器IP:30552/debug/pprof/heap这个命令会自动打开浏览器,展示一个火焰图界面。火焰图这玩意儿第一次看可能有点懵,简单说就是:越红、越粗的函数,占用的内存越多。
发现的问题
盯着火焰图看了一会儿,我发现有两个特别显眼的函数:
1. webdav(*memFile).Write 函数
顺着调用链往上看,发现是 swaggerFiles.init 调用的。我突然想起来,之前为了方便调试,在项目里集成了 gin-swagger 来生成 API 文档。
但这个认证服务其实不需要对外暴露 API 文档啊!我翻了下代码,swagger 相关的初始化逻辑确实在那里,占了不少内存。
解决方案:直接把 swagger 相关的代码注释掉了。
2. embedFS 函数
这个函数一看就是在加载文件。我顺着调用链找,发现是 base64Captcha 这个验证码包在初始化的时候加载字体文件。
打开包的源码一看,好家伙,这个包内置了好几个字体文件,总共有 5.9MB!对于一个只有 120MB 内存的服务来说,光字体就占了快 5% 的内存,这有点太奢侈了。
解决方案:换了一个更轻量的验证码库。说实话,这个认证服务的验证码需求很简单,根本不需要这么花哨的字体。
优化效果
改完代码重新部署,观察了半天,内存从 120MB 降到了 95MB 左右,效果还是挺明显的。
不过离我 50MB 的目标还有距离,继续往下排查。
第三步:内存分配分析(找出频繁分配内存的代码)
内存堆栈看的是常驻内存,但有些函数虽然不常驻,却频繁分配内存,同样会导致问题。接下来我做了内存分配分析:
go tool pprof -http=0.0.0.0:8081 http://服务器IP:30552/debug/pprof/allocs这张火焰图比上面那张还大,我只截了有问题的部分。
发现的问题
这次又揪出来几个问题:
1. webdav 和 embedFS(又是你们)
这俩在上一步已经处理了,不过因为我是先做的分析才改代码,所以这里还能看到。部署新代码之后确实消失了。
2. DBUpdateCronTask 函数
这个函数是个定时任务,定期更新数据库里的某些字段。看火焰图的时候我发现它占用挺多的,第一反应是:会不会索引没建好,导致全表扫描了?
翻出代码一看,果然,where 条件里的字段没加索引。这样每次更新都要扫全表,数据量一大就完蛋。
解决方案:赶紧给 where 条件的字段加上索引。加完之后这个定时任务的执行时间从 800ms 降到了 50ms 左右,内存分配也少了很多。
3. encoding/json 的 Marshal 函数
项目里用 Go 标准库的 encoding/json 做 JSON 序列化。虽然看起来占用不算太多,但我知道有个第三方库 json-iterator 性能更好。
解决方案:全局替换,把 encoding/json 改成 github.com/json-iterator/go。这个库号称完全兼容标准库,改起来很简单,就是批量替换一下 import。
测试了一下,序列化性能提升了大概 30%,内存分配也少了不少。
4. fmt.Sprintf 字符串拼接
这个问题找了我好久!火焰图上 fmt.Sprintf 占了一大块,我一开始还纳闷,这个函数调用频率应该不高啊。
后来我全局搜了一下 fmt.Sprintf,发现在一个叫 FmtContent 的函数里,用它做了大量的字符串拼接。大概是这样的逻辑:
// 优化前的代码(反面教材)
func FmtContent(items []string) string {
result := ""
for _, item := range items {
result = fmt.Sprintf("%s%s\n", result, item)
}
return result
}这代码写得太随意了,每次循环都要调用 fmt.Sprintf,而且字符串拼接会不断创建新对象。items 如果有几百上千个,那内存分配就炸了。
解决方案:改用 strings.Builder,这是 Go 官方推荐的字符串拼接方式:
// 优化后的代码
func FmtContent(items []string) string {
var builder strings.Builder
for _, item := range items {
builder.WriteString(item)
builder.WriteString("\n")
}
return builder.String()
}改完之后性能提升巨大,内存分配直接少了 60% 以上。
优化效果
这一轮优化完,重新部署代码,观察了一个多小时,内存终于降到 48MB 了!达到了我最初定的 50MB 以内的目标。
看着监控曲线一路走低,说实话还挺有成就感的。
第四步:CPU 耗时分析(顺便看看)
内存优化得差不多了,既然 pprof 都用上了,我就顺便看了下 CPU 的情况。从 top 监控来看,这个服务 CPU 使用率一直很低,不过还是用 pprof 扫一眼比较放心。
go tool pprof -http=0.0.0.0:8081 -seconds=60 http://服务器IP:30552/debug/pprof/profile这个命令会采集 60 秒的 CPU 数据,然后生成火焰图。
结果
火焰图看了一圈,没发现什么明显异常。大部分 CPU 时间都花在正常的业务逻辑上,比如请求解析、数据库查询、JSON 序列化这些。没有哪个函数特别突出,说明 CPU 方面没啥大问题。
如果真的发现 CPU 异常,排查方法跟内存类似:找出火焰图里最红最粗的函数,顺着调用链定位代码,然后针对性优化就行。
第五步:协程分析(查漏补缺)
最后我还想看看协程的情况,万一有协程泄漏就麻烦了。
pprof 提供了两个 URL 可以直接在浏览器查看协程信息:
http://服务器IP:30552/debug/pprof/goroutine?debug=1 # 协程总览
http://服务器IP:30552/debug/pprof/goroutine?debug=2 # 协程详情发现的问题
从界面可以看到,总共有 27 个协程在运行。这个数量不算多,不过我还是一个个看了下每个协程的堆栈信息。
大部分协程都是正常的:main 协程、pprof 服务的协程、定时任务的协程、HTTP 服务器的协程等等。
但是有一个编号为 44 的协程引起了我的注意。从堆栈来看,它是 go.opentelemetry.io 这个包启动的。
我想起来了,之前测试链路追踪的时候确实导入过这个包,但后来没用上,结果忘记删了。这个包初始化的时候会启动一个后台协程,虽然不占多少资源,但既然没用就删掉吧。
解决方案:注释掉 opentelemetry 相关的代码和 import。
总结
协程分析主要关注两点:
- 协程数量是否异常:如果协程数量特别多(成百上千),很可能存在协程泄漏
- 每个协程在干什么:通过 debug=2 查看堆栈,看看有没有不该存在的协程,或者阻塞住的协程
这次排查发现的问题不大,但养成定期检查的习惯还是很重要的。
经验总结
这次优化下来,内存从 120MB 降到了 48MB,效果超出预期。
整个过程其实不复杂,关键是要知道用什么工具、看什么指标。
几点心得:
-
pprof 真的很强大:以前总觉得性能优化很神秘,用了 pprof 才发现,只要会看火焰图,定位问题其实不难
-
依赖包要慎重:这次发现好几个占用内存的罪魁祸首都是第三方包。引入依赖的时候真的要想清楚是不是真需要,不然后面优化起来很头疼
-
基础性能优化很重要:像字符串拼接、JSON 序列化这些细节,平时写代码容易忽略,但积少成多影响还是挺大的
-
定期检查很有必要:如果不是这次告警,我根本不知道服务里还有这么多优化空间。以后打算定期用 pprof 扫一扫,防患于未然
-
优化要循序渐进:不要一次性改太多,每改一轮就观察一下效果,确认没问题再继续。这样出了问题也好回滚
最后放两个链接,都是我优化过程中参考的文章,写得挺详细的:
-
go 如何进行 Benchmark 基准测试
介绍怎么写基准测试,优化前后可以用它来对比性能 -
Golang 性能分析神器 pprof 详解与实践
pprof 的详细使用教程,包括各种分析类型和参数说明
希望这篇文章能帮到遇到类似问题的朋友。
如果有什么疑问或者更好的优化方法,欢迎在评论区交流!
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/go-pprof-record-1/
备用原文链接: https://blog.fiveyoboy.com/articles/go-pprof-record-1/