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

121 阅读10分钟

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

高质量编程

简介

  • 编写的代码能够达到正确可靠、简洁清晰的目标可称之为高质量代码
  1. 各种边界条件是否考虑完备
  2. 异常情况处理,稳定性保证
  3. 易读易维护

编程原则

  1. 简单性

    1. 消除“多余的复杂性”,以简单清晰的逻辑写代码
    2. 不理解的代码无法修复改进
  2. 可读性

    1. 代码是写给人看的,而不是机器
    2. 编写可维护代码的第一步是确保代码可读性
  3. 生产力

    1. 团队整体工作效率非常重要

编程规范

代码格式

  • 使用gofmt自动格式化代码

  • goimports实际等于gofmt加上依赖包管理

    • 自动增删依赖的包引用、将依赖包按字母序排序并分类

注释

  • 解释代码的作用

  • 解释代码如何做

  • 注释应该提醒使用者一些潜在的限制条件或者会无法处理的情况

  • 公共符号始终要注释

    • 包中声明的每个公共的符号、变量、常量、函数以及结构
    • 任何既不明显也不简短的公共功能
    • 无论长度或复杂程度如何,对库中任何函数必须进行注释
    • 有一个例外:不需要注释实现接口的方法。

变量起名

  • 简洁胜于冗长

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

    • 例如:ServeHTTP而不是ServeHttp
    • 使用XMLHTTPRequest或者xmlHTTPRequest
  • 变量距离其被使用的地方越远,则需要携带越多上下文信息

函数起名

  • 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的
  • 函数名尽量简短

包起名

  • 只由小写字母组成。不包含大写字母和下划线等字符
  • 简短并包含一定的上下文信息。例如 schema、task 等
  • 不要与标准库同名。例如不要使用 sync 或者 strings

控制流程

  • 避免嵌套,保持正常流程清晰
  • 如果两个分支中都包含 return 语句,则可以去除冗余的 else
 if xxx {
     return xxx
 }
 return xxx
  • 尽量保持正常代码路径为最小缩进,优先处理错误情况/特殊情况,并尽早返回或继续循环来减少嵌套,增加可读性

错误和异常处理

简单错误处理

  • 优先使用 errors.New 来创建匿名变量来直接表示该错误。有格式化需求时使用 fmt.Errorf
  • 处理方式称为“不透明错误处理”

复杂的错误处理

通过阅读源码 /usr/local/go/src/errors/wrap.go,我们可以发现 As() 和 Is() 是通过在错误链中不断调用 Unwrap() 函数,最终找到匹配的错误值。其中,Unwrap() 函数也是在 golang 1.13 中新增的函数。

  • 在 fmt.Errorf 中使用 %w 关键字来将一个错误 wrap 至其错误链中

错误链上判断

  • 使用 errors.Is 可以判定错误链上的所有错误是否含有特定的错误。
 package main
 ​
 import (
     "errors"
     "fmt"
 )
 ​
 // 哨兵错误处理
 var (
     ErrInvalidUser  = errors.New("invalid user")
     ErrNotFoundUser = errors.New("not found user")
 )
 ​
 func main() {
     err1 := fmt.Errorf("wrap err1: %w\n", ErrInvalidUser)
     err2 := fmt.Errorf("wrap err2: %w\n", err1)
     // golang 1.13 新增 Is() 函数
     if errors.Is(err2, ErrInvalidUser) {
         fmt.Println(ErrInvalidUser)
         return
     }
     fmt.Println("success")
 }

错误链上获取

  • 在错误链上获取特定种类的错误,使用 errors.As
 package main
 ​
 import (
     "errors"
     "fmt"
 )
 ​
 // DefineError 自定义的错误类型
 type DefineError struct {
     msg string
 }
 ​
 func (d *DefineError) Error() string {
     return d.msg
 }
 ​
 func main() {
     // wrap error,错误链形成
     err1 := &DefineError{"this is a define error type"}
     err2 := fmt.Errorf("wrap err2: %w\n", err1)
     err3 := fmt.Errorf("wrap err3: %w\n", err2)
     var err4 *DefineError
     if errors.As(err3, &err4) {
         // errors.As() 顺着错误链,从 err3 一直找到被包装最底层的错误值 err1,并且将 err3 与其自定义类型 `var err4 *DefineError` 匹配成功。
         fmt.Println("err1 is a variable of the DefineError type")
         fmt.Println(err4 == err1)
         return
     }
     fmt.Println("err1 is not a variable of the DefineError type")
 }

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

panic

  • 不建议在业务代码中使用 panic
  • 如果当前 goroutine 中所有 deferred 函数都不包含 recover 就会造成整个程序崩溃
  • 当程序启动阶段发生不可逆转的错误时,可以在 init 或 main 函数中使用 panic

recover

  • recover 只能在被 defer 的函数中使用,嵌套无法生效,只在当前 goroutine 生效
  • 如果需要更多的上下文信息,可以 recover 后在 log 中记录当前的调用栈。
 defer func() {
         if e := recover(); e != nil {
             f = nil
             err = fmt.Errorf("gitfs panic: %v\n%s", e, debug.Stack())
         }
     }()

性能优化建议

工具

  • 在满足正确性、可靠性、健壮性、可读性等质量因素的前提下,设法提高程序的效率
 package test
 ​
 func Fib(n int) int {
     if n < 2 {
         return n
     }
     return Fib(n-1)+Fib(n-2)
 }
 package test
 ​
 import "testing"
 ​
 func BenchmarkFib(b *testing.B) {
     for i := 0; i < b.N; i++ {
         Fib(10)
     }
 }
  • go test -v -bench="." -benchmem,windows要加".",mac电脑不需要加这个双引号

