这是我参与「第三届青训营 -后端场」笔记创作活动的的第3篇笔记
高质量编程
简介
高质量代码:达到正确可靠、简洁清晰的目标
- 边界条件是否考虑完备
- 异常情况处理,稳定性保证
- 易读易维护
编程原则
- 简单性:消除“多余的复杂性”,以简单清晰的逻辑编写代码
- 可读性:写给人看
- 生产力:保证团队整体工作效率
常见编码规范
代码格式
- gofmt:Go 语言官方提供的工具,自动格式化 Go 语言代码为官方统一风格
- goimports:Go 语言官方提供的工具,等于 gofmt 加上依赖包管理,自动增删依赖包的引用、将依赖包按字母序排序并分类
注释
Good code has lots of comments, bad code requires lots of comments.
—— Dave Thomas and Andrew Hunt
-
解释代码作用:注释公共符号(常量、变量、对外提供的函数)
// 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 0_RDONLY. // If there is an error,it will be of type *PathError. func Open(name string) (*File, error) { return OpenFile(name, 0_RDONLY, 0) } // Source: https://github.com/golang/go/blob/master/src/os/file.go#L313反例:变量名已充分说明其作用
// Returns true if the table cannot hold any more entries func IsTableFull() bool -
解释代码如何做的:注释实现过程
// 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) } // Source: https://github.com/golang/go/blob/master/src/net/http/client.go#L678反例:
// Process every element in the list for e := range elements { process(e) } -
解释代码实现的原因:注释代码的外部因素、提供额外上下文
switch resp.StatusCode { //... 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 } } // Source: https://github.com/golang/go/blob/master/src/net/http/client.go#L521 -
解释代码什么情况会出错:注释代码的限制条件
// 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) // Source: https://github.com/golang/go/blob/master/src/time/format.go#L1344
⚠ 公共符号始终要注释
-
包中声明的每个公共的符号:变量、常量、函数以及结构都需要添加注释
-
任何既不明显也不简短的公共功能必须予以注释
-
无论长度或复杂程度如何,对库中的任何函数都必须进行注释
// 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) // Source: https://github.com/golang/go/blob/master/src/io/io.go#L638 -
例外:不需要注释实现接口的方法(没有提供额外的信息)
// Read implement the io.Reader interface func (r *FileReader) Read(buf []byte) (int, error)
例:尽管
LimitedReader.Read本身没 有注释,但它紧跟LimitedReader结构的声明,明确它的作用// 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} } // 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 EOF when N<=O or when the underlying R returns EOF. type LimitedReader struct { R Reader // underlying reader N int64 // max bytes remaining } func (l *LimitedReader) Read(p []byte) (n int, err error) { if L.N <= 0 { return 0, EOF } if int64(len(p)) > l.N { p = p[@:l.N] } n,err = l.R.Read(p) L.N -= int64(n) return } // Source: https://github.com/golang/go/blob/master/src/io/io.go#L455
命名规范
Good naming is like a good joke. If you have to explain it, it's not funny.
—— Dave Cheney
- 降低阅读理解代码的成本
- 重点考虑上下文信息
variable
-
简洁不冗长
-
缩略词全大写,但当其位于变量开头且不需要导出时,可使用全小写
- 例如使用
ServeHTTP而不是ServeHttp - 使用
XMLHTTPRequest或者xmlHTTPRequest
- 例如使用
-
变量距离其被使用的地方越远,则需要携带越多的上下文信息
- 全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义
例:
i和index的作用域范围仅限于for循环内部时,index的额外冗长几乎没有增加对于程序的理解// Bad for index := 0; index < len(s); index++ { // do something } // Good for i := O; i< len(s); i++ { // do something }
例:将
deadline替换成t降低了变量名的信息量;t常代指任意时间;deadline指截止时间,有特定的含义// Good func (c *Client) send(req *Request, deadline time.Time) // Bad func (c *Client) send(req *Request, t time.Time)
function
- 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的
- 函数名尽量简短
- 当名为
foo的包某个函数返回类型Foo时,可以省略类型信息而不导致歧义 - 当名为
foo的包某个函数返回类型T时(T并不是Foo),可以在函数名中加入类型信息
例:
http包中创建服务的函数命名,调用时http.Serve而不是http.ServeHTTP// Good func Serve(I net.Listener, handler Handler) error // Serve func ServeHTTP(I net.Listener, handler Handler) error
package
-
只由小写字母组成:不包含大写字母和下划线等字符
-
简短并包含一定的上下文信息:例如
schema、task等 -
不要与标准库同名:例如不要使用
sync或者strings -
以下规则尽量满足,以标准库包名为例
- 不使用常用变量名作为包名:例如使用
bufio而不是buf - 使用单数而不是复数:例如使用
encoding而不是encodings - 谨慎地使用缩写:例如使用
fmt在不破坏上下文的情况下比format更加简短
- 不使用常用变量名作为包名:例如使用
控制流程
-
线性原理,处理逻辑尽量走直线:避免嵌套,保持正常流程清晰
例:如果两个分支中都包含
return语句,则可以去除冗余的else// Bad if foo { return x } else { return nil } // Good if foo { return x } return nil -
尽量保持正常代码路径为最小缩进、延屏幕向下移动:优先处理错误 / 特殊情况,尽早返回或继续循环来减少嵌套,提升可维护性、可读性
例:
- 最常见的正常流程的路径被嵌套在两个 if 条件内
- 成功的退出条件是
return nil,必须仔细匹配大括号来发现函数最后一行返回一个错误,需要追溯到匹配的左括号,才能了解何时会触发错误 - 如果后续正常流程需要增加一步操作,调用新的函数,则又会增加一层嵌套
// Bad func OneFunc() error { err := doSomething() if err == nil { err := doAnotherThing() if err == nil { return nil // normal case } return err } return err } // Good func OneFunc() error { if err := doSomething(); err != nil { return err } if err := doAnotherThing(); err != nil { return err } return nil // normal case }func(b *Reader) UnreadByte() error { if b.lastByte < 0 || b.r == 0 && b.w > 0 { return ErrInvalidUnreadByte } // b.r > 0 // b.w == 0 if b.r > 0 { b.r-- } else { // b.r == 0 && b.w == 0 b.w = 1 } b.buf[b.r] = byte(b.lastByte) b.lastByte = -1 b.lastRuneSize = -1 return nil } // Source: https://github.com/golang/go/blob/master/src/bufio/bufio.go#L277
错误和异常处理
简单错误:尽可能提供简明的上下文信息链,方便定位问题
- 简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误
- 优先使用
errors.New来创建匿名变量来直接表示简单错误 - 如果有格式化的需求,使用
fmt.Errorf
func defaultCheckRedirect(req *Request, via []*Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
}
return nil
}
// Source: https://github.com/golang/go/blob/master/src/net/http/client.go#L802
错误的 Wrap 和 Unwrap
- 错误的
Wrap实际上是提供了一个error嵌套另一个error的能力,从而生成一个error的跟踪链 - 在
fmt.Errorf中使用:%w关键字来将一个错误关联至错误链中
Go 1.13 在
errors中新增了三个新 API 和一个新的 format 关键字, 分别是errors.ls、errors.As、errors.Unwrap及fmt.Errorf的%w。如果项目运行在小于 Go 1.13 的版本中,导入
golang.org/x/xerrors来使用。
list, _, err := c.GetBytes(cache.Subkey(a.actionID,"srcfiles"))
if err != nil {
return fmt.Errorf("reading srcfiles list: %w", err)
}
// Source: https://github.com/golang/go/blob/master/src/cmd/go/intemal/work/exec.go#L983
错误判定
-
判定一个错误是否为特定错误,使用
errors.ls:不同于使用==,使用该方法可以判定错误链上的所有错误是否含有特定的错误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 []bytef), nil } return data, err // Source: https://github.com/golang/go/blob/master/src/cmd/go/internal/modfetch/sumdb.go#L208 -
在错误链上获取特定种类的错误,使用
errors.Asif _, 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) } } // Source: https://github.com/golang/go/blob/master/src/errors/wrap_test.go#L255
panic:用于真正异常的情况
-
不建议在业务代码中使用
panic- 调用函数不包含
recover会造成程序崩溃 - 若问题可以被屏蔽或解决,建议使用
error代替panic
- 调用函数不包含
-
当程序启动阶段发生不可逆转的错误时,可以在
init或main函数中使用panicfunc main() { // ... 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) } // ... } // 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) } // Source: https://github.com/Shopify/sarama/blob/main/examples/consumergroup/main.go#L94
recover
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)
}
}
}()
// ...
}
// Source: https://github.com/golang/go/blob/master/src/fmt/scan.go#L247
-
如果需要更多的上下文信息,可以
recover后在log 中记录当前的调用栈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()) } }() // ... } // Source: https://github.com/golang/website/blob/master/internal/gitfs/fs.go#L228
性能优化建议
- 避免常见的性能陷阱可以保证大部分程序的性能
- 普通应用代码,不要一味地追求程序的性能
- 越高级的性能优化手段越容易出现问题
- 在满足正确可靠、简洁清晰的质量要求的前提下提高程序性能
简介
- 性能优化的前提是满足正确可靠、简洁清晰等质量因素
- 性能优化是综合评估,有时候时间效率和空间效率可能对立
- 针对语言特性
Benchmark:代码性能评估
go test -bench=. -benchmen
例:
// from fib.go func Fib(n int) int { if n < 2 { return n } return Fib(n-1) + Fib(n-2) } // from fib_test.go func BenchmarkFib10(b*testing.B) { // run the Fib function b.N times for n := 0; n < b.N; n++ { Fib(10) } }结果说明:
slice 预分配内存:尽可能在使用 make() 初始化切片时提供初始容量信息
例:
func NoPreAlloc(size int) { data := make([]int, 0) for k := 0; k < size; k++ { data = append(data, k) } } func PreAlloc(size int) { data := make([]int, 0, size) for k := 0; k < size; k++ { data = append(data, k) } }Benchmark:
-
切片本质是一个数组片段的描述
type slice struct { array unsafe.Pointer len int cap int }- 数组指针
- 片段的长度
- 片段的容量(不改变内存分配情况下的最大长度)
append之后的长度小于等于cap,直接利用原底层数组剩余的空间append后的长度大于cap,分配一块更大的区域来容纳新的底层数组- 为了避免内存发生拷贝,如果能够知道最终的切片的大小,预先设置
cap的值能够避免额外的内存分配,获得更好的性能
-
切片操作并不复制切片指向的元素
-
创建一个新的切片会复用原来切片的底层数组(不会创建新的底层数组)
-
陷阱:大内存未释放
-
场景:原切片较大,代码在原切片基础上新建小切片→原底层数组在内存中有引用,得不到释放
-
可用
copy代替re-slice
例:
func GetLastBySlice(origin []int) []int { return origin[len(origin)-2:] } func GetLastByCopy(origin [lint) []int { result := make([]int, 2) copy(result, origin[len(origin)-2:]) return result } func testGetLast(t *testing.T, f func([]int) []int) { result := make([][]int, 0) for k := O; k < 100; k++ { origin := generatewithCap(128 * 1024) // 1M result = append(result, f(origin)) } printMem(t) _ = result }测试:
go test -run=. -v -
-
延申阅读:切片(slice)性能及陷阱
map 预分配内存
例:
func NoPreAlloc(size int) { data := make(map[int]int) for i := 0; i < size; i++ { data[i] = 1 } } func PreAlloc(size int) { data := make(map[int]int, size) for i := 0; i < size; i++ { data[i] = 1 } }Benchmark:
- 不断向
map中添加元素的操作会触发map的扩容 - 提前分配好空间可以减少内存拷贝和
Rehash的消耗 - 建议根据实际需求提前预估好需要的空间
字符串处理:使用 strings.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() } func ByteBuffer(n int, str string) string { buf := new(bytes.Buffer) for i := 0; i < n; i++ { buf.WriteString(str) } return buf.String() }Benchmark:使用
+拼接性能最差,strings.Builder、bytes.Buffer相近,strings.Buffer更快
bytes.Buffer转化为字符串时重新申请了一块空间// To build strings more efficiently, see the strings.Builder type. func(b*Buffer) String() string { if b == nil { // Special case, useful in debugging. return "<nil>" } return string(b.buf[b.off:]) }
strings.Builder直接将底层的byte转换成了字符串类型返回// String returns the accumulated string. func (b *Builder) String() string { return *(*string)(unsafe.Pointer(&b.buf)) }
- 字符串在 Go 语言中是不可变类型,占用内存大小是固定的
- 使用
+每次都会重新分配内存 strings.Builder、bytes.Buffer底层都是[]byte数组- 内存扩容策略,不需要每次拼接重新分配内存
例:进一步提升字符串拼接的效率——预分配
func PreByteBuffer(n int, str string) string { buf := new(bytes.Buffer) buf.Grow(n*len(str)) for i := 0; i < n; i++ { buf.WriteString(str) } return buf.String() } func PreStrBuilder(n int, str string) string { var builder strings.Builder builder.Grow(n*len(str)) for i := 0; i < n; i++ { builder.WriteString(str) } return builder.String() }Benchmark:
空结构体:节省内存
-
空结构体
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[t] = false } }Benchmark:
-
实现
Set,可以考虑用map来代替(开源实现)- 对于这个场景,只需要用到
map的键,而不需要值 - 即使是将
map的值设置为bool类型,也会多占据1个字节空间
- 对于这个场景,只需要用到
atomic 包:多线程并行安全,维护一个原子变量、对其进行操作
例:与加锁对比
type atomicCounter struct { i int32 } func AtomicAddone(c *atomicCounter) { atomic.AddInt32(&c.i, 1) }type mutexCounter struct { i int32 m sync.Mutex } func MutexAddOne(c *mutexCounter) { c.m.Lock() c.i++ c.m.Unlock() }Benchmark:
- 锁的实现是通过操作系统来实现,属于系统调用
- atomic 操作是通过硬件实现,效率比锁高
sync.Mutex应该用来保护一段逻辑,不仅仅用于保护一个变量- 对于非数值操作,可以使用
atomic.Value,能承载一个interface{}
性能调优实战
简介
性能调优原则
- 要依靠数据不是猜测
- 要定位最大瓶颈而不是细枝末节
- 保证正确性
- 不要过早优化
- 不要过度优化
性能分析工具:pprof
- 可视化分析性能数据
功能简介
排查分析实战
搭建 pprof 实践项目
import (
"log"
"net/http"
_ "net/http/pprof" // 自动注册 pprof 的 handler 到 http server
// ...
)
func main() {
log.SetFlags(log.Lshortfile | log.LstdFlags)
log.SetOutput(os.Stdout)
runtime.GOMAXPROCS(1) // 限制 CPU 使用数
runtime.SetMutexProfileFraction(1) // 开启锁调用跟踪
runtime.SetBlockProfileRate(1) // 开启阻塞调用跟踪
go func() {
// 启动 http server
if err := http.ListenAndServe(":6060", nil); err != nil {
log.Fatal(err)
}
os.Exit(0)
}()
// ...
}
- github.com/wolfogre/go…
- 提前埋入了一些炸弹代码,产生可观的性能问题
- 将占用 1 CPU 核心和超过 1 GB 内存
浏览器查看指标:http://localhost:6060/debug/pprof
allocs:内存分配情况blocks:阻塞操作情况cmdline:程序启动(运行进程的)命令goroutine:当前所有goroutine的堆栈信息heap:堆上内存使用情况(同alloc)mutex:锁竞争操作情况profile:CPU 占用情况threadcreate:当前所有创建的系统线程的堆栈信息trace:程序运行跟踪信息,需要额外工具分析
CPU
go tool pprof + 接口链接:go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"
topN 命令:查看占用资源数最多的函数
flat:当前函数本身的执行耗时flat%:flat占CPU 总时间的比例sum%:上面每一行的flat%总和cum:当前函数本身加上其调用函数的总耗时cum%:cum占 CPU 总时间的比例
Q:什么情况下
flat == cum?什么情况下flat == 0?A:
cum - flat得到的是函数中调用其他函数所消耗的资源,所以在函数中没有对其他函数进行调用时,cum - flat = 0;函数中除了调用另外的函数,没有其他逻辑时,flat == 0
list 命令:根据指定的正则表达式查找代码行
web 命令:调用关系可视化
Heap - 堆内存
go tool pprof -http=:8080 "http://localhost:6060/dubug/pprof/heap"
Top 视图
Source 视图
unknown_inuse_space:sample 菜单中堆内存提供了四种指标
- 默认展示的是 inuse_space 视图,只展示当前持有的内存,但如果有的内存已经释放,这时 inuse 采样就不会展示了
alloc_space
*Dog.Run()每次申请 16 MB 大小的内存,并且已经累计申请了超过 3.5 GB 内存- Top 视图中看到这个函数被内联了
- 但因为是无意义的申请,分配结束之后会马上被 GC,所以在 inuse 采样中不会体现
Goroutine - 协程
- Golang 自带垃圾回收,一般情况下不易发生内存泄露
- 但 goroutine 很容易泄露,进而导致内存泄露
go tool pprof -http=:8080 "http://localhost:6060/dubug/pprof/goroutine"
Flame Graph 火焰图
- 由上到下表示调用顺序
- 每一块代表一个函数,长度代表占用 CPU 的时间
- 火焰图是动态的,支持点击块进行分析
*Wolf.Drink()每次发起十条无意义的 goroutine,等待 30 秒后退出- 若内存占用持续增长,CPU 调度压力不断增大,进程最终会被系统 kill 掉
Mutex - 锁
go tool pprof -http=:8080 "http://localhost:6060/dubug/pprof/mutex"
*Wolf.Howl()中发生了锁竞争
Block - 阻塞
go tool pprof -http=:8080 "http://localhost:6060/dubug/pprof/block"
*Cat.Pee()函数中读取了 一个time.After()生成的channel,导致该 goroutine 实际上阻塞了 1 秒钟,而不是等待了 1 秒钟
Q:计数页面上有两个阻塞操作,但是实际上只有一个, 另一个为什么没有展示?
A:观察Top 视图中表格之外的部分,可以发现有 4 个节点因为 cumulative 小于 1.41 秒被 drop 掉了——这就是另一个阻塞操作的节点,但因总用时小于总时长的 5 ‰,所以被省略掉了→更加有效地突出问题所在,利于问题定位
- 打开 Block 指标的页面,可以看到第二个阻塞操作发生在了
http handler中
采样过程和原理
CPU
-
采样对象:函数调用及占用时间
-
采样率:100 次 / 秒,固定值
- 每次记录当前的调用栈信息,汇总后根据调用栈在采样中出现的次数推断函数的运行时间
- 定时暂停机制在 UNIX / 类 UNIX 系统上依赖信号机制实现:每次“暂停”都会接收到一个信号,通过系统计时器保证信号发送的频率固定
-
采样时间:手动起动到手动结束
- 开始采样→设定信号处理函数→开启定时器
- 停止采样→取消信号处理函数→关闭定时器
- 启动采样时,进程向 OS 注册一个定时器
- 操作系统:每 10 ms 向进程发送一次 SIGPROF 信号
- 进程:每次接收到 SIGPROF 会记录调用堆栈,同时启动一个写缓冲的 goroutine
- 写缓冲:每 100 ms 从进程中读取已经记录的调用栈信息并写入输出流
- 采样停止时,进程向 OS 取消定时器、不再接收信号,写缓冲读取不到新的堆栈时结束输出
Heap - 堆内存
-
采样程序通过内存分配器在堆上分配和释放的内存,记录分配 / 释放的大小和数量
- 局限性:依赖内存分配器的记录,只能记录堆上分配、参与 GC 的内存,其他如调用结束级回收的栈内存、更底层使用 CGO 调用分配的内存不会被记录
-
采样率:每分配 512 KB 记录一次,可在运行开头修改,1 为每次分配均记录
-
采样时间:从程序运行开始到采样时(采样时遍历结果并汇总)
-
采样指标:alloc_space、alloc_objects、inuse_space、inuse_objects
-
计算方式:inuse = alloc - free
Goroutine - 协程
- 记录所有用户发起且在运行中的 goroutine(即入口非 runtime 开头的)、main 函数所在的 goroutine 信息及 runtime.main 的调用栈信息
- STW(Stop The World)→遍历 allg 切片→输出创建 g 的堆栈→ Start The World
- 立即触发的全量记录、可通过比较两个时间点的插值得到某一时间段的指标
ThreadCreate - 线程创建
-
记录程序创建的所有系统线程的信息
-
STW →遍历 allm 链表→输出创建 m 的堆栈→ Start The World
- m 即 GMP 模型中的 m,在 Golang 中与线程一一对应
-
立即触发的全量记录、可通过比较两个时间点的插值得到某一时间段的指标
延申:GMP模型
Block - 阻塞
- 采样阻塞操作的次数和耗时
- 采样率:阻塞耗时超过阈值的才会被记录,1 为每次阻塞都记录
- “主动上报”;采样时采样器遍历已经记录的信息,统计出具体操作的次数、调用栈和总耗时, 可对比两个时间点的差值计算出段时间内的操作指标
Mutex - 锁
- 采样争抢锁的次数和耗时
- 采样率:(运行时通过随机数)只记录固定比例的锁操作,1 为每次加锁均记录
- “主动上报”;采样时采样器遍历已经记录的信息,统计出具体操作的次数、调用栈和总耗时, 可对比两个时间点的差值计算出段时间内的操作指标
案例
业务服务优化
- 一般指直接提供功能的程序,如专门处理用户评论操作的程序
基本概念
| 系统部署示意图 客户端请求经过网关转发,由不同的业务服务处理,业务服务可能依赖其他的服务,也可能会依赖存储、消息队列等组件 |
|---|
- 服务:能单独部署,承载一定功能的程序
- 依赖:Service A 的功能实现依赖 Service B的响应结果,称为 Service A 依赖 Service B
- 调用链路:能支持一个接口请求的相关服务集合及其相互之间的依赖关系
- 基础库:公共的工具包、中间件
流程
-
建立服务性能评估标准和手段
-
服务性能评估方式
-
单独 benchmark 无法满足复杂逻辑分析,希望从更高层面分析服务性能问题
-
不同负载情况下性能表现差异
负载 单核 QPS
-
-
请求流量构造
- 逻辑复杂,不同请求参数覆盖逻辑不同,性能表现也不相同
- 线上真实流量情况,分析真正的性能瓶颈:压测录制线上的请求流量,通过控制回放速度对服务进行测试
-
压测范围
- 单机器压测
- 集群压测
-
性能数据采集
- 单机性能数据
- 集群性能数据
-
产出:服务的性能指标分析报告
- 实际的压测报告截图,会统计压测期间服务的各项监控指标,包括 QPS、延迟等内容
- 在压测过程中,也可以采集服务的 pprof 数据分析性能问题
-
-
分析性能数据,定位性能瓶颈:pprof 采样性能数据、分析服务的表现
-
基础组件库使用不规范
- 在每次使用配置时都进行了 JSON 解析
- 实际组件内部提供了缓存机制,只有数据变更的时候才需要重新解析 JSON
-
日志使用不规范
- 调试日志发布到线上
- 线上服务在不同的调用链路上数据有差别
- 真实线上全量场景上导致日志量增加,影响性能
-
高并发场景优化不足
高峰期性能数据 低峰期性能数据 - metrics(监控组件)的 CPU 资源占用变化较大
- 主要原因是监控数据上报是同步请求,在请求量上涨,监控打点数据量增加时,达到性能瓶颈,造成阻塞,影响业务逻辑的处理
- 后续改成异步上报机制提升性能
-
-
重点优化项改造:重构代码、使用更高效的组件
-
正确性是基础
-
响应数据 diff
- 线上请求数据录制回放:包括参数、返回内容
- 新旧逻辑接口数据 diff
-
-
优化效果验证
-
重复压测验证
-
上线评估效果(实际收益)
- 关注服务监控
- 逐步放量
- 收集性能数据:压测并不能保证和线上表现完全一致,有时还要通过线上的表现再进行分析改进,是个长期的过程
-
-
进一步优化:服务整体链路分析
-
规范上游服务调用接口,明确场景需求
-
分析链路,通过业务流程优化提升服务性能
- Service A 调用 Service B 是否存在重复调用情况
- 调用Service B服务时,是否更小的结果数据集就能满足需求
- 接口是否一定要实时数据,能否在 Service A 层进行缓存,减轻调用压力
- 这种优化只使用与特定业务场景,适用范围窄,不过能更合理的利用资源
-
基础库优化
- 一般指提供通用功能的程序,主要针对业务服务提供功能,如监控组件,负责手机业务服务的运行指标
例:AB 实验 SDK 优化
在实际的业务服务中,为了评估某些功能上线后的效果,经常需要进行 AB 实验,看看不同策略对核心指标的影响
公司内部多数服务都会使用 AB 实验的 SDK
如果能优化 AB 组件库的性能,所有用到的服务都会有性能提升
类似业务服务的优化流程
![]()
- 先统计下各个服务中 AB 组件的资源占用情况,寻找更耗费资源的逻辑
- 提取公共问题进行重点优化
- 图中看到有部分性能耗费在序列化上:AB 相关的数据量较大,因此制定优化方案时会考虑优化数据序列化协议,同时进行按需加载,只处理服务需要的数据
- 完成改造和内部压测验证后,会逐步选择线上服务进行试点放量,发现潜在的正确性和使用上的问题,不断迭代后推广到更多服务
流程
-
分析基础库核心逻辑和性能瓶颈
- 设计完善改造方案
- 数据按需获取
- 数据序列化协议优化
-
内部压测验证
-
推广业务服务落地验证
Go 语言优化:编译器 & 运行时优化
例:换用新的发行版本进行编译,CPU 占用降低 8%
- 优化内存分配策略
- 优化代码编译流程,生成更高效的程序
- 内部压测验证
- 推广业务服务落地验证
优点
- 接入简单,只需要调整编译配置
- 通用性强