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

172 阅读14分钟

高质量编程与性能调优实战.png 这是我参与「第三届青训营 -后端场」笔记创作活动的的第3篇笔记

主要针对《高质量编程与性能调优实战》进行学习和总结

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())
              }
          }()
          // ...
      }
      
    • 宕机恢复:c.biancheng.net/view/64.htm…

  • 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)
        }
    }
    

    结果:

性能优化建议-Benchmark.png

-   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
  • 字符串处理:【时间优化】

    • 案例:字符串拼接

性能优化建议-string.png

-   执行速度:`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,可以用空结构体替代,节省内存

      开源实现:github.com/deckarep/go…

      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功能

pprof.png

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,设置采样时间段

    访问http://localhost:6060/debug/pprof/xxx会直接下载

  • 读取文件:go tool pprof xxx

  • 数据分析:pprof程序中

    topN:查看占用资源最多

    • flat:当前函数本身的执行耗时
    • flat%:flat占CPU总时间的比例
    • sum%:从上至下每一行flat%的总和统计
    • cum:当前函数本身+其中调用的函数的总耗时
    • cum%:cum占CPU总时间的比例

    note:flat=cum,则当前函数没有调用其他函数;flat=0时,则当前函数只有其他函数的调用

    list pattern:根据指定的正则表达式查找代码行,显示匹配到的代码行/函数执行的flat和cum

    web:调用关系可视化,显示各函数之间的调用图,及内存之间的关系

    traces pattern:打印所用的调用栈,及调用栈的指标信息

  • 问题排查:

  • 可视化界面:

    • 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,固定值

    • 采样时间:手动启动到结束

    • 采样过程:

pprof-CPU采样.png

    -   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 业务服务优化

  • 流程:

    1. 建立服务性能评估手段,根据系统的规模、实际运行需求建立评估方法
    2. 分析性能数据,定位性能瓶颈,例如pprof工具等
    3. 重点优化项改造
    4. 优化效果验证
  • 建立服务性能评估手段:

    • 服务性能评估:单独benckmark无法满足复杂逻辑分析,不同负载情况下性能表现不同,需要综合考量
    • 构造请求流量:同一个服务的不同请求参数的覆盖逻辑不同,需要模拟真实流量情况
    • 压测范围:单机器压测、集群压测
    • 性能数据采集:单机性能数据、集群性能数据
  • 分析性能数据,定位性能瓶颈:pprof火焰图

  • 重点优化项分析:确保正确性为前提(对线上请求数据录制回放,对比新旧逻辑接口数据diff)

    • 规范组件库使用
    • 高并发场景优化
    • 增加代码检查规则避免增量劣化出现
    • 优化正确性验证
  • 优化效果验证:

    • 重复压测验证
    • 上线评估优化效果:关注服务监控,逐步放量,收集性能数据
  • 服务整体链路分析:

    • 规范上游服务调用接口,明确场景需求
    • 分析业务流程,通过业务流程优化提升服务性能

2.3.3 基础库优化

  • AB实验SDK优化:

    • 分析基础库核心逻辑和性能瓶颈
    • 完善改造方案,按需获取,序列化协议优化
    • 内部压测验证
    • 推广业务服务落地验证

2.3.4 Go语言优化

  • 编译器&运行时优化:

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

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

3 Tips

3.1 sarama

3.2 内存分配器

zhuanlan.zhihu.com/p/410317967

3.3 MPG模型

zhuanlan.zhihu.com/p/62683990

3.4 AB实验

zhuanlan.zhihu.com/p/342756498