slice预分配内存

  • 尽可能使用make()初始化切片时提供容量信息

    • make(Type, len, cap)

切片本质是一个数组片段的描述 包括数组指针 片段的长度 片段的容量(不改变内存分配情况下的最大长度) 切片操作并不复制切片指向的元素 创建一个新的切片会复用原来切片的底层数组 以切片的append 为例,append时有两种场景: 当 append 之后的长度小于等于 cap,将会直接利用原底层数组剩余的空间。 当 append 后的长度大于 cap 时,则会分配一块更大的区域来容纳新的底层数组。 因此,为了避免内存发生拷贝,如果能够知道最终的切片的大小,预先设置 cap 的值能够避免额外的内存分配,获得更好的性能

内存大坑

 func TestGetLastBySlice(t *testing.T) {
     m := GetLastBySlice(make([]int,1000,1000))
     fmt.Println(m)
 }
 func TestGetLastByCopy(t *testing.T) {
     m := GetLastByCopy(make([]int,1000,1000))
     fmt.Println(m)
 }

了解slice的基本结构之后,还有个问题需要注意 因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放 可使用 copy 替代 re-slice 两部分代码使用了不同的逻辑取slice的最后两位数创建新数组,同时统计输出了内存占用信息 结果差异非常明显,lastBySlice 耗费了 100.14 MB 内存,也就是说,申请的 100 个 1 MB 大小的内存没有被回收。因为切片虽然只使用了最后 2 个元素,但是因为与原来 1M 的切片引用了相同的底层数组,底层数组得不到释放,因此,最终 100 MB 的内存始终得不到释放。而 lastByCopy 仅消耗了 3.14 MB 的内存。这是因为,通过 copy,指向了一个新的底层数组,当 origin 不再被引用后,内存会被垃圾回收

map建议

  • 预分配内存

    • make(map[type]type,size)
    • 提前分配好空间可以减少内存拷贝和Rehash的消耗

字符串拼接

  • 使用"+"拼接性能最差

    • 每次都会重新分配内存
  • strings.Builder、strings.Buffer相近,strings.Buffer更快

    • 内存扩容策略,不需要每次拼接重新分配内存

    • bytes.Buffer转化为字符串时重新申请了一块内存

    • strings.Buffer直接将底层的[]byte转换成了字符串类型返回

      • 我们使用Grow(int)预分配一下内存,这样速度更快
package main

import (
	"bytes"
	"fmt"
	"strings"
)

func main() {
	t1()
	t2()
	t3()
}

func t1() {
	var s strings.Builder
	for i := 0; i < 10; i++ {
		s.WriteString("1")
	}
	fmt.Println(s.String())
}

func t2() {
	var s bytes.Buffer
	for i := 0; i < 10; i++ {
		s.WriteString("1")
	}
	fmt.Println(s.String())
}

func t3() {
	var s strings.Builder
	s.Grow(10)
	for i := 0; i < 10; i++ {
		s.WriteString("1")
	}
	fmt.Println(s.String())
}

空结构体

  • 空结构体"struct{}"实例不占据任何内存空间

  • 可作为各种场景下的占位符使用

    • 仅作为占位符
  • 实现Set可以考虑用map来代替

atomic包

  • atomic操作通过硬件实现,效率比锁高
  • 对应非数值操作,可以使用atomic.Value,能承载一个interface{}
  • 参考文档地址:点击跳转
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

var (
	x int64
	wg sync.WaitGroup
)

func main() {
	wg.Add(10)
	for i := 0; i < 10; i++ {
		go func() {
			atomic.AddInt64(&x,10)
			wg.Done()
		}()
	}
	wg.Wait()
	fmt.Println(x)
}

性能调优实战

简介

优化原则

要依靠数据不是猜测 要定位最大瓶颈而不是细枝末节 不要过早优化 不要过度优化

工具:pprof

简介

应用在什么地方耗费了多少CPU、Memory

在浏览器中打开http://localhost:6060/debug/pprof,可以看到这样的页面, 这就是我们刚刚引入的net/http/pprof注入的入口了。 页面上展示了可用的程序运行采样数据,下面也有简单说明,分别是: allocs:内存分配情况 blocks:阻塞操作情况 cmdline:当前程序的命令行的完整调用路径。 goroutine:当前所有goroutine的堆栈信息 heap:堆上内存使用情况(同alloc) mutex:锁竞争操作情况 profile: CPU占用情况 threadcreate:当前所有创建的系统线程的堆栈信息 trace:程序运行跟踪信息

排查CPU

go tool pprof http://localhost:6060/debug/pprof/profile?seconds=10 再输入top

  • flat == cum,函数中没有调用其他函数
  • fat == 0,函数中只有其他函数的调用

list 文件名:eg:list Eat

  • 这样我们就能找到问题所在

输入web命令,调用关系图可视化

排查heap

注意,要显示这个我们还需要安装一个东西,Graphviz

  • 通过view那里选择Top、Source我们就可以定位到我们内存出大问题的地方

sample那里

排查goroutine

切换到火焰图

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

排查锁mutex

排查阻塞block

image.png

过程:pprof

CPU

  • 采样对象:函数调用和它们占用的时间
  • 采样率:100次/秒,固定值
  • 采样时间:从手动启动到手动结束

image.png

goroutine

image.png

Heap

image.png

Block

  • 锁竞争
    • 采样争抢锁的次数和耗时
    • 采样率:只记录固定比例的锁操作,1为每次加锁均记录

Mutex

  • 采样阻塞操作的次数和耗时
  • 采样率:阻塞耗时超过阈值才会被记录,1为每次阻塞均记录

优化案例

步骤

  • 建立服务性能评估手段
  • 分析性能数据,定位性能瓶颈
  • 重点优化项改造
  • 优化效果验证