后端小白学习笔记(四):高质量编程与性能调优实战 | 青训营笔记

101 阅读10分钟

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

1、高质量编程

1.1 简介

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

  • 边界条件
  • 异常处理
  • 易读易维护

编码原则:

  1. 简单性
  • 消除“多余的复杂性”
  • 不理解的代码无法修复改进
  1. 可读性
  • 编写可维护的代码的第一步是确保代码可读
  1. 生产力
  • 团队整体工作效率

1.2 编码规范

  • 代码格式
  • 注释
  • 命名规范
  • 控制流程
  • 错误和异常处理

1.2.1 代码格式

  • gofmt

Go语言官方的工具,能够自动格式化 Go 代码。Goland 中有相关配置

  • goimports

Go语言官方的工具,等于gofmt加上依赖包管理。自动增删依赖的包引用、将依赖包按照字母序排序并分类

1.2.2 注释

  1. 简介
  • 注释应该解释代码作用
    • 适合注释公共符号

  • 注释应该解释代码如何做
    • 注释代码实现过程

  • 注释应该解释代码实现的原因
    • 解释代码外部原因
    • 提供额外的上下文

  • 注释应该解释代码什么情况会出错
    • 解释代码的限制条件

  1. 公共符号始终要注释
  • 公共符号都要有注释说明
  • LimitedReader.Read 本身没有注释,但是它紧跟的 LimitedReader 结构声明,明确了它的作用

注意:不要注释实现接口的方法。

1.2.3 命名规范

  1. 变量
  • 简洁胜于冗长
  • 缩略词全大写,当其不需要导出的时候,可以使用权小写
    • 例如:ServerHTTP 而不是 ServerHttp
    • 使用 XMLHTTPRequest 或者 xmlHTTPRequest
  • 变量距离其被使用的地方越远就需要携带更多的上下文信息
    • 全局变量需要在名字中带更多的上下文信息
  1. 函数
  • 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现
  • 尽量简短
  • 当名为 foo 的包某个函数的返回类型为 Foo 时,可以省略类型信息而不产生歧义
  • 当名为 foo 的包某个函数的返回类型为 T(T 不是 Foo) 时,可以在函数名中加入类型信息
  • 只能由小写字母组成
  • 简短并包含一定上下文信息,例如 schema、task 等
  • 不与标准库同名。不要使用 sync 或者 strings

尽量满足:

  • 不适用常用变量作为包名。例如:使用 bufio 而不是 buf
  • 使用单数而不是复数。例如:使用 encoding 而不是 encodings
  • 谨慎使用缩写

1.2.4 控制流程

  1. 避免嵌套,保证正常流程清晰
  2. 尽量保证正常代码路径为最小缩进
  • 优先处理错误情况/特殊情况,尽早返回或者继续循环减少嵌套

1.2.5 错误和异常处理

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

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

  1. 错误判定
  • 判定一个错误是否为特定错误,使用 errors.Is
  • 与 == 不同,该方法可以判定错误链上所有错误是否包含特定的错误

  • 在错误链上获取特定种类的错误,使用 errors.As

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

  1. recover
  • recover 只能被 defer 函数中使用
  • 嵌套无法生效
  • 只能在当前的 goroutine 中生效
  • defer 语句是后进先出的

  • 如果需要更多上下文信息,可以 recover 后在 log 中记录当前的调用栈

1.3 性能优化建议

1.3.1 Benchmark

  1. 如何使用
  • 性能表现需要实际数据衡量
  • Go 语言提供了支持基准性能测试的 benchmark 工具
go test -bench=. -benchmem

  1. 结果说明

上述结果中第四项表示:每次执行申请多大内存

1.3.2 Slice

  1. slice 预分配内存
  • 尽可能在使用 make() 初始化切片时 提供容量信息(如下图:建议右侧写法)

性能优化原因:

  • 切片本质是是一个数组片段的描述
    • 包括数组指针
    • 片段长度
    • 片段容量(不改变内存分配情况下的最大长度)
  • 切片操作不复制切片指向的元素
  • 创建一个新的切片会复用原来切片的底层数组

性能优化建议:

  • 在已有切片基础上创建切片,不会创建新的底层数组
  • 因此可以使用 copy 代替 re-slice

1.3.3 map 预分配内存

  • 不断向 map 中添加元素会触发 map 扩容
  • 提前分配好空间可以减少内存拷贝和 rehash 的消耗

1.3.4 使用 strings.Builder

常见字符串拼接方式:Plus(+)、StrBuilder、ByteBuffer

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

进一步提升性能方法:

