如何借助go pprof分析真实场景问题

作者:名扬

前言

本文将分享一个 笔者在实际业务开发当中遇到的一个有趣的问题。并且本文还会讲述笔者是如何借助 go tool pprof 分析和定位问题原因的。希望本篇的分享 能够给读者在日常开发与定位问题方面 带来一些启发。本文如有任何纰漏与建议欢迎在评论区指正与讨论(笔芯)。

阅读本文后,希望你能收获:

  • 1.掌握分析与定位问题的方法。
  • 2.能够使用 pprof 定位问题或优化性能。
  • 3.了解value context 的实现机制。

整个问题的分析及定位步骤如下,通过下图,读者可以对全篇文章 定位问题的流程有个整体的认识,便于后续文章的阅读与理解。
如何借助go pprof分析真实场景问题_第1张图片

步骤一:编写demo。

定位特定问题时,我喜欢单独写个纯净的demo。1 这样可以去除业务等其他代码带来的干扰。2 便于 debug调试。 3. 便于控制变量,这里的控制变量 就类似 我们在做科学实验时,如果影响实验结果的因素很多,我们希望控制一些变量不变,只变动个别变量,这样更容易找出科学实验中的 因果关系,其实我们日常定位问题也经常使用这种方法。控制变量法。写个demo 可以更方便的去控制变量。

步骤二:查看git 提交日志。

当定位问题时,这其实是一个很自然的想法,如果之前的代码没问题,现在出现问题了,那么可能是某个错误的提交造成的。通过查看git 的提交日志可以快速的缩写问题定位的范围。这里推荐个 git 好用的工具 git bisect ,git bisect 借助二分查找法,能够在海量的提交里快速定位到问题的原因。虽然本文没用到这个工具,但实际工作中git bisect 还是非常实用的,感兴趣的小伙伴建议自行搜索了解下。

步骤三:引入 pprof 工具。

pprof 工具是 go 官方提供的一个能够监控go 程序 cpu、内存、协程 、block 等信息的工具,pprof常被用于问题定位、性能优化、进程监控等方面。pprof 几乎是代码无侵入的,任何go程序都可以非常轻松的使用此工具,后文会详细介绍。
步骤四:分析源代码。
任何的问题定位,要想完全了解其背后的原因,都逃不过使用 最朴素 简单的方式,看代码。

1、问题描述

背景描述:公司内部有个redisdao插件,是基于开源库go-redis进行的封装。其目的是提供更加友好的接口及引入了配置管理、日志记录等功能。读者只需要知道 redisdao是基于go-redis开源库封装的即可。

问题描述:在开发一个需要频繁向redis写入数据的需求时,发现了一个有趣的现象:通过 redisdao 循环向redis写入数据会越来越慢(注意这里是越来越慢。慢的速度是线性增长的)。这就有些不可思议了,查看云端redis服务的负载并不高,使用本地redis实验也是如此。为了简化问题定位,去除业务逻辑,写了一个纯粹的从redis中的循环读取数据的demo。伪代码如下。经过反复验证发现(上文提到的控制变量法),问题出在redisdao 1.0.21 之后的版本(1.0.21版本没有问题的)。但使用redisdao 1.0.24版本则可以复现此问题。

func main(){
   ...
   key := "testRedisDao"
   //获取redis实例
   r := redisdao.NewSimpleXesRedis(context.Background(), "rediscon")
   i :=0
   t1,t2 := time.Now(),time.Now()
   for {
       val, err := r.Get(key, nil)
      if err == redis.Nil { // 此key不存在
         fmt.Printf("not exist key\n")
      } else if err != nil { // 异常
         fmt.Printf("error :%s\n",err.Error())
      }
      i++
      if i % 10000 ==0{
         t2 = time.Now()
         fmt.Printf("get value :%s cost: %d ms\n",val,t2.Sub(t1).Milliseconds())
         t1 = time.Now()
      }
   }
}

其实定位到这里,很自然的会想到就是redisdao在升级过程中引入了bug。

但是这个问题比较诡异,不是整个过程一直慢,而是越往后执行越慢,如下所示,首次执行1w次要5s,到最后已经变成了162s。
如何借助go pprof分析真实场景问题_第2张图片

因为这个问题现象比较有趣,所以就想了解下为啥会这样,于是去翻了下redisdao这个期间的提交记录。
如何借助go pprof分析真实场景问题_第3张图片

1.0.21 之后提交了挺多代码,不过最重要的是引入了go-redis的v8版本 (这点很重要,后面会用到)。在这么多次提交中寻找原因,无异于大海捞针(这里其实可以使用 git bisect 工具)。

由于执行慢属于性能类问题,很自然的想到了使用 go tools 中的 pprof 定位下 到底是哪里耗时了。
于是开始了新一轮的定位。

2、Pprof 分析

