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

116 阅读23分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第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
  • 变量距离其被使用的地方越远,则需要携带越多的上下文信息

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

例:iindex 的作用域范围仅限于 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

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

  • 简短并包含一定的上下文信息:例如 schematask

  • 不要与标准库同名:例如不要使用 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 

错误的 WrapUnwrap

  • 错误的 Wrap 实际上是提供了一个 error 嵌套另一个 error 的能力,从而生成一个 error 的跟踪链
  • fmt.Errorf 中使用:%w 关键字来将一个错误关联至错误链中

Go 1.13 在 errors 中新增了三个新 API 和一个新的 format 关键字, 分别是 errors.lserrors.Aserrors.Unwrapfmt.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.As

     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)
       }
     }
     ​
     // Source: https://github.com/golang/go/blob/master/src/errors/wrap_test.go#L255 
    

panic:用于真正异常的情况

  • 不建议在业务代码中使用 panic

    • 调用函数不包含 recover 会造成程序崩溃
    • 若问题可以被屏蔽或解决,建议使用 error 代替 panic
  • 当程序启动阶段发生不可逆转的错误时,可以在 initmain 函数中使用 panic

     func 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
     }
    
    • 数组指针
    • 片段的长度
    • 片段的容量(不改变内存分配情况下的最大长度)

    https://ueokande.github.io/go-slice-tricks/

    • 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.Builderbytes.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.Builderbytes.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 视图

*Mouse.Steal() 函数会向固定的 Buffer 中不断追加 1MB 内存,直到 Buffer 大小达到 1 GB 大小为止

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%

  • 优化内存分配策略
  • 优化代码编译流程,生成更高效的程序
  • 内部压测验证
  • 推广业务服务落地验证

优点

  • 接入简单,只需要调整编译配置
  • 通用性强