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

128 阅读11分钟

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

一、高质量的编程

1.1简介

什么是高质量

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

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

生产中的编程原则

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

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

可读性

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

生产力

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

1.3 性能优化建议

  • 性能优化的前提是满足正确可靠、简洁清晰等质量因素
  • 性能优化是综合评估,有时候时间效率和空间效率可能对立
  • 针对G0语言特性,介绍G0相关的性能优化建议

1.3.1性能优化建议-Benchmark(基准测试)

性能表现需要实际数据衡量。G0语言提供了支持基准性能测试的benchmark工具。

//fib.go
func Fib(n int) int {
	if n < 2 {
		return n
	}
	return Fib(n-1) + Fib(n-2)
}

//fib_test.go
func BenchmarkFib10(b *testing.B) {
	for n := 0; n < b.N; n++ {
		Fib(10)
	}
}

命令行运行 go test -bench=. -benchmem 运行结果如下: image.png BenchmarkFib10-8 :BenchmarkFib10是测试函数名,-8表示GOMAXPROCS的值为8。 5109224:表示一共执行5109224次,即b.N的值。 234.0 ns/op:每次执行花费234.0 ns 0 B/op:每次执行申请多大的内存 0 allocs/op:每次执行申请几次内存 GOMAXPROCS:1.5版本后默认值为CPU核数

常见命令参数如下 image.png

使用方法:

  • 参与Benchmark基准性能测试的方法必须以Benchmark为前缀
  • 参与基准测试函数必须接受一个指向Benchmark类型的指针作为唯一参数,*testing.B
  • 基准测试函数不能有返回值
  • b.ResetTimer 是重置计时器,调用时表示重新开始计时,可以忽略测试函数中的一些准备工作
  • b.N是基准测试框架提供的,表示循环的次数,因为需要反复调用测试的代码,才可以评估性能

1.3.2 性能优化建议-Slice

slice预分配内存 尽可能在使用make0初始化切片时提供容量信息 切片本质是一个数组片段的描述,包括数组指针、片段的长度、片段的容量(不改变内存分配情况下的最大长度)。切片操作并不复制切片指向的元素,创建一个新的切片会复用原来切片的底层数组。

func PreAll(size int){
    data:=make([]int,0,size)
    for k:=0;k<size;k++{
        data=append(data,k)
    }
}

image.png 对比之下,每次操作耗费时间大大降低,申请内存的次数也减少(一开始就分配足够)。也不会因为重复触发扩容导致内存浪费。

避免大内存未释放 注意:在已有切片基础上创建切片,不会创建新的底层数组

  • 原切片较大,代码在原切片基础上新建小切片
  • 原底层数组在内存中有引用,得不到释放
  • 可使用copy替代re-slice

1.3.3 性能优化建议-Map

map预分配内存

  • 不断向map中添加元素的操作会触发map的扩容
  • 提前分配好空间可以减少内存拷贝和Rehash的消耗
  • 建议根据实际需求提前预估好需要的空间