使用 pprof 分析程序,可分为4步:

  • (1)前期准备,安装相关工具, graphviz 用户可视化分析,ppof 用于web 分析。
  • (2)程序中植入 pprof 埋点,目前有三种常用的方式,go test bench、 引入 runtime/pprof包、引入net/http/pprof包。
  • (3)生成监控信息,不同植入方式对应不同的文件生成方法,后文会介绍。
  • (4)分析监控信息,分析监控工具包括 原生的 go tool pprof 以及 更加先进的 pporf。
    整体流程如下所示:
    如何借助go pprof分析真实场景问题_第4张图片

2.1 前期装备

2.1.1 安装 graphviz

go pprof的图形化分析依赖于 graphviz

// 友情提示:安装graphviz 需要科学上网
brew install graphviz
2.1.2 安装 pprof

pprof 提供了网页版本的分析界面,并支持使用火焰图分析性能。

go get -u github.com/google/pprof

2.2 程序中启用 pprof 监控

在程序中启用pprof监控,常用的方法用三种:

  • (1)go test benchmart,结合go test 使用。适用于 对性能要求较高的函数进行性能优化分析。
  • (2)runtime/pprof ,将pprof监控信息以文件形式输出,在程序停止后进行分析。适用于分析一次性任务性程序,如worker类进程。
  • (3)net/http/pprof ,启用 web 监控,优点可以在程序运行整个生命周期进行监控。
2.2.1 搭配go test 使用

编写单元测试代码如下

