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

44 阅读9分钟

在后端学习中,当使用 go 语言进行编程时,总会遇到质量不高的运行过程以及性能不够优化的情况,此时便需要进行高质量编程与性能调优:包括图片优化、前端资源优化、数据请求优化等,通过实战和测试,分析和优化任意项目中存在的性能问题。

一、高质量编程

1.1 编程原则

简单性:用简单清晰的逻辑去处理功能,消除多余复杂的逻辑,避免代码无法修复改进。 可读性:项目是相互迭代,功能是不断完善的,因此要方便维护阅读。 生产力:要有团队整体工作效率。

1.2 编程规范

1.2.1 代码格式

推荐使用 gofmt(自动格式化代码)或 goimports。

代码格式 工具介绍 作用

gofmt GO 官方提供的工具 能够在退出时自动格式化代码为官方统一标准

goimports GO 官方的工具,gofmt + 依赖包的管理 能够自动增删依赖包的引用,将依赖包按字母排序分类,比较清晰

1.2.2 注释

要知道:代码是最好的注释,代码本身最好能体现出它的功能。公共符号必须要注释,防止他人使用时看不懂或出现错误。

公共符号的注意:

包中声明的每个公共的符号变量、常量、函数以及结构都需要添加注释

任何既不明显也不简短的公共功能必须予以注释

无论长度或复杂程度如何,对库中的任何函数都必须进行注释

注释的作用:

解释代码作用,比如一些公共符号,公共常量,公共函数等。

解释代码如何做的,注释代码的实现过程。

解释代码实现的原因,为什么要这么写,可能有上下文联系,外部因素等。

解释代码什么情况会出错,一些限制条件。

1.2.3 命名规范

1.2.3.1 变量名

简洁胜于冗长。

缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写。例如使用 ServeHTTP,而不是 ServeHttp。

使用 XMLHTTPRequest 或者 xmHTTPRequest。

变量距离其被使用的地方越远,则需要携带越多的上下文信息。

全局变量在基名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义。

比如我们要用截止时间时,用 deadline 就比用 t 好。

1.2.3.2 函数

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

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

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

尽量将函数与包名相关联,与包相关性强,命名就可以略微省去和包相关的信息,方便在其他的地方出现类似功能时可以很好的命名。

1.2.3.3 包

必须满足规则:

只由小写字母组成。不包含大写字母和下划线等字符。

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

不要与标准库同名。例如不要使用 sync 或者 strings,避免冲突或被误解,影响开发效率。

非必须满足规则:

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

使用单数而不是复数。例如使用 encoding 而不是 encodings。

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

1.3 控制流程

避免 if else 的嵌套,尽量清晰简洁。比如两个分支都包含 return,可以省去冗余的 else。

处理错误情况和特殊情况,尽早的返回或继续循环来减少嵌套。

线性原理,处理逻辑尽量走直线。

避免复杂的嵌套分支正常流程代码沿着屏幕向下移动。

故障问题大多出现在复杂的条件语句和循环语句中。

1.4 错误和异常处理

简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误。优先使用 errors.New 来创建匿名变量来直接表示简单错误。

如果有格式化的需求,使用 fmt.Errorf。错误的 Wrap 实际上是提供了一个 error 嵌套、另一个 error 的能力,从而生成一个 error 的跟踪链,在 fmt.Errorf 中使用 :%w 关键字来将一个错误关联至错误链中。

判定一个错误是否为特定错误不能使用 == ,使用 errors.Is,判定一个错误是否为特定错误,使用errors.Is,使用该方法可以判定错误链上的所有错误是否含有特定的错误。

在错误链上获取特定种类的错误,使用 errors.As,可把出现问题的地方给拼接出来,方便定位问题。

不建议在业务代码中使用 panic。调用函数不包含 recover 会造成程序崩溃。当程序启动阶段发生不可逆转的错误时,可以在 init 或 main 函数中使用 panic。

recover 只能在被 defer 的函数中使用嵌套无法生效,只在当前 goroutine 生效 defer 的语句是后进先出,可以 recover 后在 log 中记录当前的调用栈。

error 尽可能提供简明的上下文信息链,方便定位问题。

panic 用于真正异常的情况。

recover 生效范围,在当前 goroutine 的被 dofer 的函数中生效。

defer 语句会在函数返回前调用多个 defer 语句是后进先出。

二、性能调优实战

2.1 性能调优原则

要根据实际开发环境和具体的数据分析,而不是主观的猜测。

要专注定位对性能影响较大的瓶颈而不是细枝末节。

最好不要过早优化,防止后续升级代码导致无法正常运行造成限制。

不要过度优化。

性能优化的前提: 代码的正确、可靠、简洁等根本的质量因素。

性能优化综合考量: 时间复杂度和空间复杂度。

2.2 使用 Benchmark 进行性能测试

gotest-bench=, -benchmem