使用 Grow 方法预分配内存空间长度:builder.Grow(n * len(str));

1.3.5 空结构体

  1. 使用空结构体节省内存
  • 空结构体 struct{} 实例不占据任何内存空间
  • 可作为任何场景下的占位符使用
    • 节省资源
    • 本身具备很强的语义,即这里不需要任何值,仅仅作为占位符

注意:实现 Set 可以考虑 map 来代替。对于该场景,只需要使用 map 的键而不用 map 的值

1.3.6 atomic 包

atomic 和 加锁 的对比

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

2、性能调优实战

2.1 简介

  • 依靠数据不是猜测
  • 不要过早优化 和 过度优化

2.2 性能分析工具 pprof

2.2.1 pprof 工具组成

2.2.2 排查实战

浏览器查看指标

CPU

  1. 命令:topN(查看占用资源最多的函数)

什么情况下 flat == cum?什么情况下 flat==0?

flat==cum:函数中没有调用其他函数

flat==0:函数中只调用了其他函数没有其他操作

  1. 命令:list(根据指定的正则表达式查找代码行)
  2. 命令:web(调用关系可视化)

Heap-堆内存

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

Top视图 与 Source视图

SAMPLE列表下:

  • alloc_objects:程序累计申请的对象数
  • inuse_objects:程序当前持有的对象数
  • alloc_space:程序累计申请的内存大小
  • inuse_space:程序当前占用的内存大小 goroutine-协程

goroutine泄露也会导致内存泄露

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

切换到火焰图视角后:

  • 由上到下表示调用顺序
  • 每一块表示一个函数,越长表示占用CPU的时间越长
  • 火焰图是动态的,支持点击块进行分析

  • 支持搜索,可以在 Source 视图下搜索

mutex-锁

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

与前面一样,先在graph视图里面看占用时间的方法,再在source视图里面看是哪一行代码有问题,将其注释

block-阻塞

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

小结

2.2.3 采样过程和原理

  1. CPU:
  • 采样对象:函数调用和它们占用的时间
  • 采样率:100次/秒,固定值
  • 采样时间:从手动启动到手动结束

  1. Heap-堆内存
  • 采样程序通过内存分配器在堆上分配和释放内存,记录分配/释放的大小和数量
  • 采样率:每分配512KB记录一次,可以在运行开头修改,1为每次分配均记录
  • 采样时间:从程序运行开始到采样时
  • 采样指标:alloc_space,alloc_objects,inuse_space,inuse_objects
  • 计算方式:inuse = alloc - free
  1. GoRoutine-协程 & ThreadCreate-线程创建
  • Goroutine
    • 记录所有用户发起并且运行中的的 goroutine(即入口非 runtime 开头的)runtime.main 的调用栈信息
  • ThreadCreate
    • 记录程序创建的所有系统线程的信息

  1. Block-阻塞 & Mutex-锁
  • 阻塞操作
    • 采样阻塞操作的次数和耗时
    • 采样率:阻塞耗时超过阈值的才会被记录,1位每次阻塞均记录
  • 锁竞争
    • 采样争抢锁的次数和耗时
    • 采样率:只记录固定比例的锁操作,1为每次加锁均记录

2.3 性能调优案例

2.3.1 业务服务优化

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

  1. 流程
  • 建立服务性能评估手段
    • 服务性能评估方式
      • 单独benchmark无法满足复杂逻辑分析
      • 不同负载情况下性能表现差异
    • 请求流量构造
      • 不同请求参数覆盖逻辑不同
      • 线上真实流量情况
    • 压测范围
      • 单机器压测
      • 集群压测
    • 性能数据采集
      • 单机器性能数据
      • 集群性能数据
  • 分析性能数据,定位性能瓶颈
    • 使用库不规范
    • 高并发场景优化不足
  • 重点优化项改造
    • 正确性是基础
    • 相应数据 diff
      • 线上请求数据录制回放
      • 新旧逻辑接口数据 diff
  • 优化效果验证
    • 重复压测验证
    • 上线评估优化效果
      • 关注服务键控
      • 逐步放量
      • 收集性能数据
  • 进一步优化,服务整体链路分析
    • 规范上游服务调用接口,明确场景需求
    • 分析链路,通过业务流程优化提升服务性能

2.3.2 性能调优案例-基础库优化

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

2.3.3 Go 语言优化

  • 编译器 & 运行时优化
    • 优化内存分配策略
    • 优化代码编译流程
    • 内部压测验证
    • 推广业务服务落地验证

通用性强,只需要调整编译配置

PS

本文主要作用是作为上课笔记,如有错误,欢迎大家评论指正,我会及时改正