func PreAlloc(size int){
    data:=make(map[int]int,size)
    for i:=0;i<size;i++{
        data[i]=1
}

1.3.4 性能优化处理-字符串处理

使用strings.Builder

//常见的字符串拼接方式

//加号
func Plus(n int,str string)string{
    s:=""
    for i:=0;i<n;i++{
        s+=str
    }
    return s
}

//strings.Builder
func StrBuilder(n int,str string)string{
    var builder strings.Builder
    for i:=0;i<n;i++{
        builder.WriteString(str)
    }
    return builder.String()
}

//使用bytes.Buffer
func ByteBuffer(n int,str string)string{
    buf:=new(bytes.Buffer)
    for i:=0;i<n;i++{
        buf.WriteString(str)
    }
    return buf.String()
}

image.png 上述结果所示,相同情况下使用strings.Builder性能较好。

使用+拼接性能最差,strings.Builder,bytes.Buffer相近,strings.Buffer更快。因为字符串在G0语言中是不可变类型,占用内存大小是固定的。使用+每次都会重新分配内存。strings..Builder,bytes.Buffer底层都是[]byte数组,属于内存扩容策略,不需要每次拼接重新分配内存。

bytes.Buffer转化为字符串时重新申请了一块空间,strings.Builder直接将底层的[]byte转换成了字符串类型返回。所以Builder比Buffer稍快一些。

1.3.5 性能优化建议-空结构体

使用空结构体节省内存

  • 空结构体structf实例不占据任何的内存空间,可作为各种场景下的占位符使用,节省资源。
  • 空结构体本身具备很强的语义,即这里不需要任何值,仅作为占位符。
  • 实现Set,可以考虑用map来代替。对于这个场景,只需要用到map的键,而不需要值。即使是将map的值设置为bool类型,也会多占据1个字节空间

1.3.6 性能优化建议-atomic包

//使用atomic
type atomicCounter struct{
    i int32
}
func AtomicAddOne(c *atomicCounter){
    atomic.AddInt32(&c.i,1)
}

//使用锁
type mutexCounter struct{
    i int32
    m sync.Mutex
}
func MutexAddOne(c *mutexCounter){
    c.m.Lock()
    c.i++
    c.m.Unlock()
}


//测试文件
func BenchmarkAtomicAddOne(b *testing.B) {
	var atomicC atomicCounter
	for n := 0; n < b.N; n++ {
		AtomicAddOne(&atomicC)
	}
}

func BenchmarkMutexAddOne(b *testing.B) {
	var mutexC mutexCounter
	for n := 0; n < b.N; n++ {
		MutexAddOne(&mutexC)
	}
}

image.png 使用atomic包的性能更佳。

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

二、性能调优实战

性能调优原则

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

2.2性能分析工具 pprof

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

pprof实战教程:www.cnblogs.com/jiujuan/p/1…

2.2.1 pprof功能简介

**具体pprof有哪些内容?**可以看下图片

  • 分析部分-有两种方式
  • 具体的工具-可以在runtime/pprof中找到源码,同时Golang的http标准库中也对oproff做了-些封装,能让你在http服务中直接使用它
  • 采样部分-它可以采样程序运行时的CPU、堆内存、goroutine、锁竟争、阻塞调用和系统线程的使用数据
  • 展示-用户可以通过列表、调用图、火焰图、源码、反汇编等视图去展示采集到的性能指标。方便分析

image.png

2.2.2 性能分析工具pprof-排查实战

浏览器访问:http://localhost:6060/debug/pprof/

命令行执行go tool pprof "[http://localhost:6060/debug/pprof/profile?seconds=10"](http://localhost:6060/debug/pprof/profile?seconds=10")

CPU排查-topN命令

输入 top image.png 什么情况下flat==cum,什么情况下flat==0 要理解这个问题,要牢记下面两个概念:

  • flat:当前函数本身的执行时间
  • cum:当前函数本身加上其调用函数的总耗时

flat==cum:即当前函数执行时间==函数本身加上其调用函数的总耗时--->该函数没有调用其他函数。 flat==0:当前函数执行时间 == 0,函数本身+调用函数时间 !=0 -->该函数只调用了其他的函数,没有自己的逻辑。 image.png

  • flat==cum,函数中没有调用其他函数
  • flat==0,函数中只有其他函数的调用

CPU排查-list命令

根据指定的正则表达式查找代码行 image.png

CPU排查-web命令

调用关系可视化 image.png

Heap-堆内存

go tool pprof -http=:8080 "http://localhost:6060/debug/pprof/heap" image.png 如果出现上面的情况,可能是8080端口被占用了,可以换用其他端口。 go tool pprof -http=:8000 "[http://localhost:6060/debug/pprof/heap"](http://localhost:6060/debug/pprof/heap") 回车后浏览器打开ui视图: image.png

source视图 image.png

goroutine排查

go tool pprof -http=:8001 "http://localhost:6060/debug/pprof/goroutine" image.png image.png

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

wolf的时间消耗很大,在Source视图下搜索wolf得到如下图 image.png

mutex排查

go tool pprof -http=:8001 "http://localhost:6060/debug/pprof/mutex" image.png image.png

block阻塞

go tool pprof -http=:8001 "[http://localhost:6060/debug/pprof/block"](http://localhost:6060/debug/pprof/block") image.png go tool pprof "[http://localhost:6060/debug/pprof/block"](http://localhost:6060/debug/pprof/block") 输入top命令 image.png 从框住的地方可以发现,有4个节点因为cumulative小于1.41秒被drop掉了,这就是另-一个阻塞操作的节点,但他因为总用时小于总时长的千分之5,所以被省略胳掉了。这样的过滤策略能够更加有效地突出问题所在,而省略相对没有问题的信息。 如果不作任何过滤全部展示的话,对于一个复杂的程序可能内容就会非常庞大了,不利于我们的问题定位。

2.2.3 性能分析工具pprof-采样过程和原理

CPU

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

image.png 在采样时,进程会每秒暂停一百次,每次会记录当前的调用栈信息。汇总之后,根据调用钱在采样中出现的次数来推断函数的运行时间。用户需要手动地启动和停止采样。每秒100次的暂停频率也不能更改。这个定时暂停机制在unix或类unix系统上是依赖信号机制实现的。每次「暂停】都会接收到一个信号,通过系统计时器来保证这个信号是固定频率发送的。

具体实现 image.png

  • 操作系统:每10ms向进程发送一次SIGPROF信号
  • 进程:每次接收到SIGPROF会记录调用堆栈
  • 写缓冲:每100ms读取已经记录的调用栈并写入输出流

启动采样时,进程向OS注册一个定时器,OS会每隔10ms向进程发送一个SIGPROF信号,进程接收到信号后就会对当前的调用浅进行记录。与此同时,进程会启动一个写缓冲的goroutine,它会每隔100ms从进程中读取已经记录的堆栈信息,并写入到输出流。当采样停止时,进程向OS取消定时器,不再接收信号,写缓冲读取不到新的堆时,结束输出。

Heap-堆内存

  • 采样程序通过内存分配器在堆上分配和释放的内存,记录分配/释放的大小和数量
  • 采样率:每分配512KB记录一次,可在运行开头修改,为每次分配均记录
  • 采样时间:从程序运行开始到采样时
  • 采样指标:alloc space,alloc_objects,inuse_space,inuse_objects
  • 计算方式:inuse=aloc-free

提到内存指标的时候说的都是「堆内存」而不是「内存」,这是因为Ppro的内存采样是有局限性的,内存采样在实现上依赖了内存分配器的记录,所以它只能记录在堆上分配,且会参与GC的内存,一些其他的内存分配,例如调用结束就会回收的栈内存、一些更底层使用cgo调用分配的内存,是不会被内存采样记录的。它的采样率是一个大小,默认每分配512KB内存会采样一次,采样率是可以在运行开头调整的,设为1则为每次分配都会记录。

与CPU和goroutin都不同的是,内存的采样是一个特续的过程,它会记录从程序运行起的所有分配或释放的内存大小和对象数量,并在采样时遍历这些结果进行汇总。还记得刚才的例子中,堆内存采祥的四种指标吗?alloc的两项指标是从程序运行开始的累计指标,而isuse的两项指标是通过累计分配减去累计释放得到的程序当前持有的指标。你也可以通过比较两次alloc的差值来得到某一段时间程序分配的内存[大小和数量].

Goroutine-协程&ThreadCreate-线程创建

  • Goroutine

记录所有用户发起且在运行中的goroutine(即入口非runtime;开头的)runtime.main的调用栈信息

  • ThreadCreate

记录程序创建的所有系统线程的信息 image.png Goroutine采样会记录所有用户发起的goroutine,也就是入☐不是runtime开头的goroutine,以及main函数所在goroutinel的信息和创建这些goroutinel的调用栈。 他们在实现上非常相似,都是在STW之后,遍历所有goroutine/所有线程的列表(图中的m就是GMP模型中的m,在golang中和线程一一对应)并输出堆栈,最后start the world继续运行。这个采样是立刻触发的全量记录,你可以通过比较两个时间点的差值来得到某一个时间段的指标。

Block-阻塞&Mutex-锁

  • 阻塞操作
    • 采样阻塞操作的次数和耗时
    • 采样率阻塞耗时超过阈值的才会被记录,1为每次阻塞均记录

image.png

  • 锁竞争
    • 采样争抢锁的次数和耗时
    • 采样率:只记录固定比例的锁操作,1为每次加锁均记录

image.png 这两个指标在流程和原理上也非常相似。两个采样记录的都是对应操作发生的调用栈、次数和耗时,不过这两个指标的采样率含义并不相同。

阻塞操作的采样率是一个阈值,消耗超过阈值时间的阻塞操作才会被记录,1为每次操作都会记录。

锁竞争的采样率是一个比例,运行时会通过随机数来只记录固定比例的锁操作,1为每次操作都会记录,它们在实现上也是基本相同的。都是一个「主动上报」的过程。

在阻塞操作或锁操作发生时,会计算出消耗的时间,连同调用栈一起主动上报给采样器,采样器会根据采样率可能会丢弃-些记录。在采样时,采样器会遍历已经记录的信怎,统计出具体操作的次数、调用和总耗时。和堆内存一样,可以对比两个时间点的差值计算出段时间内的操作指标。

2.3 性能调优案例

2.3.1 性能调优案例-业务服务优化

基本概念

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

image.png

流程

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

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

建立服务性能评估手段

  • 服务性能评估方式
    • 单独benchmark无法满足复杂逻辑分析
    • 不同负载情况下性能表现差异
  • 请求流量构造
    • 不同请求参数覆盖逻辑不同
    • 线上真实流量情况
  • 压测范围
    • 单机器压测
    • 集群压测
  • 性能数据采集
    • 单机性能数据
    • 集群性能数据

评估手段建立后,它的产出实际是一个服务的性能指标分析报告。这个报告会统计压测期间服务的各项监控指标,包括qps,延迟等内容,同时在压测过程中,也可以采集服务的pprof数据,使用上一节的方式分析性问题。

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

  • 使用库不规范。如:json解析部分占用较多的CPU资源,可以定位到具体的逻辑代码,是每次使用配置时都进行json解析,拿到配置项,实际组件内部提供了缓存机制,只有数据变更的时候才需要重新更新json数据。
  • 高并发场景优化不足

重点优化项改造

  • 正确性是基础
  • 响应数据diff
    • 线上请求数据录制回放
    • 新旧逻辑接口数据diff

性能优化的前提是保证正确性,所以在变动较大的性能优化上线之前,还需要进行正确性验证,因为线上的场景和流程太多,所以要借助自动化手段来保证优化后程序的正确性。同样是线上请求的录制,不过这里不仅包含请求参数录制,还会录制线上的返回内容,重放时对比线上的返回内容和优化后服务的返回内容进行正确性验证

优化效果验证

  • 重复压测验证
  • 上线评估优化效果
    • 关注服务监控
    • 逐步放量
    • 收集性能数据

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

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

AB实验SDK的优化

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

适用范围更广的就是基础库的优化。比如在实际的业务服务中,为了评估某些功能上线后的效果,经常需要进行AB实验,看看不同策略对核心指标的影响,所以公司内部多数服务都会使用AB实验的SDK,如果能优化AB组件库的性能,所有用到的服务都会有性能提升。类似以业务服务的优化流程,也会先统计下各个服务中AB组件的资源占用情况,看看AB组件的哪些逻辑更耗费资源,提取公共问题进行重点优化。 图中看到有部分性能耗费在序列化上,因为八B相关的数据量较大,所以在制定优化方案时会考虑优化数据序列化协议,同时进行按需加载,只处理服务需要的数据。 完成改造和内部压测验证后,会逐步选择线上服务进行试点放量,发现潜在的正确性和使用上的问题,不断迭代后推广到更多服务。

2.3.3 性能调优案例-Go语言优化

image.png

编译器&运行时优化

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

优点

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

参考: