这是我参与「第三届青训营 -后端场」笔记创作活动的的第 3 篇笔记。
一、本堂课重点内容:
在开始前要提前安装graphviz:在第二节性能调优实战中打开web页面会用到。
在Windows系统上安装配置Graphviz
- 下载 http://www.graphviz.org/ 找到windows版本
- 安装
- 配置环境变量:计算机→属性→高级系统设置→高级→环境变量→系统变量→path,在path中加入路径 安装目录\bin
- 验证:在windows命令行界面,输入dot -version,而后按回车,若是显示以下图所示的graphviz相关版本信息,则安装配置成功。
二、详细知识点介绍:
1. 高质量编程
1.1 简介
什么是高质量 ————编写的代码能够达到正确可靠,简洁清晰的目标
- 各种边界条件是否考虑完备
- 异常情况处理,稳定性保证
- 易读以维护
1.2 编码规范
1.2.1 代码规范
1.2.2 注释
- 注释应该解释代码作用
- 注释应该解释代码如何做的
- 注释应该解释代码实现的原因
- 注释应该解释代码什么情况会出错
- 公共符号始终要注释
- 包中声明的每个公共的符号:变量、常量、函数以及结构
- 任何极不明显也不简短的公共功能必须予以注释
- 无论长度或复杂程度如何,对库中的任何函数都必须进行注释
- 例外:不需要注释实现接口的方法
1.2.2 小结
- 代码时最好的注释
- 注释应该提供给代码未表达出的上下文信息
1.2.3 命名规范
变量
- 简洁胜于冗长
- 缩略词全大写,但当其位于变量开头且不需要导出时,使用全小写
- 例如使用 ServerHTTP 而不是 ServerHttp
- 使用 XMLHTTPRequest 或者 xmlHTTPRequest
- 变量距离其被使用得到地方越远,则需要携带越多的上下文信息
- 全局变量在其名字中需要更多的上下文信息,使得在不同地方可以轻易辨认出其含义
举例:
i和index的作用域范围仅限于for循环内部时,index的额外冗长没有增加对于程序的理解
- 将 deadline 替换成 t 降低了变量名的信息量
- t 常指任意时间
- deadline 指截止时间,有特定含义
方法
- 函数名不携带包名的上下文信息,因为包名和函数名总是成对出现的
- 函数名尽量简短
- 当名为foo的包某个函数返回类型Foo 时,可以省略类型信息而不导致歧义
- 当名为foo 的包某个函数返回类型丁时(T并不是Foo),可以在函数名中加入类型信息
举例:
package http
func Serve(l net.Listener, handler Handler) error
func ServeHTTP(l net.Listener, handler Handler) error
在http包下明显第一个方法名更好
包
- 只由小写宇母组成。不包含火写宇母和下划钱等字符
- 简短并包含一定的上下文信息。例如schema、task 等
- 不要与标准库同名。例如不要使用sync或者strings
以下规则尽量满足,以标准库包名为例
- 不使用常用变量名作为包名。例如使用bufio而不是 buf
- 使用单数而不是复数。例如使用encoding 而不是encodings
- 谨慎地使用缩写。例如使用fmt在不破坏上下文的情况下比 format更加简短
1.2.3 小结
- 核心目标是降低阅读理解代码的成本
- 重点考虑上下文信息,设计简洁清晰的名称
1.2.4 控制流程
避免嵌套,保持正常流程清晰
尽量保持正常代码路径为最小缩进
- 优先处理错误情况/特殊情况,尽早返回或继续循环来减少嵌套
优化前:
优化后:
1.2.4 小结
- 线性原理,处理逻辑尽量走直线,避免复杂的嵌套分支
- 正常流程代码沿着屏幕向下移动
- 提升代码可维护性和可读性
- 故障问题大多出现在复杂的条件语句和循环语句中
1.2.5 错误和异常处理
简单错误
- 简单的错误指的是仅出观一次的错误,且在其他地方不需要捕获该错误
- 优先使用errors.New来创建匿名变量来直接表示简单错误
- 如果有格式化的需求,使用fmt.Errorf
错误的 Wrap 和 Unwrap
- 错误的Wrap实际上是提供了一个error嵌套另一个error的能力,从而生成一个error的艰踪链
- 在fmt.Errorf 中使用: %w关键字来将一个错误关联至错误链中
错误判定
- 判定一个错误是否为特定错误,使用errors.ls
- 不同于使用==,使用该方法可以判定错误链上的所有错误是否含有特定的错误
- 在错误链上获取特定种类的错误,使用errors.As
panic
- 不建议在业务代码中使用panic
- 调用函数不包含recover会造成程序崩溃
- 若问题可以被屏蔽或解决,建议使用 error代替panic
- 当程序启动阶段发生不可逆转的错误时,可以在init或 main函数中使用panic
recover
- recover 只能在被defer 的图数中使用
- 嵌奔无法生效
- 只在当前goroutine生效defer的语句是后进先出
- 如果需要更多的上下文信息,可以 recover 后再 log 中记录当前的调用栈
1.2.5 小结
- error尽可能提供简明的上下文信息链,方便定位问题
- panic用于真正异常的惰况
- recover生效范围,在当前goroutine的被defer的函数中生效
程序的输出是什么?
最终输出:31
- defer 语句会在函数返回前调用
- 多个 defer 语句是先进后出
1.3 性能优化建议
简介
- 性能优化的前提是满足正确可靠、简洁清晰等质量因素
- 性能优化是综合评估,有时候时间效率和空间效率可能对立
- 针对Go语言特性,介绍Go相关的性能优化建议
1.3.1 Benchmark
如何使用
- 性能表现需要实际数据衡量
- Go语言提供了支持基准性能测试的benchmark工具
go test -bench=. -benchmem
结果说明
1.3.2 slice
slice 预分配内存
- 尽可能在使用make() 初始化切片时提供容量信息
- 第一条建议就是预分配,尽可能在使用make()初始化切片时提供容量信息,特别是在追加切片时
- 对比看下两种情况的性能表现,左边是没有提供初始化容量信息,右边是设置了容量大小
- 结果中可以看出执行时间相差很多,预分配只有一次内存分配
slice 预分配内存
- 切片本质是-一个数组片段的描述
- 包括数组拊针
- 片段的长度
- 片段的容量(不改变内存分配情况下的最大长皮)
- 切片操作并不复制切片指向的元素
- 创建一个新的切片会复用原来切片的底层数组
slice结构体
另一个陷阱:大内存未释放
- 在已有切片基础上创建切片,不会创建新的底层数组
- 场景
- 原切片较大,代码在原切片基础上新建小切片
- 原底层数组在内存中有引用,得不到释放
- 可使用copy替代re-slice
map 预分配内存
分析
- 不断向map中添加元素的操作会触发map的扩容
- 提前分配好空间可以减少内存烤贝和Rehash的消耗
- 建议报据实际需求提前预估好需要的空问
字符串拼接方式
string
strings.Builder
bytes.Buffer
对比
- 使用+拼接性能最差,strings.Builder,bytes.Buffer相近,strings.Builder 更快
- 分析
- 字符串在Go 译言中是不可变类型,占用内存火小是固定的
- 使用+每次都会重新分配内存
- strings.Builder, bytes.Buffer底,层都是byte数组
- 内存扩容策略,不需要每次拼接重新分配内存
strings.Builder 直接将底层的[]byte 转换成了字符串类型返回
bytes.buffer 转化为字符串时重新申请了一块空间
进行了预分配
1.3.5 空结构体
使用空结构体节省内存
- 空结构体struct实例不占据任何的内存空间
- 可作为各种场景下的占位符使用
- 节省资源
- 空结构体本身具备很强的语义,即这里不需要任何值,仅作为占位符
空结构体比bool更省内存
- 实现Set,可以考虑用map来代替
- 对于这个场景,只需要用到map的键,而不需要值
- 即使是将map的值设置为bool类型,也会多占据Ⅰ个字节空间
1.3.6 atomic 包
如何使用 atomic 包
- 锁的实现是通过操作系统来实观,属于系统调用
- atomic操作是通过硬件实现,效率比锁高
- sync.Mutex应该用来保护一段逻辑,不仅仅用于保护一个变量
- 对于非数值操作,可以使用atomic.Value,能承载一个interfacet
1.3 小结
- 避免常见的性能陷阱可以保证火部分程序的性能
- 普通应用代码,不要一味地追求程序的性能
- 越高级的性能优化手段越容易出现问题
- 在满足正确可靠、简洁清晰的质量要求的前提下提高程序性能
2. 性能调优实战
2.1 性能调优原则
- 要依靠数据不是猜测
- 要定位最大瓶颈而不是细枝末节
- 不要过早优化
- 不要过度优化
2.2 性能分析工具 pprof
说明
- 希望知道应用在什么地方耗费了多少CPU、Memory
- pprof是用于可视化和分析性能分析数据的工具
- pprof功能简介
- pprof 排查实战
- pprof的采样过程和原理
2.2.1 功能简介
2.2.2 排查实战(重要)
搭建 pprof 实践项目
- GitHub(来自Wolfogre )
- https://github.com/wolfogre/go-pprof-practice
- 项目提前埋入了一些炸弹代码,产生可观测的性能问题
前置准备
- 下载项目代码,能够编译运行
- 会占用1CPU核心和超过1GB的内存
浏览器查看指标
浏览器输入 http://localhost:6060/debug/pprof/
CPU
我们先从CPU问题排查开始,不同的操作系统工具可能不同,我们首先使用自己熟悉的工具看看程序进程的资源占用,CPU占用了58%,显然这里是有问题的
go tool pprof "http://localhost:6060/debug/pprof/profile?seconds=10"
这里我们使用go tool pprof +采样链接来启动采样。
连接中就是刚刚「|炸弹」程序暴露出来的接回,连接结尾的tporief表采样的对金是CU使用。如果你在流说克器里直接打开这个钳接,会定动一个60s的采祥,并在纯束后下数文件。这里我和们加上scndses = 10的参势数,让他采样10s.
稍等片刻,我们需要的采样数据已经记录和下载完成,并展示出pprof终端
CPU
- 命令:top
- 查看占用资源最多的函数
-
flag == cum, 函数中没有调用其他函数
-
flat == 0, 函数中只有其他函数的调用
-
命令:list
-
根据指定的正则表达式查找代码行
输入:list Eat
- 命令:web
- 调用关系可视化
以上命令可以定位[炸弹]的位置,用q退出终端。
接下来就是将[炸弹]删除:将/animal/felidae/tiget/tiget.go文件下的 23-26行注释,重新运行
heap-堆内存
cpu降下来了,但内存还是很高
终端输入:
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap"
这里浏览器打开的是[内存占用]
点击view按钮切换到top,点击路径
点击view按钮切换到source
根据top图路径打开mouse.go,在steal()这个函数会向固定的Buffer中不断追加1MG内存,直到Buffer达到1GB大小位置,和我们再Graph视图中发现的情况一致。我们将代码注释掉。
至此,[炸弹]已经拆除两个了
重新运行程序,内存降低了
我们注意到右上角有个 unknown inuse_space。
我们打开sample菜单,会发现最内存实际上提供了四种指标。
在堆内存采样中,默认展示的是inuse_space视图,只展示当前持有的内存,但如果有的内存已经释放,这时inuse采样就不会展示了。我们切换到alloc_ space指标。后续分析下alloc的内存问题
- alloc_objects:程序累计申请的对象数
- alloc_space:程序累计中请的内存火小
- inuse_objects:程序当前持有的对象数
- inuse_space:程序当前占用的内存大小
点击 alloc_space,鼠标点击一下dog
在进去 source,然后根据路径找到dog.go,将128M这行代码注释掉
至此,内存部分的[炸弹]已经被全部拆除。
goroutine-协程
- goroutine 泄露也会导致内存泄露
Golang是一门自带垃圾回收的语言,一般情况下内存泄露是没那么容易发生的。
但是有一种例外: goroutine是很容易泄露的,进而会导致内存泄露。
打开http;//ocalhost6060/debug/pprof/,发现「炸弹」程序已经有65条goroutine在运行了,这个量级并不是很大,但对于一个简单的小程序来说还是很不正常的。
终端输入:
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/goroutine"
就会出现一张非常长的调用关系图,但是这种比较不好阅读,推荐使用火焰图
火焰图:
- 由上到下表示调用顺序
- 每一块代表一个函数,越长代表占用CPU的时问更长
- 火焰图是动态的,支持点击块进行分析
打开view菜单,切换到flame graph视图,可以看到刚刚的节点被堆叠起来。
图中自顶向下展示了各个调用,表示各个函数之间的层级关系,每一行中,条形越长代表消耗的资源占比越多,可以看到wolfy资源占比95.24%,所以我们打开source。
- 支持搜索,在Source视图下搜索 wolf
发现每次都会发起十次无意义的goroutine,每次等待30秒菜退出,导致goroutine泄露。这里为了模拟泄漏场景,只设置了30秒,如果发起的goroutine没有退出,不断有新的goroutine被启动,内存占比持续增长,cpu调度压力不断增大,最终进程会被系统kill掉。
我们注释掉代码,重启,可以看到goroutine恢复正常水平
mutex-锁
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/mutex"
修改链接后缀,改成mutex,然后打开网页观察,发现存在1个锁操作同样地,在Graph视图中定位到出问题的函数在Wolf.Howl()
然后在Source视图*中定位到具体哪—行发生了锁竞争
在这个函数中,goroutine足足等待了1秒才解锁,在这里阻塞住了,显然不是什么业务需求,注释掉。
block-阻塞
重启后,可以看到页面中block还剩两个
连接地址结尾换成block
go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/block"
和刚才一样,graph到source视图切换,可以看到,在*Cat.Pee()函数中读取了一个time.Afterl)生成的channel这就导致了这个goroutine实际上阻塞了1秒钟,而不是等待了1秒钟。我们注释掉,不用重启
- 两个blocak为什么只展示了一个
打开block页面,可以看到第二个阻塞操作发生在http handle中,这个操作是符合预期的,不用管。
2.2.2 小结
2.2.3 采样过程和原理
俗话说,知其然还要知其所以然,接下来我们来看看它们内部的实现
cpu
- 采样对象:函数调用和它们占用的时间
- 采样率:100次/秒,固定值
- 采样时间:从手动启动到手动结束
首先来看CPU。CPU采样会记录所有的调用栈和它们的占用时间。
在采样时,进程会每秒暂停一百次,每次会记录当前的调用栈信息。汇总之后,根据调用栈在采样中出现的次数来推断函数的运行时间。
你需要手动地启动和停止采样。每秒100次的暂停频率也不能更改。这个定时暂停机制在unix或类unix系统上是依赖信号机制实现的。
每次「暂停」都会接收到一个信号,通过系统计时器来保证这个信号是固定频率发送的。接下来看看具体的流程。
- 操作系统
- 每10ms向进程发送一次SIGPROF信号
- 进程
- 每次接收到SIGPROF会记录调用堆栈
- 写缓冲
- 每100ms读取已经记录的调用栈并写入翰出流
Heap-堆内存
- 采样程序通过内存分配器在堆上分配和释放的内存,记录分配/释放的大小和数量
- 采样率:每分配512KB记录一次,可在运行开头修改,1为每次分配均记录
- 采样时间:从程序运行开始到采样时
- 采样指标: alloc_space,alloc_objects.inuse_space.inuse_objects
- 计算方式: inuse = alloc - free
Goroutine-协程 & ThreadCreate-线程创建
- Goroutine
- 记豪所有用户发起且在运行中的goroutine(即入口非runtime开头的)runtime.main的调用找信息
- ThreadCreate
- 记录程序创建的所有系统线程的信息
Goroutie采样会记录所有用户发起,也就是入口不是runtime开头的goroutine, 以及main函数所在goroutine的信息和创建这些goroutine的调用栈;
他们在实现上非常的相似,都是会在STW之后,遍历所有goroutine/所有线程的列表(图中的m就是GMP模型中的m,在golang中和线程——对应)并输出堆栈,最后Start The World继续运行。这个采样是立刻触发的全量记录,你可以通过比较两个时间点的差值来得到某—时间段的指标。
Block-阻塞 & Mutex-锁
这两个指标在流程和原理上非常相似,
这两个采样记录的都是对应操作发生的调用栈、次数和耗时,不过这两个指标的采样率含义并不相同。
阻塞操作的采样率是一个「阈值」,消耗超过阈值时间的阻塞操作才会被记录,1为每次操作都会记录。记得炸弹程序的main代码吗?里面设置了rate=1。
锁竞争的采样率是一个「比例」,运行时会通过随机数来只记录固定比例的锁操作,1为每次操作都会记录。
它们在实现上也是基本相同的。都是—个「主动上报」的过程。
在阻塞操作或锁操作发生时,会计算出消耗的时间,连同调用栈一起主动上报给采样器,采样器会根据采样率可能会丢弃-些记录.
在采样时,采样器会遍历已经记录的信息,统计出具体操作的次数、调用栈和总耗时。和堆内存一样,你可以对比两个时间点的差值计算出段时间内的摄作指标.
2.2 小结
- 掌握常用 pprof 工具功能
- 灵活运用pprof 工具分析解决性能问题
- 了解pprof的采样过程和工作原理
2.3 性能调优案例
简介
介绍实际业务服务性能优化的隶例
对逻辑相对复杂的程序如何进行性能调优
- 业务服务优化
- 基础库优化
- Go语言优化
务服务优化
基本概念
- 服务:能单独部署,承载一定功能的程序依赖: ServiceA的功能实现
- 依赖:ServiceB的响应结果,称为ServiceA依赖Service B
- 调用链路:能支持一个接口请求的相关服务集合及其相互之间的依赖关系
- 基础库:公共的工具包、中闻件
流程
- 建立服务性能评估子段
- 分析性能数据,定位性能瓶颈
- 重点优化项改造
- 优化效界验证
建立服务性能评估手段
- 服务性能评估方式
- 单独benchmark 无法满足复杂逻辑分析
- 不同负载情况下性能表现差异
- 请求流量构造
- 不同请求参数覆盖迈科不同
- 线上真实流量情况
- 压测范围
- 单机器压测
- 集群压测
- 性能数据采集
- 单机性能数据
- 集群性能数据
评估手段建立后,它的产出是什么呢?实际是一个服务的性能指标分析报告。
实际的压测报告截图,会统计压测期间服务的各项监控指标,包括qps,延迟等内容,同时在压测过程中,也可以采集服务的pprof数据,使用之前的方式分析性能问题
分析性能数据,定位性能瓶颈
- 使用库不规范
业务服务常见的性能问题可能是使用基础组件不规范
比如这里通过火焰图看出json的解析部分占用了较多的CPU资源,那么我们就能定位到具体的逻辑代码,是在每次使用配置时都会进行json解析,拿到配置项,实际组件内部提供了缓存机制,只有数据变更的时候才需要重新解析json
- 高并发场景优化不足
高峰期性能数据
低峰期性能数据
可以发现metrics,即监控组件的CPU资源占用变化较大,主要原因是监控数据上报是同步请求,在请求量上涨,监控打点数据量增加时,达到性能瓶颈,造成阻塞,影响业务逻辑的处理,后续是改成异步上报的机制提升了性能
重点优化项改造
- 正确性是基础
- 响应数据diff
- 线上请求数据录制回放
- 新旧逻辑接口数据diff
定位到性能瓶颈后,我们也有了对应的修复手段,但是修改完后能直接发布上线吗?
性能优化的前提是保证正确性,所以在变动较大的性能优化上线之前,还需要进行正确性验证,因为线上的场景和流程太多,所以要借助自动化手段来保证优化后程序的正确性同样是线上请求的录制,不过这里不仅包含请求参数录制,还会录制线上的返回内容,重放时对比线上的返回内容和优化后服务的返回内容进行正确性验证
比如图中作者信息相关的字段值在优化有有变化,需要进—步排查原因
优化效果验证
- 重复压测验证
- 上线评估优化效界
- 关注服务监控
- 逐步放曼
- 收集性能数据
进一步优化,服务整体链路分析
- 规范上游服务调用接口,明确场景需求
- 分析链路,通过业务流程优化提升服务性能
AB实验SDK的优化
- 分析基础库核心逻辑和性能瓶颈
- 设计完善改造方案
- 数据按需获取
- 数据序列化协议优化
- 内部压测验证
- 推广业务服务落地验证
编译器 & 运行时优化
- 优化内存分配鬟略
- 优化代码编译流程,坐成更南效的程序
- 内部压测验证
- 推广业务-服务-落地验证
- 优点
- 接入简单,只需要调整编译配置
- 适用性强
图中服务知识换用新的版本进行编译,CPU占用降低8%
2.4 总结
- 性能调优原则
- 要依靠数据不是猜测
- 性能分析工具 pprof
- 熟练使用pprof 工具排查性能问题并了解其基本原理
- 性能调优
- 保证正确性
- 定位主要瓶颈
三、课后个人总结:
- 高质量编程这块主要注意 编码命名规范以及注释
- 性能调优这块主要注意 pprof工具的使用
- 以及安装配置Graphviz