高质量编程与性能调优实战 | 青训营笔记

188 阅读19分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第4篇笔记

1.高质量编程

在正式讲解具体具体之前,我们可以了解一下什么是高质量,实际是个偏主观性的标准。

1.正确性:是否考虑了各种边界的条件,能够正确的处理错误的调用

2.可靠性:在异常情况下或者错误的处理中,策略是否明确,依赖的服务出现异常是否能够处理。

3.简洁:逻辑是否简单,后续调整功能或新增功能是否能够快速支持

4.清晰:其他人在阅读理解代码的时候是否能清除明白,重构或者修改功能是否不会担心出现无法预料的问题

1.编程原则

对于编写高质量的代码,实际应用场景千变万化,各种语言的特性和语法各不相同,从而衍生出许多通用的原则。

1.简单性

消除"多余的负责性",以简单清晰的逻辑编写代码

在实际工程项目中,复杂的程序逻辑会让人害怕重构和优化,因为无法明确预知调整造成的影响范围,并且难以理解的逻辑会使得排查问题时也难以定位,不知道如何修复

2.可读性

可读性很重要,因为代码是写给人看的,而不是机器。

在项目不断迭代的过程中,大部分工作是对已有功能的完善或扩展,很少会完全下线某个功能,对应的功能代码实际会生存很长

3.生产力

编程在当前更多是团队合作,因此团队整体的工作效率是非常重要的一方面

2.代码规范

1.代码格式

gofmt

gofmt 作为代码格式工具是Go语言官方提供的工具,能够自动格式化Go语言代码为官方统一风格。

goimports

goimports 也是Go语言官方提供的工具实际等于gofmt加上依赖包管理,它能够自动增删依赖的包应用,将依赖包按照字母序排序并分类

2.注释

在大多数时候我们都在关注代码实现,但是注释的重要性容易被忽略,从而引起一些项目开发的理解上的问题,因此每当我们实现一个函数的时候更需要添加注释来解释函数的含有,才能更加有利于团队项目开发。

对于注释我们应该遵循一下四点规则;

注释应该解释代码作用

首先是注释应该解释代码的作用,这种注释适合说明公共符号,比如对外提供的函数注释描述它的功能和用途,只有在函数的功能简单而明显式才能省略这些注释(例如简单的取值函数和设值函数)

其次注释要避免啰嗦,不对显而易见的内容进行说明例如以下代码:

func IsEmpty() bool

注释应该解释代码如何做的

对于比较复杂的代码,我们需要对一些逻辑并不清除的地方进行注解,对其解释实现的过程

注释应该解释代码实现的原因

注释可以解释代码的外部因素,这些因素脱离上下文后通常很难理解。

例如在一个函数中我们将一个开关的布尔值设置为false,当上下文关联性不强的时候,我们需要来解释为什么需要将其设置false状态。

注释应该解释代码什么情况会出错

最后注释应该提醒使用者一些潜在的限制条件或者会无法处理的轻快

例如函数的注释中可以说明是否存在性能隐患,输入的限制条件,可能存在那些错误情况,让使用者无需了解实现细节

3.命名规范

在实际写代码的时候,我们有一些约定和规范

写代码时最常见的就是命名,不管变量命名还是函数命名都希望能够简洁清晰

变量的命名规则

  1. 简洁胜于冗长

  2. 缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写例如:

    使用ServeHTTP而不是ServelHttp
    使用XMLHTTPRequest或者xmlHTTPRequest

  3. 变量距离其被使用的地方越远,则需要携带越多的上下文信息,全局比哪里在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义

函数的命名规则

  1. 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的

  2. 函数名尽量简短

  3. 当名为foo的包某个函数返回类型为Foo时,可以省略类型信息而不导致歧义

  4. 当名为foo的包某个函数返回类型为T时(T 并不是Foo),可以在函数名中加入类型信息

包的命名规则

  1. 只由小写字母构成,不包含大写字母和下划线等字符

  2. 简短并包含一定的上下文信息。例如 schema、task等

  3. 不要与标准库同名。例如不要使用 sync 或者 strings

对于以下规则尽量满足,以标准库包名为主:

  1. 不适用常用变量名作为包名。例如使用bufio而不是buf

  2. 使用单数而不是复数,例如使用encoding而不是encodings

  3. 谨慎使用缩写,例如使用fmt在不破坏上下文的情况下比format更加简短

4.流程规范

当我们能够给变量或者函数一个合适的名称过后,我们接下来就要开始实现具体的流程功能了,经常使用的就是if else语句了,我们在使用这种条件控制语句又有其他的规范条件。

首先在使用条件控制语句的时候,我们需要避免嵌套,从最简单的一个if else条件开始,如果两个分支都包含return语句,则可以去除冗余的else语句方便后续维护。例如以下代码

