性能调优|青训营笔记

92 阅读9分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第3篇笔记

1. 编码规范

1.1 命名规范

  1. for内部

    image_9EqWHjknY0.png

  2. 方法签名,deadline是有特殊含义的,所以不能用t(泛指时间)代替,以免降低信息量

    image_Q1vLfQCQXQ.png

  3. function

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

    • 只由小写字母组成。不包含大写字母和下划线等字符
    • 简短并包含一定的上下文信息。例如schema、task等
    • 不要与标准库同名。例如不要使用sync或者strings
    • 下列规则尽量满足
      • 不使用常用变量名作为包名。例如使用bufio而不是buf
      • 使用单数而不是复数。例如使用encoding而不是encodings
      • 谨慎的使用缩写。例如使用fmt在不破坏上下文的情况下比format更加简短
  5. 小结

    • 核心目标是降低阅读理解代码的成本
    • 重点考虑上下文信息,设计简洁清晰的名称

1.2控制流程

  1. 避免嵌套,保持正常流程清晰

    Bad:

    if foo {
      return x
    } else {
      return nil
    }
    

    good:

    if foo {
    return x
    }
    return nil
    
  2. 尽量保持正常代码路径为最小缩进,优先处理错误情况/特殊情况,尽早返回或继续循环来减少嵌套

    Bad:

    func OneFunc() error {
      err := doSomething()
      if err == nil {
        err := doAnotherThing()
        if err == nil {
          return nil
        }
        return err
      }
      return err
    }
    

    Good:

    func OneFunc() error {
      if err := doSomething(); err != nil {
        return err
      }
      if err := doAnotherThing(); err != nil {
        return err
      }
      return nil
    }
    
  3. 小结

    • 线性原理,处理逻辑尽量走直线,避免复杂的嵌套分支
    • 正常流程代码沿着屏幕向下移动
    • 提升代码可维护性和可读性
    • 故障问题大多出现在复杂的条件语句和循环语句中

1.3错误和异常处理

  1. 简单错误

    • 简单的错误指的是仅出现一次的错误,且在其他地方不需要捕获该错误
    • 优先使用error.New来创建匿名变量来直接表示简单错误
    • 如果有格式化的需求,使用fmt.Errorf
  2. 错误的Wrap和Unwrap

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

    • 判定一个错误是否为特定错误,使用errors.Is

    • 不同于使用==,使用该方法可以判定错误链上的所有错误是否含有特定的错误

    • 在错误链上获取特定的某种错误,使用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)
        }
      }
      
  4. panic

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

    • recover只能在被defer的函数中使用
    • 嵌套无法生效
    • 只在当前goroutine生效
    • defer的语句是后进先出
    • 如果需要更多的上下文信息,可以再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())
          }
        }()
        //...
      }
      
  6. 小结

    • error尽可能提供简明的上下文信息链,方便定位问题
    • panic用于真正异常的情况
    • recover生效范围,在当前goroutine的被defer的函数中生效

2. 性能优化

2.1 Benchmark

  • 示例,计算一个斐波那契数列来展示

    package benchmark
    
    func Fib(n int) int {
      if n < 2 {
        return n
      }
      return Fib(n-1) + Fib(n-2)
    }
    
    package benchmark
    
    import "testing"
    
    func BenchmarkFib10(b *testing.B) {
      for n := 0; n < b.N; n++ {
        Fib(10)
      }
    }
    

    image_8_kAX-MBxf.png

