这是我参与「第三届青训营 -后端场」笔记创作活动的的第3篇笔记
主要针对《高质量编程与性能调优实战》进行学习和总结
- Go 代码 Review 建议:github.com/golang/go/w…
- Uber 的 Go 编码规范:github.com/xxjwxc/uber…
1 高质量编程
1.1 简介
- 简单性:消除多余复杂性,以简单清晰的逻辑编写代码
- 可读性
- 生产力:提升团队整体效率
1.2 编码规范
1.2.1 代码格式
-
目的:提升可读性,风格一致的代码更容易维护、学习和团队合作成本更低,降低review成本
-
gofmt:自动格式化go代码
goimports:gofmt + 依赖包管理,自动增删依赖的包的引用,将依赖包按字母序排序分类等
1.2.2 注释
-
目的:
-
解释代码作用
下面的代码包含了函数的功能,以及运行成功和失败时的结果
// Open opens the named file for reading. If successful, methods on // the returned file can be used for reading; the associated file // descriptor has mode O_RDONLY. // If there is an error, it will be of type *PathError. func Open(name string) (*File, error) { return OpenFile(name, O_RDONLY, 0) } -
解释代码实现过程
// Add the Referer header from the most recent // request URL to the new one, if it's not https->http: if ref := refererForURL(reqs[len(reqs)-1].URL, req.URL); ref != "" { req.Header.Set("Referer", ref) } -
解释代码实现的原因,解释该代码的外部因素,提供额外的上下文信息
case 307, 308: redirectMethod = reqMethod shouldRedirect = true includeBody = true if ireq.GetBody == nil && ireq.outgoingLength() != 0 { // We had a request body, and 307/308 require // re-sending it, but GetBody is not defined. So just // return this response to the user instead of an // error, like we did in Go 1.7 and earlier. shouldRedirect = false } -
解释代码什么情况下会出错
// parseTimeZone parses a time zone string and returns its length. Time zones // are human-generated and unpredictable. We can't do precise error checking. // On the other hand, for a correct parse there must be a time zone at the // beginning of the string, so it's almost always true that there's one // there. We look at the beginning of the string for a run of upper-case letters. // If there are more than 5, it's an error. // If there are 4 or 5 and the last is a T, it's a time zone. // If there are 3, it's a time zone. // Otherwise, other than special cases, it's not a time zone. // GMT is special because it can have an hour offset. func parseTimeZone(value string) (length int, ok bool)
-
-
公共符号始终需要注释
-
包中声明的变量、常量、函数、结构体等都需要注释
-
任何不明显且不简短的公共功能必须予以注释
// ReadAll reads from r until an error or EOF and returns the data it read. // A successful call returns err == nil, not err == EOF. Because ReadAll is // defined to read from src until EOF, it does not treat an EOF from Read // as an error to be reported. func ReadAll(r Reader) ([]byte, error) { b := make([]byte, 0, 512) for { if len(b) == cap(b) { // Add more capacity (let append pick how much). b = append(b, 0)[:len(b)] } n, err := r.Read(b[len(b):cap(b)]) b = b[:len(b)+n] if err != nil { if err == EOF { err = nil } return b, err } } } -
无论长度或复杂度如何,对库中的任何函数都要进行注释
// LimitReader returns a Reader that reads from r // but stops with EOF after n bytes. // The underlying implementation is a *LimitedReader. func LimitReader(r Reader, n int64) Reader { return &LimitedReader{r, n, nil} } // A LimitedReader reads from R but limits the amount of // data returned to just N bytes. Each call to Read // updates N to reflect the new amount remaining. // Read returns Err when N <= 0. // If Err is nil, it returns EOF instead. type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining Err error // error to return on reaching the limit } func (l *LimitedReader) Read(p []byte) (n int, err error) { if l.N <= 0 { err := l.Err if err == nil { err = EOF } return 0, err } if int64(len(p)) > l.N { p = p[0:l.N] } n, err = l.R.Read(p) l.N -= int64(n) return } -
1.2.3 命名规范
-
核心要求:
- 降低阅读代码的成本
- 着重考虑上下文信息,名称清晰简洁
-
变量:
-
简洁胜于冗长
-
缩略词全大写;当其位于变量开头且不需要导出时,全小写
ServeHttp -> ServeHTTP XMLHTTPRequest or xmlHTTPRequest -
变量距离被使用的位置越远,需要携带更多的上下文信息
全局变量的名称需要携带更多的上下文信息,其他地方使用时可以轻易辨认意义
-
-
函数:
- 不携带包名的信息,因为通常使用函数的方式为包名.函数名
- 尽量简短
- 名为foo的包的某个函数的返回类型为Foo时,可以省略类型信息
- 名为foo的包的某个函数的返回类型为T时(非Foo),可以在函数名中加入类型信息
package http func Serve(l net.Listener, handler Handler) error {} // 更好 func ServeHTTP(l net.Listener, handler Handler) error {} // 调用 http.Serve(...) http.ServeHTTP(...) -
包:
- 小写字母组成,不包括大写字母、下划线等字符
- 简短且包含一定的上下文信息,如schema,task等
- 不要与标准库同名
- 不使用常用变量名作为包名,如buf -> bufio
- 使用单数而非复数,如encodings -> encoding
- 谨慎使用缩写,不破坏上下文,含义清晰容易理解,如format -> fmt
1.2.4 控制流程
-
避免嵌套,保持正常流程清晰
// Bad if foo { return x } else { return nil } // Good if foo { return x } return nil -
确保正常代码路径为最小缩进
优先处理错误情况/特殊情况,尽早返回或继续循环来减少嵌套
// Bad // 正常流程返回nil,但所处的位置需要经过层层嵌套 func Foo() error { err := doSomething() if err == nil { err = doAnotherThing() if err == nil { return nil // normal case } return err } return err } // Good func Foo() error { if err := doSomething(); err != nil { return err } if err := doAnotherThing(); err != nil { return err } return nil // normal case } -
线性原理:
- 处理逻辑尽量走直线,避免复杂的嵌套分支
- 正常流程代码沿着屏幕向下移动
1.2.5 错误和异常处理
-
简单错误:
- 含义:指仅出现一次的错误,且在其他地方不需要捕获该错误
- 创建:使用
errors.New创建匿名变量,表示一个简单错误 - 格式化:
fmt.Errorf
-
错误的Wrap和Unwrap:便于跟踪排查问题
- Wrap功能:一个error嵌套另一个error的能力,生成一个error跟踪链
- 错误关联:
fmt.Errorf中使用%w关键字,将错误关联到错误链中 errors.Is()、errors.As()、errors.Unwrap()
-
错误判定:
-
errors.Is(err, target error) bool:判断错误链上的所有错误是否包含特定的错误data, err = lockedfile.Read(targ) if errors.Is(err, fs.ErrNotExist) { // Treat non-existent as empty, to bootstrap the "latest" file // the first time we connect to a given database. return []byte{}, nil } return data, err -
errors.As(err error, target interface{}) bool:在错误链上获取特定种类的错误,传入一个错误的引用func ExampleAs() { if _, err := os.Open("non-existing"); err != nil { var pathError *fs.PathError if errors.As(err, &pathError) { fmt.Println("Failed at path:", pathError.Path) } else { fmt.Println(err) } } // Output: // Failed at path: non-existing }
-
-
panic:
- 业务代码尽量不使用,建议使用error替代
- 当前goroutine中,所有deferred的函数都不包含recover会造成整个程序崩溃
- 当程序启动阶段发生不可逆的错误时,可以在init或main函数中使用panic
// sarama ctx, cancel := context.WithCancel(context.Background()) client, err := sarama.NewConsumerGroup(strings.Split(brokers, ","), group, config) if err != nil { log.Panicf("Error creating consumer group client: %v", err) } // fmt // Panicf is equivalent to Printf() followed by a call to panic(). func Panicf(format string, v ...interface{}) { s := fmt.Sprintf(format, v...) std.Output(2, s) panic(s) } -
recover:
- 只能在defer的函数中使用
- 嵌套无法生效
- 仅在当前goroutine有效
- defer的语句是后进先出,是栈的处理方式
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) } } }() if f == nil { f = notSpace } s.buf = s.buf[:0] tok = s.token(skipSpace, f) return }-
若需要更多上下文信息,可以recover后在log中记录当前的调用栈
// Open opens the given file or directory, implementing the fs.FS Open method. func (t *treeFS) Open(name string) (f fs.File, err error) { defer func() { if e := recover(); e != nil { f = nil err = fmt.Errorf("gitfs panic: %v\n%s", e, debug.Stack()) } }() // ... }
-
error尽可能提供简明的上下文信息,方便定位问题
panic用于真正异常情况
recover在当前goroutine中被defer的函数中生效
1.3 性能优化建议
-
Benchmark:基准测试,测量程序在固定工作负载下的性能
go test -bench=. -benchmen-bench:指手工指定要运行的基准测试函数,是一个正则表达式,默认值为空-bench=.:.模式表示匹配所有的基准测试函数-benchmen:在报告中包含内存的分配数据统计文件写入:如果
-c,则写成二进制文件文件读取分析通过
go tool pprof xxx实现-blockprofile block.out:将协程的阻塞数据写入特定的文件(block.out)-cpuprofile cpu.out:将协程的CPU使用数据写入特定的文件(cpu.out)-memprofile mem.out:将协程的内存申请数据写入特定的文件(mem.out)-mutexprofile mutex.out:将协程的互斥数据写入特定的文件(mutex.out)-trace trace.out:将执行调用链写入特定文件(trace.out)测试案例:
// fib.go func Fib(n int) int { if n < 2 { return n } return Fib(n-1) + Fib(n-2) } // fib_test.go import "testing" // BenchmarkFib10 run 'go test -bench=. -benchmem' to get the benchmark result func BenchmarkFib10(b *testing.B) { // run the Fib function b.N times for n := 0; n < b.N; n++ { Fib(10) } }结果:
- BenchmarkFib10-8:BenchmarkFib10是测试函数名,-8指GOMAXPROCS的值为8(逻辑CPU数量)
`runtime.GOMAXPROCS(逻辑CPU数量)`可以设置,`runtime.NumCPU()`查询当前CPU数量
- 4701891:表示一共执行了多少次,即`b.N`的值
- 268.8 ns/op:每次执行耗费的时间
- 0 B/op:每次执行大概申请多少内存(量)
- 0 allocs/op:每次执行申请几次内存(次数)
-
slice预分配内存:【时间优化】
-
使用make初始化slice时,提供容量信息,特别是在追加切片时
-
本质:一个数组片段的描述,包括数组指针、片段长度、片段容量(不改变内存分配时的最大长度)
-
原理:ueokande.github.io/go-slice-tr…
- slice的操作不会复制底层的元素
- 在已有的slice基础上创建一个新的slice,新slice会复用原来的底层数组
-
大内存无法释放:
- 场景:原始slice占用很大内存,在此基础上进行切片(范围很小),导致大部分数据占用内存、没有使用且无法被释放
- 方案:copy替代re-slice
// Bad func Foo(origin []int) []int { return origin[len(origin)-2:] } // Good func Foo(origin []int) []int { result := make([]int, 2) copy(result, origin[len(origin)-2:]) return result }
-
-
map预分配内存:【时间优化】
- 原因:不断向map添加元素,会触发map的扩容
- 方式:预估需要的空间,并提前分配内存,减少内存拷贝和Rehash消耗
- map底层实现:zhuanlan.zhihu.com/p/406751292
-
字符串处理:【时间优化】
-
案例:字符串拼接
-
- 执行速度:`strings.Builder` > `bytes.Buffer` > `+`
占用内存:`strings.Builder` ~ `bytes.Buffer` < `+`
- 原理:
- 字符串是不可变类型,占用内存大小固定
- 使用`+`时需要每次重新分配内存
- `strings.Builder`和`bytes.Buffer`底层都为`[]byte`,采用内存扩容机制(倍数申请),不需要每次重新分配内存
`bytes.Buffer`:转换为字符串时会重新申请内存,存放生成的字符串
`strings.Builder`:直接将底层`[]byte`转换成了字符串类型并返回
-
结构体:【空间优化】
- 空结构体不占据任何内存空间,可以作为占位符(作为一种语义占位)
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 } } /* 占用内存 BenchmarkEmptyStructMap-8 1861 595148 ns/op 389394 B/op 254 allocs/op BenchmarkBoolMap-8 2012 588189 ns/op 427587 B/op 320 allocs/op */-
应用:Set实现,仅需要使用map的K,不需要V,可以用空结构体替代,节省内存
type threadUnsafeSet[T comparable] map[T]struct{}
-
atomic包:【时间&空间优化】
- 与加锁的方式对比
import ( "sync" "sync/atomic" ) // atomic type atomicCounter struct { i int32 } func AtomicAddOne(c *atomicCounter) { atomic.AddInt32(&c.i, 1) } // lock type mutexCounter struct { i int32 m sync.Mutex } func MutexAddOne(c *mutexCounter) { c.m.Lock() c.i++ c.m.Unlock() } /* 执行时间 占用内存 BenchmarkAtomicAddOne-8 72773199 15.88 ns/op 4 B/op 1 allocs/op BenchmarkMutexAddOne-8 39835346 33.64 ns/op 16 B/op 1 allocs/op */-
原理:
- lock是OS实现,属于系统调用;atomic通过硬件实现,效率比lock高
- lock应用于保护一段逻辑,而非保护一个变量
-
对于非数值,可以使用
atomic.Value,承载一个interface{}
2 性能调优实战
2.1 性能调优原则
- 依靠数据,而非猜测:根据统一的数据和标准进行评估
- 定位最大瓶颈,而非细枝末节
- 不要过早优化:先实现基本功能,当预期出现性能瓶颈时再进行优化
- 不要过度优化:防止因为使用了某些特定优化方式,导致迭代过程中的优化手段的不兼容
2.2 性能分析工具pprof
2.2.1 pprof功能
2.2.2 排查实践
-
测试代码
func main() { // 运行环境 log.SetFlags(log.Lshortfile | log.LstdFlags) log.SetOutput(os.Stdout) runtime.GOMAXPROCS(1) // 限制CPU使用数 runtime.SetMutexProfileFraction(1) // 开启锁调用跟踪,mutex runtime.SetBlockProfileRate(1) // 开启阻塞调用跟踪,block go func() { // 启动http server if err := http.ListenAndServe(":6060", nil); err != nil { log.Fatal(err) } os.Exit(0) }() // 实际运行的测试代码 for { // ... } } -
浏览器查看指标:http://localhost:6060/debug/pprof/
allocs:所有过去内存分配的抽样
block:导致阻塞同步原语(synchronization primitives)的栈跟踪
cmdline:当前程序的命令行调用
goroutine:所有当前协程的栈跟踪
heap:存活对象的内存分配采样,可以再获得heap采样之前,设置GC GET参数来运行GC
mutex:争用互斥锁的持有者的栈跟踪
profile:CPU属性,可以通过seconds GET参数设置持续时间
利用
go tool命令研究属性文件threadcreate:导致创建新OS线程的栈跟踪
trace:当前程序执行跟踪,可以通过seconds GET参数设置持续时间
利用
go tool命令研究跟踪文件重点关注:goroutine,threadcreate,heap,block
-
下载信息:
go tool pprof http://localhost:6060/debug/pprof/xxx,xxx为下载到本地的数据go tool pprof http://localhost:6060/debug/pprof/xxx?seconds=n,设置采样时间段 -
读取文件:
go tool pprof xxx -
数据分析:pprof程序中
topN:查看占用资源最多- flat:当前函数本身的执行耗时
- flat%:flat占CPU总时间的比例
- sum%:从上至下每一行flat%的总和统计
- cum:当前函数本身+其中调用的函数的总耗时
- cum%:cum占CPU总时间的比例
note:flat=cum,则当前函数没有调用其他函数;flat=0时,则当前函数只有其他函数的调用
list pattern:根据指定的正则表达式查找代码行,显示匹配到的代码行/函数执行的flat和cumweb:调用关系可视化,显示各函数之间的调用图,及内存之间的关系traces pattern:打印所用的调用栈,及调用栈的指标信息 -
问题排查:
- 排查堆内存问题:go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap
- 排查协程问题:go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine
- 排查锁问题:go tool pprof -http=:8080 http://localhost:6060/debug/pprof/mutex
- 查阻塞问题:go tool pprof -http=:8080 http://localhost:6060/debug/pprof/block
-
可视化界面:
-
VIEW:
Top:同pprof top
Graph:函数调用图
Flame Graph:火焰图
从上至下表示调用顺序,每一块代表一个函数,越长代表占用CPU时间更长。
动态的,支持点击块进行分析
Peek:同pprof test
Source:源码中某些行的flat和cum
Disassemble:反汇编
-
SAMPLE:
alloc_objects:程序累计申请的对象数
alloc_space:程序累计申请的内存大小
inuse_objects:程序当前持有的对象数
inuse_space:程序当前占用的内存大小
-
2.2.3 pprof采样原理
-
CPU采样:
-
采样对象:函数调用和占用的时间
-
采样率:100次/s,固定值
-
采样时间:手动启动到结束
-
采样过程:
-
- OS每10ms向进程发送一次SIGPROF信号
- 进程接收到信号后,记录调用堆栈信息,并每100ms将已经记录的信息写入输出流
-
Heap采样:
-
采样率:每分配512KB记录一次,可修改
-
采样时间:从程序开始到采样时
-
采样指标:alloc_objects,alloc_space,inuse_objects,inuse_space
计算方式:inuse = alloc - free
-
采样方式:采样程序通过内存分配器在堆上分配和释放内存,记录分配/释放的大小和数量
-
-
协程和系统线程采样:
-
协程:记录用户发起且在运行中的goroutine(入口非runtime开头) runtime.main调用栈信息
采样方式:stop world -> 遍历allg切片 -> 输出创建g的堆栈 -> start world
-
线程:记录程序创建的所有系统线程的信息
采样方式:stop world -> 遍历allm链表 -> 输出创建m的堆栈 -> start world
-
-
阻塞操作和锁竞争采样:
-
阻塞:采样阻塞操作的次数和耗时
采样率:阻塞耗时超过阈值时记录,1表示每次阻塞均记录
runtime.SetBlockProfileRate(1)采样方式:当发生阻塞时,给Profiler上报调用栈和消耗时间,Profiler遍历阻塞记录采样,统计阻塞次数和耗时
-
锁竞争:采样争抢锁的次数和耗时
采样率:只记录固定比例的锁操作,1为每次加锁均记录
runtime.SetMutexProfileFraction(1)采样方式:发生锁竞争时,给Profiler上报调用栈和消耗时间,Profiler遍历锁记录,统计锁竞争次数和耗时
-
2.3 性能调优流程
2.3.1 基本概念
- 服务:能单独部署,承载一定功能的程序
- 依赖:A的功能实现依赖于B,称为A依赖B
- 调用链路:能支持一个接口请求的相关服务集合,及其相互之间的依赖关系
- 基础库:公共的工具包、中间件
2.3.2 业务服务优化
-
流程:
- 建立服务性能评估手段,根据系统的规模、实际运行需求建立评估方法
- 分析性能数据,定位性能瓶颈,例如pprof工具等
- 重点优化项改造
- 优化效果验证
-
建立服务性能评估手段:
- 服务性能评估:单独benckmark无法满足复杂逻辑分析,不同负载情况下性能表现不同,需要综合考量
- 构造请求流量:同一个服务的不同请求参数的覆盖逻辑不同,需要模拟真实流量情况
- 压测范围:单机器压测、集群压测
- 性能数据采集:单机性能数据、集群性能数据
-
分析性能数据,定位性能瓶颈:pprof火焰图
-
重点优化项分析:确保正确性为前提(对线上请求数据录制回放,对比新旧逻辑接口数据diff)
- 规范组件库使用
- 高并发场景优化
- 增加代码检查规则避免增量劣化出现
- 优化正确性验证
-
优化效果验证:
- 重复压测验证
- 上线评估优化效果:关注服务监控,逐步放量,收集性能数据
-
服务整体链路分析:
- 规范上游服务调用接口,明确场景需求
- 分析业务流程,通过业务流程优化提升服务性能
2.3.3 基础库优化
-
AB实验SDK优化:
- 分析基础库核心逻辑和性能瓶颈
- 完善改造方案,按需获取,序列化协议优化
- 内部压测验证
- 推广业务服务落地验证
2.3.4 Go语言优化
-
编译器&运行时优化:
- 优化内存分配策略
- 优化代码编译流程,生成更高效的程序
- 内部压测验证
- 推广业务服务落地验证
-
特点:
- 接入简单,只需要调整编译配置
- 通用性强
3 Tips
3.1 sarama
-
sarama文档:
-
kafka介绍:
3.2 内存分配器
zhuanlan.zhihu.com/p/410317967