if foo{                                   if foo{
    return a;                                return a;
}else{                ----修改为--->      }
    return b;                             return b;
}

如果出现多个if else条件语句进行嵌套,我们则需要尽量保持正常代码路径未最小缩进,并且有限处理错误情况或者特殊情况,尽早返回或者继续循环来减少嵌套,例如以下代码作为示范 。

//错误示范
func OneFunc() error{
    err := doSomething()
    if err == nil{
        err := doAnotherThing()
        if err == nil {
            return nil
        }
        return err
    }
    return err
}

上述代码出现了多个if语句进行嵌套,但是并没有优先处理错误情况或特殊情况,引起需要增加多个步骤来进行流程。

//调整后
func OneFunc() error{
    if err := doSomething();err != nil {
        return err
    }
    if err := doAnotherThing(); err != nil{
        return err
    }
    return nil
}

调整后的代码从上到下就是正常流程的执行过程,且比原来的代码的更加能够清晰明了。

5.错误和异常处理

之前介绍的都是程序正常工作时的相关规范,但是在实际程序运行中,难免遇到错误或者异常情况,这里就开始介绍错误和异常处理的相关规范。

简单错误

简单错误指的是仅仅出现一次的错误,且在其他地方不需要捕获该错误,我们通常优先使用errors.New来创建匿名变量来直接表示简单错误。如果有格式化的要求,我们可以通过fmt.Errorf进行对其格式化。例如以下代码

func defaultCheckRedirect(req *Request,via []*Request) errer{
    if len(via) >= 10{
        return error.New("stopped after 10 redirects")
    }
    return nil
}

复杂错误

对于复杂的错误,有时候并不能简单描述,因此我们利用错误的包装提供了一个error嵌套另一个error的能力,生成一个error的跟踪链,同时结合错误的判定方法来确认调用链中是否有关注的错误出现。并且我们可以通过fmt.Errorf中使用:%w关键字来将一个错误wrap至错误链中。

list,_ err := c.GetBytes(cache.Subkey(a.actionID,"srcfiles"))
if err != nil{
    return fmt.Errorf("reading secfiles list: %w",err)
}

错误判定

如果错误是一条错误链,我们可以使用errors.Is()来进行判定,errors.IS()不同于使用==,使用该方法可以判定错误链上面的所有错误是否含有特定的错误,例如以下代码

data,err := lockedfile.Read(targ)
if errors.Is(err,fs.ErrNotExist){
    return []byte{},nil
}
return data,err

代码中使用errors.Is()来进行判定特定错误,如果出现文件不存在的特定错误,则返回指定的数据。

对于错误判定,还有一个方法就是errors.As(),它和Is的区别在于as会提取出调用链中指定类型的错误,并将错误赋值给定义好的变量,方便后续处理。

panic

在Go语言中,比错误更加严重的就是 panic ,它的出现表示程序无法正常工作了。

对于 panic 不建议在业务代码中使用panic,因为 panic 发生后,会向上传播至调用栈顶,如果当前的 goroutine 中所有 deferred 函数都不包含 recover 就会造成整个程序的崩溃,对此建议使用 error 代替 panic

特殊的,当程序启动阶段发生不可逆转的错误时,可以在 init 或 main 函数中使用 panic。因为在这种情况下,服务启动起来也不会有意外。

recover

有 panic,自然会有 recover,因为我们不能控制所有的大地吗,避免不了引入其他的库,如果是引入库的 bug 导致 panic,影响到程序的自身逻辑,我们就需要使用到 recover。

recover 只能在被 defer 的函数中使用,并且在嵌套中无法生效,只能在当前的 goroutine 生效。例如以下代码所示

func (s *ss) Token(skipSpace bool,f func(rune) bool)(tok []byte,err error){
    defer func(){
        if e := recover(); e != nil{
            if se,ok :=e.(scanError);ok{
                err = se.err
            }else {
                panic(e)
            }
        }
    }()
}

如果需要更多的上下文信息,可以 recover 后再 log 中记录当前的调用栈以下代码中的 debug.Stack() 包含的调用堆栈信息,方便定位具体问题代码

func (t *treeFS) Open(name string)(f fs.File,err error){
    defer func(){
        if e:= recover(); e != nil{
            f = nil;
            err = fmt.Errorf("gifts panic:%v\n%s",e,debug.Stack())
        }
    }()
}

2.性能调优实战

实现了编程的规范化后,我们就要开始对应用程序进行性能调优。 以下四点就是性能调优原则:

1.要依靠数据不是猜测

2.要定位最大瓶颈而不是细枝末节

3.不要过早优化

4.不要过度优化

既然性能调优前提是对应用程序性能表现有实际的数据指标,那么有什么工具能够获得这种数据呢?对于Go语言程序中,我们有一个很方便的工具就是 pprof。