BenchmarkFib10 是 测试函数名-8 表示 GOMAXPROCS 的值为 8。

1855870:表示一共执行 1855870 次即 b.N 的值

602.5ns/op:每次执行花费 602.5ns

0 B/op:每次执行申请多大的内存

0 allocs/op:每次执行申请几次内存

2.3 Slice 性能优化建议

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

下面第一个代码 没有预制大小,第二个 初始化了大小。

func NoPreAlloc(size int) {
    data := make([]int, 0)
    for k:= 0; k < size; k++ {
       data = append(data, k)
    }
}
func NoPreAlloc(size int) {
    data := make([]int, 0, size)
    for k:= 0; k < size; k++ {
       data = append(data, k)
    }
}

执行时间对比:

测试的函数方法 申请和执行次数 每次执行逻辑消耗的时间

BenchmarkNoPreAlloc-8 3529980 331.1 ns/op

BenchmarkPreAlloc-8 11171086 107.1 ns/op

通过观察执行时间的对比,明显看出来:初始化大小后会提升效率。

2.4 Map性能优化建议

和 Slice 类似,最好先预分配内存、提前分配好空间,可以减少内存拷贝和 Rehash 的消耗,不断向 map 中添加元素的操作会触发 map 的扩容。

2.5 字符串处理性能优化

使用 strings.Builder 进行字符串拼接可以提升性能。

使用+拼接性能最差,strings.Builder,bytes.Buffer相近,strings.Buffer更快。

下面第一个代码为 直接拼接,第二个代码 使用 Builder。

func Plus(n int, str string) string {
    s := ""
    for i := 0; i < n; i++ {
       s += str
    }
    return s
}

func StrBuilder(n int, str string) string {
    var builder strings.Builder
    for i := 0; i < n; i++ {
        builder.WriteString(str)
    }
    return builder.String()
}

性能差异如下:

测试的函数方法 申请和执行次数 每次执行逻辑消耗的时间

BenchmarkPlus-8 4310 280260 ns/op

BenchmarkStrBuilder-8 269257 4392 ns/op

性能差异原因:

字符串在 Go 语言中是不可变类型,占用内存大小是固定的使用+每次都会重新分配内存。

strings.Builder,bytes.Buffer 底层都是 []byte 数组内存扩容策略,不需要每次拼接重新分配内存。

bytes.Buffer 转化为字符串时重新申请了一块空间,而 strings.Builder 直接将底层的 byte 转换成了字符串类型返回。

注意: 如果知道字符串大小,最好预分配内存,这样的效率最优,原理和 Slice 差不多。

2.6 空结构体的使用

空结构体 struct 实例不占据任何的内存空间,可作为各种场景下的占位符使用。空结构体本身具备很强的语义,即这里不需要任何值,仅作为占位符。

比如:

func EmptyStructMap(n int) {
    m := make(map[int]struct{})

    for i := 0; i < n; i++ {
        m[i] = struct{}{}
    }
}

func BoolMap(n int) {
    m := make(map[int]bool)

    for i := 0; i < n; i++ {
        m[i] = false
    }
}

测试性能结果如下:

测试的函数方法 申请和执行次数 每次执行逻辑消耗的时间

BenchmarkStructMap-8 2372 505970 ns/op

BenchmarkBoolMap-8 2266 526095 ns/op

实现 Set,可以考虑用 map 来代替。对于这个场景,只需要用到 map 的键,而不需要值。

即使是将 map 的值设置为 bool 类型,也会多占据 1 个字节空间。

2.7 atomic 包的使用处理线程问题

下面第一个代码是使用 atomic 包处理线程问题,第二个代码是使用加锁的方式,也就是我们之前经常用到的方式。

比如:

type atomicCounter struct {
    i int 32
}

func AtomicAddOne(c
*atomicCounter) {
    atomic.AddInt32(&c.i, 1)
}

type mutexCounter struct {
    i int 32
    m sync.Mutex
}

func MutexAddOne(c *mutexCounter) {
    c.m.Lock()
    c.i++
    c.m.Unlock()
}

对比性能:

测试的函数方法 申请和执行次数 每次执行逻辑消耗的时间

BenchmarkAtomicAddOne-8 141824372 8.045 ns/op

BenchmarkMutexAddOne-8 60487044 21.73 ns/op

原因分析:

锁的实现是通过操作系统来实现,成本较高,属于系统调用 atomic 操作是通过硬件实现,效率比锁高,sync.Mutex 应该用来保护一段逻辑,不仅仅用于保护一个变量对于非数值操作,可以使用 atomic.Value,能承载一个 interface{}。

避免常见的性能陷阱可以保证大部分程序的性能。普通应用代码,不要一味地追求程序的性能越高级的性能优化手段越容易出现问题,在满足正确可靠、简洁清晰的质量要求的前提下提高程序性能。