2.2 slice预分配内存和引用

  • 可以看出知道大小的情况下预分配的话,优化的空间是很大的,无论是时间还是空间上 (map预分配其实也是一样的

    image_5G-P7urhGs.png

  • 陷阱:大内存未释放

    • 在已有切片基础上创建切片,不会创建新的底层数组
    • 场景
      • 原切片较大,代码在原切片基础上新建小切片
      • 原底层数组在内存中有引用,得不到释放
    • 可使用copy替代re-silce
    func GetLastBySlice(origin []int) []int {
      return origin[len(origin)-2:]
    }
    
    func GetLastByCopy(origin []int) []int {
      result := make([]int, 2)
      copy(result, origin[len(origin)-2:])
      return result
    }
    
    //其中generateWithCap和printMem函数实现未知,先直接引用老师上课的结论,重要的是结果
    func testGetLast(t *testing.T, f func([]int) []int) {
      result := make([][]int, 0)
      for k := 0; k < 100; k++ {
        origin := generateWithCap(128 * 1024)
        result = append(result, f(origin))
      }
      printMem(t)
      _ = result
    }
    

2.3 字符串处理

  • 使用 + 、strings.Builder 、bytes.Buffer 来分别处理拼接字符串效率比较

    image_gvW9YOiSfX.png

    +处理最慢,strings.Builder和bytes.Buffer相近但是前者更快

    • 分析:
      • 字符串在Go语言中是不可变类型,占用内存大小是固定的
      • 使用 + 每次都会重新分配内存
      • strings.Builder,bytes.Buffer底层都是[]byte数组
      • 内存扩容策略,不需要每次拼接重新分配内存
    • strings.Builder比bytes.Buffer更快的原因:
      • bytes.Buffer转化为字符串时重新申请了一块空间
      • strings.Builder直接将底层的[]byte转换成了字符串类型返回
  • 预分配

    image_4QEDi3qOLG.png

2.4 atomic包

  • 锁的实现时通过操作系统来实现的,属于系统调用
  • atomic操作时通过硬件实现,效率比锁高
  • sync.Mutex应该用来保护一段逻辑,不仅仅用于保护一个变量
  • 对于非数值操作,可以使用atomic.Value,能承载一个interface{}

3. 性能调优

3.1原则

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

3.2 pprof

image_BjD6GmRbMy.png

  • class03中的main.go就是一个炸弹代码,专门用来熟悉pprof的使用

  • main函数代码

    image__SHwRT5Bxb.png

  • 启动main.go之后,浏览器中输入127.0.0.1:6060/debug/pprof 得出以下界面

    image_Er9roUVwCZ.png

    点击页内超链接可以看到相应的数据,但是比较的简洁,所以可以用一些可视化的工具来查看,需要下载一些依赖

  • 或者终端输入 go tool pprof "http://localhost:6060/debug/pprof/profile?second=10"

    image_28o9YWjeE9.png

    image_x4Ba7qZf9B.png

    根据报错提示安装依赖

    sudo apt-get install graphviz
    

    最终网页结果

    image_oDp0ReToDj.png

  • pprof中的top命令

    image_pe-7dpeAV-.png

    • flat:当前函数本身的执行耗时
    • flat%:flat占CPU总时间的比例
    • sum%:上面每一行的flat%总和
    • cum:指当前函数本身加上其调用函数的总耗时
    • cum%:cum占CPU总时间的比例
    • flat == cum ,函数中没有调用其他函数
    • flat == 0,函数中只有其他函数的调用
  • list 命令

    • 直接列出代码给出清晰的情况

      image_LGYWXdIm87.png

  • 查heap, go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap"

    • 结果

      image_3ZzFX2cqly.png

      image_-UJuXVZzsZ.png

    • 导航栏:里面有一些火焰图和其他的可视化方式(goroutine、block、mutex、top、alloca、heap、source)

      image_8NedulJgSC.png

  • 采样原理和过程

    image_VHKb9WXuxE.png

    image_YPI-4kedRy.png

4. 业务服务优化

4.1 基本概念

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

image_Y2bAm2vs2S.png

4.2 流程

  • 建立服务性能评估手段 (Benchmark只能评估小部分,服务的话还是需要整体性的)

    • 服务性能评估方式

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

      • 不同请求参数覆盖逻辑不同
      • 线上真实流量情况(压测平台模拟)
    • 压测范围

      • 单机器压测
      • 集群压测
    • 性能数据采集(pprof火焰图可行)

      • 单机性能数据
      • 集群性能数据
  • 分析性能数据,定位性能瓶颈(可以通过火焰图看出)

    • 使用库不规范
    • 高并发场景优化不足(高峰期低峰期性能图判断,看看那些点是在高并发的时候优化不足的)
  • 重点优化项改造

    • 正确性是基础

    • 响应数据diff

      • 线上请求数据录制回放(先将之前的请求结果录制,优化完之后在与之前的结果相比较,来判断此次优化对正确性是否有影响)
      • 新旧逻辑接口数据diff
  • 优化效果验证

    • 重复压测验证

    • 上线评估优化效果

      • 关注服务监控
      • 逐步放量
      • 收集性能数据
  • 进一步优化,服务整体链路分析

    • 规范上游服务调用接口,明确场景需求
    • 分析链路,通过业务流程优化提升服务性能(一些重复调用的情况,需要根据特定的业务场景)

5. 基础库优化

5.1 AB实验SDK的优化

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

6. Go语言优化

6.1 编译器&运行时优化

  • 优化内存分配策略
  • 优化代码编译流程,生成更高效的程序
  • 内部压测验证
  • 推广业务服务落地验证
  • 优点
    • 介入简单,只需要调整编译配置
    • 通用性强

7. 提问

  1. 遇到线上应用panic的时候应该怎么排查?分布式应用除了打log还有没有什么好的debug方式?

    答:log给线上定位问题提供了便捷,因为一般都会有上下文调用堆栈,尤其是nil的情况。除了打log之外,还有go自带的debug工具,但是线上服务的时候不太方便开放,怕误操作,但是如果问题严重的话就可以从线上的机器中拿出一台来开放debug打断点去排查,但是还是很少用。