1.性能分析工具pprof 功能介绍

pprof功能繁多,有着分析功能,工具功能,展示功能等其他功能,如下图所示:

image.png

具体的工具功能我们可以在runtime/prrof中找到源码,同时Golang的http标准库也对pprof做了一些封装,能让你在http服务中直接使用它。

2.性能分析工具pprof-排查实战

我们为了熟悉pprof工具,并使用其能够排查性能问题,那么我们需要构造一个有问题的程序,看看如使用pprof来定位性能问题点。

前置准备:下载项目代码,能够编译运行,会占用1CPU核心和1GB的内存

1.排查CPU-内存

我们通过git clone https://github.com/wolfogre/go-pprof-practice.git 来获取我们所需要的的项目代码。

打开项目我们先通过终端使用go mod init 对go.mod文件进行初始化生成,随后再使用go get "github.com/wolfogre/go-pprof-practice/animal" 将所需的包进行导入。

随后我们运行项目中的main.go文件,随后再浏览器中打开"http://localhost:6060/debug/pprof/" 就可以看到这样的页面

image.png

这就是我们引入的net/http/pprof注入的入口了。 页面上展示了可用的程序运行采样数据这些分别是:

allocs:内存分配情况
blocks:阻塞操作情况
cmdline:程序启动命令集
goroutine:当前所有groutine的堆栈信息
heap:堆上内存使用情况 mutex:锁竞争操作情况
profile:CPU占用情况
threadcreate:当前所有创建的系统现成的堆栈信息
trace:程序运行跟踪信息

这些数据可读性很差,所以一会儿我们将会借助pprof工具帮助我们来阅读这些指标,我们在我们启动main.go文件后另起一个终端输入
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10" (即 go tool pprof 采样链接)进行采样,链接结尾的profile代表采样对象是CPU使用,默认为60s一次的采样,加上second=10的参数表示每10s进行一次采样。 接下来我们将得到以下界面

image.png

接下来我们输入top就会得到如下画面

image.png

查看CPU占用最高的函数,这五列从左到右分别是

flat:当前函数的占用
flat%:Flat占总量的比例
sum%:上面所有行的Flat%总和
cum:当前函数加上其调用函数的总占比
cum%:cum占总量的比例

表格前面描述了采样的总体信息,默认会展示资源占用最高的10个函数,如果只需要查看最高的N个函数,可以输入topN,例如查看最高的3个调用,就输入top3

在这个表格中cum与flat有的时候会是相等,有的时候又是不等,甚至flat直接为0,是因为cum-flat得到的是函数中调用其他函数所消耗的资源,所以在函数中没有对其他函数进行调用时,就出现了cum = flat。相应的,如果函数中除了调用另外的函数,没有其他逻辑时,则flat = 0

可以看到表格第一个行,Tiger.Eat函数本身占比2.52s的CPU时间,占总时间的95.45%,显然问题是这里引起的。

接着,输入list Eat查找这个函数,看看具体是哪里除了问题

image.png

接着可以看到,第24行有一个100亿次的空循环,占用了2.52秒的CPU时间,问题就在这里了,定位成功。

除了这两种视图之外,我们还可以输入web命令,生成一张调用关系图,默认会使用浏览器打开。图中除了每个节点的资源占用以外,还会将他们的调用关系穿起来。

prrof 常用指令

top:默认展示资源占用最高的前10个函数,如要查看具体数量则在top后添加数字
list:根据后面给定的正则表达式查找代码,并按行展示出每一行的占用
web:生成函数关系视图并默认用浏览器打开(如果出现以下代码)

Failed to execute dot. Is Graphviz installed? Error: exec: "dot": executable file not found in %PATH%

gvedit官网:graphviz.gitlab.io/_pages/Down…
按照提示安装即可。注意配置环境变量。
安装结束后需要进入到安装目录,以管理员模式打开cmd输入dot -c安装插件,重启cmd。
具体参考(www.136.la/jingpin/sho…)

2.排查Heap-堆内存(1)

通过刚刚的定位,我们将问题代码注释掉后,重新运行程序后,发现内存成功的降了下来。

在刚刚排查CPU的过程中,我们使用的是pprof终端,这里我们介绍另一种展示方式。 通过-http=:8080参数

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/profile?seconds=10"

可以开启pprof自带的Web UI,性能指标会以网页的形式呈现。