// add.go
func Add(a,b int) (sum int){
   for i:=0;i

运行如下命令,输出cpu.prof 文件。

go test -bench=. -cpuprofile=profile

profile文件的分析,后文会详细介绍。

2.2.2 runtime/pprof 文件形式输出

引入 "runtime/pprof" 包,创建 profile 文件,并采样 cpu数据。

import (
   _ "runtime/pprof"
)

func main(){
    // 创建 profile文件 
    file, err := os.Create("./profile") 
    if err != nil { 
       return 
   } 
   // 采样cpu数据
   pprof.StartCPUProfile(file) 
   defer pprof.StopCPUProfile() 
    
    // 业务代码
    ...
    
}

正常启动程序即可,程序运行结束后会输出 profile 文件。

go run main.go
2.2.3 net/http/pprof http监控

(1) 引入 "net/http/pprof" 包,并 监听一个可用端口。伪代码如下:

import (
   _ "net/http/pprof"
)

func main(){
    // 监听一个可用端口,若程序本身为web程序,则此步骤可省了,pprof会使用程序已监听的端口
    go func() {
       _ = http.ListenAndServe("0.0.0.0:6060", nil)
    }()
    
    // 业务代码
    ...
    
}

(2) 像往常一样启动程序

// 运行时同往常一样,无需增加任何额外参数
go run main.go

(3)使用浏览器打开如下网址,网址中的6060端口号即为程序中监听的端口号:

http://localhost:6060/debug/pprof/

打开网址后可以看到许多信息,如下图所示,包括 内存分配、运行的携程数量、互斥锁等。每个选项下面还有简单的描述。
多说一句,从下图可以看出 pprof 监控的强大,pprof 可以用来做 CPU运行分析、内存分析、协程泄露、阻塞分析、互斥分析 等问题。
如何借助go pprof分析真实场景问题_第5张图片
点击 profile 可输出profile 文件。点击后要等待30秒(默认30秒),将在这30秒过程中采集cpu运行信息。

2.3 分析监控信息

2.3.1 go tool pprof 分析

使用 go tool pprof 工具分析上文通过三种方式生成的 prfile 。

go tool pprof ./profile

程序将会进入一个交互式终端,可以使用 help 命令,终端将会输出支持的命令。
本文主要用到了top 命令。
如何借助go pprof分析真实场景问题_第6张图片
go tool 还支持其他丰富的功能,如通过 web、pdf 等命令,输出调用图
如何借助go pprof分析真实场景问题_第7张图片
有兴趣的可以自行google了解丰富的go tool pprof功能。

针对上文的 net/http/pprof 方式,实际上可以直接联网分析,无需生成文件,命令如下:

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=60
2.3.2 pprof 分析

使用 pporf 工具进行分析,需要先下载pprof工具,下载方法见 2.1 章节。
运行如下命令进行分析:

pprof -http=:8080 ./profile 

浏览器将自动打开,如下界面,pprof 功能非常强大。包括 top、graph、flame graph、peek、source、disassemble。
如何借助go pprof分析真实场景问题_第8张图片

本次定位主要用到了,top、source、peek、graph 功能。想要深入了解可自行google。
top 显示占用cpu最多的几个函数。
如何借助go pprof分析真实场景问题_第9张图片

source 可以查看源代码的耗时,非常好用。
如何借助go pprof分析真实场景问题_第10张图片
peek,显示内容与 top 类似。
如何借助go pprof分析真实场景问题_第11张图片
graph,这个大家应该已经熟悉了。
如何借助go pprof分析真实场景问题_第12张图片
flame grap 火焰图
如何借助go pprof分析真实场景问题_第13张图片

3、对Redisdao 分析

上一节我们简单的介绍了 pprof 性能分析工具。下面就使用 pprof 分析 redisdao 慢的原因
其实通过上面的top 以及 graph 分析,我们可以明显的感觉到一个奇怪的地方,就是 context.value() 函数的耗时有点过于高了。
如何借助go pprof分析真实场景问题_第14张图片
很可惜, pprof 只能帮我们到这了,它只能告诉我们程序哪里耗时多,不能告诉我们为什么。在这个问题上,只告诉了我们where 但并未告诉我们 why。

关于Why的分析,有时候只能凭经验和灵机一动了。这里分享一下我定位的过程。

3.1 谜底揭晓

上面提到了,redisdao 之后更新了代码,增加了对 go-redis v8 的支持。 对于 go-redis v8 我恰好了解下, go-redis v8 较go-redis 增加了一个很重要的功能,就是 链路跟踪 trace 。
而且 trace 功能在 golang中的实现,基本上都是使用 context来实现的(包括 rpcx、go-zero 等框架)。所以基于以上经验也可以推测确实是由于引入 go-redis v8 导致的耗时。
但这里 就有意思了,难道这是 go-redis v8 的bug, (本来想用go-redis v8,而不是使用redisdao的封装, 再写个demo测试下,不过还好,还没到这一步我就想到了问题的关键),对于这个高星项目我还是有些信心的(但开源项目也不能全信,定位bug,一定保持着大胆猜测,小心求证的原则),而且这么明显的bug,应该早就被发现了,所以还是要再排查下是不是由于我们的使用方式不当导致的。

这里只能再仔细研读 redisdao 的代码了。仔细翻阅代码后,我发现了一个问题。下面贴出代码调用过程:
如何借助go pprof分析真实场景问题_第15张图片

tracer.Start 调用了 contex.Withvalue 与 context.Value 函数,下面贴出两个函数的实现:

// 插入 value
func WithValue(parent Context, key, val interface{}) Context {
   if key == nil {
      panic("nil key")
   }
   if !reflectlite.TypeOf(key).Comparable() {
      panic("key is not comparable")
   }
   return &valueCtx{parent, key, val}
}
// 读取 value
func (c *valueCtx) Value(key interface{}) interface{} {
   if c.key == key {
      return c.val
   }
   return c.Context.Value(key)
}

这里要注意 context 的实现,context 实际上是 链表 数据结构 并非 hash结构。这也是为什么contex能不加锁也能实现,并发安全访问。

// A valueCtx carries a key-value pair. It implements Value for that key and
// delegates all other calls to the embedded Context.
type valueCtx struct {
   Context
   key, val interface{}
}

类似如下结构:
3.2 context 引发的“血案”
contex.Withvalue 是向链表中插入值,链表会不断的增大。value 会遍历链表去取值,时间复杂度是 O(N)。这里灵机一动的想到:程序越跑越慢,应该就是由于 context 链表 越来越大导致的。 那么有了这个想法,接下来就是验证了。

回头再去看rediadao的代码发现,果然ctx 是在调用newSimpleXesRedis 时传递的,而整个过程当中会一直使用这个ctx。这就导致了,ctx链表越来越长,value时也就越来越耗时,进而导致程序会越来越慢。如下图,main函数是 在循环外面调用的newSimpleXesRedis。
如何借助go pprof分析真实场景问题_第16张图片

那么如果我们猜测正确,只要将newSimpleXesRedis 放入 for函数里面就没问题了。试了一下,果然如此:
如何借助go pprof分析真实场景问题_第17张图片
再次启动程序果然快的飞起。
如何借助go pprof分析真实场景问题_第18张图片

上面的结果再一次验证了我们的猜测。好了,到此为止,问题的原因总算是水落石出了。整个过程好比破案[捂脸]。

总结
通过这个RedisDao 越跑越慢的问题定位,我们学习了 性能分析工具 go tool pprof 以及context 的实现。本次定位给我几点启发:

  • (1)定位问题要有刨根问底的精神,如果定位到了是 redisdao升级导致的,直接甩给redisdao开发者解决,可能就不会有后面的收获了。
  • (2)很多时候一个好的工具能够帮助我们快速定位问题。 工欲善其事必先利其器。
  • (3)问题定位很多时候也是要依赖直觉的,那么直觉如何培养,只能通过不断的经验积累与刻意练习获取。
  • (4)很多时候其实是 先想到结果,再通过数据与实验去验证。
  • (5)本次的总结与输出后,温故而知新,对pprof 与 context 知识的理解更加全面了,同时总结也是一个重新提取的过程,让自己掌握的更加深刻。 其实总结也是上面说到的 培养直觉的刻意练习。

image.png

你可能感兴趣的:(如何借助go pprof分析真实场景问题)