Go性能调优及高质量代码| 青训营笔记

50 阅读9分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 4 天

本节主要简要介绍了高质量编程的定义和原则,分享了代码格式、注释、命名规范、控制流程、错误和异常处理五方面的常见编码规范。目标主要达成以下四点:如何编写更简洁清晰的代码;常用Go语言程序优化手段;熟悉Go程序性能分析工具;了解工程中性能优化的原则和流程。

高质量编程

什么是高质量——编写的代码能够达到正确可靠、简洁清晰的目标可称之为高质量代码

  • 各种边界条件是否考虑完备
  • 异常情况处理,稳定性保证
  • 易读易维护

编程原则

实际应用场景千变万化,各种语言的特性和语法各不相同,但是高质量编程遵循的原则是相通的

简单性

  • 消除“多余的复杂性”,以简单清晰的逻辑编写代码
  • 不理解的代码无法修复改进

可读性

  • 代码是写给人看的,而不是机器
  • 编写可维护代码的第一步是确保代码可读

生产力

  • 团队整体工作效率非常重要

编码规范

如何编写高质量的Go代码

代码格式

推荐使用gofmt自动格式化代码

注释

  • 注释应该做的

    • 注释应该解释代码作用
    • 注释应该解释代码如何做的
    • 注释应该解释代码实现的原因
    • 注释应该解释代码什么情况会出错

公共符号始终要注释

  • 包中声明的每个公共的符号:变量、常量、函数以及结构都需要添加注释
  • 任何既不明显也不简短的公共功能必须予以注释
  • 无论长度或复杂程度如何,对库中的任何函数都必须进行注释
// ReadAll reads from r until an error or EOF and returns the data it read.
// A successful call return err == nil, not err == EOF. Because ReadAll is
// 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)
  • 有一个例外,不需要注释实现接口的方法。具体不要像下面这样做
// Read implements the io.Reader interface
func (r *FileReader) Read(buf []byte) (int, error)

命名规范

变量命名

  • 简洁胜于冗长

  • 缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写

    • 例如使用ServeHTTP而不是ServeHttp
    • 使用XMLHTTPRequest或者xmlHTTPRequest
  • 变量距离其被使用的地方越远,则需要携带越多的上下文信息

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

函数命名

  • 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的
  • 函数名尽量简短
  • 当名为foo的包某个函数返回类型Foo时,可以省略类型信息而不导致歧义
  • 当名为foo的包某个函数返回类型T时(T并不是Foo),可以在函数名中加入类型信息

包命名

  • 只用小写字母组成。不包含大写字母或下划线等字符
  • 简短并包含一定的上下文信息。例如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
}

错误和异常处理

简单错误处理 —— 仅出现一次的错误,且在其他地方不需要捕获该错误

  • 优先使用 errors.New 来创建匿名变量来直接表示简单错误
  • 如果有格式化的需求,使用 fmt.Errorf
func defaultCheckRedirect(req *Request, via []*Request) error {
  if len(via) >= 10 {
    return errors.New("stopped after 10 redirects")
  }
  return nil
} 

错误的Wrap和Unwrap

  • 错误的Wrap实际上是提供了一个error嵌套另一个error的能力,从而生成一个error的跟踪链
  • 在 fmt.Errorf 中使用: %w 关键字来将一个错误关联至错误链中
  • Go1.13 在 errors 中新增了三个新 API 和一个新的 format 关键字,分别是 errors.Is、errors.As 、errors.Unwrap 以及 fmt.Errorf 的 %w。如果项目运行在小于 Go1.13 的版本中,导入 golang.org/x/xerrors 来使用。以下语法均已 Go1.13 作为标准。

list, _, err := c.GetBytes(cache.Subkey(a.actionID, "srcfiles"))
if err != nil {
  return fmt.Errorf("reading srcfiles list: %w", err)
}

错误判定

  • 判断一个错误是否为特定错误,使用 errors.Is
  • 不同于使用==,使用该方法可以判断错误链上的所有错误是否含有特定的错误
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
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)
  }
}

panic

  • 不建议在业务代码中使用panic
  • 调用函数不包含recover会造成程序崩溃
  • 若问题可以被屏蔽或解决,建议使用error代替panic
  • 当程序启动阶段发生不可逆转的错误时,可以在init或main函数中使用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)
  }
  // ...
}

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)
      }
    }
  }()
  // ...
}
  • 如果需要更多的上下文信息,可以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())
    }
  }()
  // ...
}

总结

初次之外,还可以了解一些Standard Go Prject Layout(github.com/golang-stan…

This is a basic layout for Go application projects. It's not an official standard defined by the core Go dev team; however, it is a set of common historical and emerging project layout patterns in the Go ecosystem. Some of these patterns are more popular than others. It also has a number of small enhancements along with several supporting directories common to any large enough real world application.

一个中文版本 Go 面向包的设计和架构分层(github.com/danceyoung/…

性能调优工具

性能调优原则

  • 要依靠数据而不是猜测
  • 要定位最大瓶颈而不是细枝末节
  • 不要过早优化
  • 不要过度优化

性能分析工具 pprof

  • 希望知道应用在什么地方耗费了多少CPU、Memory
  • pprof是用于可视化和分析性能分析数据的工具

http://localhost:6060/debug/pprof/

go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"
  • flat:当前函数本身的执行耗时
  • flat%:flat占CPU总时间的比例
  • sum%:上面每一行的flat%总和
  • cum:指当前函数本身加上其调用函数的总耗时
  • cum%:cum占CPU总时间的比例
(pprof) top
Showing nodes accounting for 3320ms, 100% of 3320ms total
      flat  flat%   sum%        cum   cum%
    3190ms 96.08% 96.08%     3320ms   100%  github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Eat
     130ms  3.92%   100%      130ms  3.92%  runtime.asyncPreempt
         0     0%   100%     3320ms   100%  github.com/wolfogre/go-pprof-practice/animal/felidae/tiger.(*Tiger).Live
         0     0%   100%     3320ms   100%  main.main
         0     0%   100%     3320ms   100%  runtime.main

排查 CPU 问题

  • 命令行分析

    • go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"
      
  • top 命令

  • list 命令

  • 熟悉 web 页面分析

  • 调用关系图,火焰图

  • go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/cpu"
    

排查堆内存问题

  • 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"
    

性能优化案例

业务服务优化

  • 服务:能单独部署,承载一定功能的程序
  • 依赖:Service A的功能实现依赖Service B的响应结果,称为Service A依赖Service B
  • 调用链路:能支持一个接口请求的相关服务集合及其相互之间的依赖关系
  • 基础库:公共的工具包、中间件

流程

  • 建立服务性能评估手段
  • 分析性能数据,定位性能瓶颈
  • 重点优化项改造
  • 优化效果验证

建立服务性能评估手段

  • 服务性能评估手段

    • 单独 benchmark 无法满足复杂逻辑分析
    • 不同负载情况下性能表现差异
  • 请求流量构造

    • 不同请求参数覆盖逻辑不同
    • 线上真实流量情况
  • 压测范围

    • 单机器压测
    • 集群压测
  • 性能数据采集

    • 单机性能数据
    • 集群性能数据

分析性能数据,定位性能瓶颈

  • pprof火焰图

重点优化项分析

进一步优化,服务整体链路分析

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

基础库优化

AB实验SDK的优化

  • 分析基础库核心逻辑和性能瓶颈

    • 设计完善改造方案
    • 数据按需获取
    • 数据序列化协议优化
  • 内部压测验证

  • 推广业务服务落地验证

Go语言优化

编译器&运行时优化

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