我们再次启动pprof工具,注意这时的链接结尾是heap(http://localhost:6060/debug/pprof/heap ) 等待采样完成后,浏览器会被自动打开,展示出熟悉的web视图,同时展示的资源使用从「CPU时间」变为了「内存占用」可以明显看到,这里出问题的是*Mouse.Steal()函数,它占用了1GB内存。在页面顶端的View菜单中,我们可以切换不同的视图。

image.png image.png

接下来我们将View视图切换到Source视图,可以看到页面上展示出了刚刚看到的四个掉哦用和具体的源码视图。如果觉得内容太多,也可以在顶部的搜索框中输入Steal来使用正则表达式过滤出需要的代码。

image.png

根据源码我们会发现,在*Mouse.Steal()这个函数会向固定的Buffer中不断追加1MB内存,直到Buffer达到1GB大小为止,和我们在Graph视图中发现的情况-致。我们之后将这里的问题代码注释掉,至此,我们已经解决两个问题了。

3.排查Heap-堆内存(2)

我们重新运行之前的程序,发现内存占用已经降至23.6MB,说明刚才的解决方案起效果了。

我们在使用pprof工具中,右上角会有一个标签

image.png

并且sample标签中有四个指标,在堆内存采样中,默认展示的是inuse_space视图,只展示当前持有的内存,但如果有的内存已经释放,这时inuse采样就不会展示了。接下来我们将视图切换到alloc_space指标。后续分析下alloc的内存问题

image.png

通过alloc_space视图我们就可以立刻定位到了马上就定位到了*Dog.Run(),它会每次申请16MB大小的内存,并且已经累计申请了超过3.5GB内存,在Top视图中还能看到这个函数被内联了。

看看定位到的函数,果然,这个函数在反复申请16MB的内存,但因为是无意义的申请,分配结束之后会马上被GC,所以在inuse采样中并不会体现出来。

我们将这一行问题代码注释掉,继续接下来的排查。至此,内存部分的问题已经被全部解决了。

4.排查goroutine协程

Go语言是一门自带垃圾回收的语言,一般情况下内存泄露是没有那么容易发生的,但是有一种例外:goroutine是很容易泄露的,进而会导致内存泄漏。所以接下来,我们需要来看啊可能goroutine的使用情况。

运行程序后,打开 http://localhost:6060/debug/pprof/ 发现问题程序已经有105条goroutine在运行了,这个量级并不是很大,但对于一个简单的小程序来说还是很不正常的。

image.png

因此我们利用指令,我们把链接末尾缓存goroutine,继续查看结果

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/goroutine"

image.png

可以看到,pprof生成了一张非常长的调用关系图,尽管最下方的问题用红色标了出来,不过这么多节点还是比较难以阅读的 这里我们介绍另—种更加直观的展示方式——火焰图。

打开视图菜单,切换到火焰图视图可以看到,刚才的节点被堆叠了起来,图中,自顶向下展示了各个调用,表示各个函数调用之间的层级关系每-行中,条形越长代表消耗的资源占比越多 显然,那些“又平又长”的节点是占用资源多的节点 可以看到,*Wolf.Drink()这个调用创建了超过90%的goroutine,问题可能在这里 火焰图是非常常用的性能分析工具,在程序逻辑复杂的情况下很有用,可以重点熟悉

image.png

到Source视图搜索Drink,发现函数每次会发起10条无意义的goroutine,每条等待30秒后才退出,导致了goroutine的泄露。因此我们将其注释掉,再回到最开始页面查看

image.png

可以看到goroutine数量已经回到了正常水平。

5.排查mutex-锁的问题

我们通过修改链接后缀,再次运行命名

go tool pprof -http:=8080 "http://localhost:6060/debug/pprof/mutex" 然后打开网页观察,发现存在一个锁操作,同样地,在Graph视图中定位到出问题的函数在*Wolf.Howl0然后在Source视图中定位到具体哪—行发生了锁竞争。

image.png

我们发现在这个函数中,goroutine足足等待了1秒才解锁,在这里阻塞住了,显然不是什么业务需求,注释掉。 在这个函数中,足足等待了1秒才解锁,在这里阻塞住了,显然不是什么业务需求,注释掉。就此锁问题就排查完毕了。

6.排查block-阻塞问题

在程序中,除了锁的竞争会导致阻塞之外,还有很多逻辑(例如读取一个channel)也会导致阻塞。

image.png

在页面中可以看到阻塞操作还剩两个,于是我们将之前的命名的后缀链接地址再换成block

和刚才一样,Graph到Source的视图切换, 可以看到,在*Cat.Pee()函数中读取了一个 time.After() 生成的 channel ,这就导致了这个 goroutine 实际上阻塞了1秒钟,而不是等待了1秒钟。

image.png

不过通过上面的分析,我们之定位到了一个 block 的问题,但是技术页面上有两个阻塞操作。我们知道另一个阻塞是确实存在的,尽管占用低的节点不会再 pprof 工具中展示出来,但实际上是会被记录下来的。我们还可以通过暴露出来的接口地址直接访问它。

image.png

所以,打开 Block 指标的页面,可以看到,第二个阻塞操作发生再 http handler 中,这个阻塞操作时符合预期的。

至此,我们已经发现了程序中所有的问题。