IT评测·应用市场-qidao123.com

标题: 高德Go生态的服务稳定性建设|性能优化的实战总结 [打印本页]

作者: 九天猎人    时间: 2023-4-17 10:26
标题: 高德Go生态的服务稳定性建设|性能优化的实战总结
  1. 目前go语言不仅在阿里集团内部,在整个互联网行业内也越来越流行,本文把高德过去go服务开发中的性能调优经验进行总结和沉淀,希望能为正在使用go语言的同学在性能优化方面带来一些参考价值。
复制代码
前言
go语言凭借着优秀的性能,简洁的编码风格,极易使用的协程等优点,逐渐在各大互联网公司中流行起来。而高德业务使用go语言已经有3年时间了,随着高德业务的发展,go语言生态也日趋完善,今后会有越来越多新的go服务出现。在任何时候,保障服务的稳定性都是首要的,go服务也不例外,而性能优化作为保障服务稳定性,降本增效的重要手段之一,在高德go服务日益普及的当下显得愈发重要。此时此刻,我们将过去go服务开发中的性能调优经验进行总结和沉淀,为您呈上这篇精心准备的go性能调优指南。通过本文您将收获以下内容: 一、性能调优-理论篇
1.1 衡量指标
 

优化的第一步是先衡量一个应用性能的好坏,性能良好的应用自然不必费心优化,性能较差的应用,则需要从多个方面来考察,找到木桶里的短板,才能对症下药。那么如何衡量一个应用的性能好坏呢?最主要的还是通过观察应用对核心资源的占用情况以及应用的稳定性指标来衡量。所谓核心资源,就是相对稀缺的,并且可能会导致应用无法正常运行的资源,常见的核心资源如下:
以上这些都是系统资源层面用于衡量性能的指标,除此之外还有应用本身的稳定性指标:
 
1.2 制定优化方案
明确了性能指标以后,我们就可以评估一个应用的性能好坏,同时也能发现其中的短板并对其进行优化。但是做性能优化,有几个点需要提前注意:第一,不要反向优化。比如我们的应用整体占用内存资源较少,但是rt偏高,那我们就针对rt做优化,优化完后,rt下降了30%,但是cpu使用率上升了50%,导致一台机器负载能力下降30%,这便是反向优化。性能优化要从整体考虑,尽量在优化一个方面时,不影响其他方面,或是其他方面略微下降。第二,不要过度优化。如果应用性能已经很好了,优化的空间很小,比如rt的tp99在2ms内,继续尝试优化可能投入产出比就很低了,不如将这些精力放在其他需要优化的地方上。由此可见,在优化之前,明确想要优化的指标,并制定合理的优化方案是很重要的。常见的优化方案有以下几种:有经验的程序员在编写代码时,会时刻注意减少代码中不必要的性能消耗,比如使用strconv而不是fmt.Sprint进行数字到字符串的转化,在初始化map或slice时指定合理的容量以减少内存分配等。良好的编程习惯不仅能使应用性能良好,同时也能减少故障发生的几率。总结下来,常用的代码优化方向有以下几种:1)提高复用性,将通用的代码抽象出来,减少重复开发。
2)池化,对象可以池化,减少内存分配;协程可以池化,避免无限制创建协程打满内存。
3)并行化,在合理创建协程数量的前提下,把互不依赖的部分并行处理,减少整体的耗时。
4)异步化,把不需要关心实时结果的请求,用异步的方式处理,不用一直等待结果返回。
5)算法优化,使用时间复杂度更低的算法。
设计模式是对代码组织形式的抽象和总结,代码的结构对应用的性能有着重要的影响,结构清晰,层次分明的代码不仅可读性好,扩展性高,还能避免许多潜在的性能问题,帮助开发人员快速找到性能瓶颈,进行专项优化,为服务的稳定性提供保障。常见的对性能有所提升的设计模式例如单例模式,我们可以在应用启动时将需要的外部依赖服务用单例模式先初始化,避免创建太多重复的连接。在优化的前期,可能一个小的优化就能达到很好的效果。但是优化的尽头,往往要面临抉择,鱼和熊掌不可兼得。性能优秀的应用往往是多项资源的综合利用最优。为了达到综合平衡,在某些场景下,就需要做出一些调整和牺牲,常用的方法就是空间换时间或时间换空间。比如在响应时间优先的场景下,把需要耗费大量计算时间或是网络i/o时间的中间结果缓存起来,以提升后续相似请求的响应速度,便是空间换时间的一种体现。在我们的应用中往往会用到很多开源的第三方库,目前在github上的go开源项目就有173万+。有很多go官方库的性能表现并不佳,比如go官方的日志库性能就一般,下面是zap发布的基准测试信息(记录一条消息和10个字段的性能表现)。
PackageTimeTime % to zapObjects Allocated
⚡️ zap862 ns/op+0%5 allocs/op
⚡️ zap (sugared)1250 ns/op+45%11 allocs/op
zerolog4021 ns/op+366%76 allocs/op
go-kit4542 ns/op+427%105 allocs/op
apex/log26785 ns/op+3007%115 allocs/op
logrus29501 ns/op+3322%125 allocs/op
log1529906 ns/op+3369%122 allocs/op
从上面可以看出zap的性能比同类结构化日志包更好,也比标准库更快,那我们就可以选择更好的三方库。
 
二、性能调优-工具篇
当我们找到应用的性能短板,并针对短板制定相应优化方案,最后按照方案对代码进行优化之后,我们怎么知道优化是有效的呢?直接将代码上线,观察性能指标的变化,风险太大了。此时我们需要有好用的性能分析工具,帮助我们检验优化的效果,下面将为大家介绍几款go语言中性能分析的利器。2.1 benchmark
 

Go语言标准库内置的 testing 测试框架提供了基准测试(benchmark)的能力,benchmark可以帮助我们评估代码的性能表现,主要方式是通过在一定时间(默认1秒)内重复运行测试代码,然后输出执行次数和内存分配结果。下面我们用一个简单的例子来验证一下,strconv是否真的比fmt.Sprint快。首先我们来编写一段基准测试的代码,如下:
  1. package main
  2. import (
  3.     "fmt"
  4.     "strconv"
  5.     "testing"
  6. )
  7. func BenchmarkStrconv(b *testing.B) {
  8.     for n := 0; n < b.N; n++ {
  9.       strconv.Itoa(n)
  10.   }
  11. }
  12. func BenchmarkFmtSprint(b *testing.B) {
  13.     for n := 0; n < b.N; n++ {
  14.       fmt.Sprint(n)
  15.   }
  16. }
复制代码
我们可以用命令行go test -bench . 来运行基准测试,输出结果如下:
  1. goos: darwin
  2. goarch: amd64
  3. pkg: main
  4. cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
  5. BenchmarkStrconv-12             41988014                27.41 ns/op
  6. BenchmarkFmtSprint-12           13738172                81.19 ns/op
  7. ok      main  7.039s
复制代码
可以看到strconv每次执行只用了27.41纳秒,而fmt.Sprint则是81.19纳秒,strconv的性能是fmt.Sprint的三倍,那为什么strconv要更快呢?会不会是这次运行时间太短呢?为了公平起见,我们决定让他们再比赛一轮,这次我们延长比赛时间,看看结果如何。通过go test -bench . -benchtime=5s 命令,我们可以把测试时间延长到5秒,结果如下:
  1. goos: darwin
  2. goarch: amd64
  3. pkg: main
  4. cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
  5. BenchmarkStrconv-12             211533207               31.60 ns/op
  6. BenchmarkFmtSprint-12           69481287                89.58 ns/op
  7. PASS
  8. ok      main  18.891s
复制代码
结果有些变化,strconv每次执行的时间上涨了4ns,但变化不大,差距仍有2.9倍。但是我们仍然不死心,我们决定让他们一次跑三轮,每轮5秒,三局两胜。通过go test -bench . -benchtime=5s -count=3 命令,我们可以把测试进行3轮,结果如下:
  1. goos: darwin
  2. goarch: amd64
  3. pkg: main
  4. cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
  5. BenchmarkStrconv-12             217894554               31.76 ns/op
  6. BenchmarkStrconv-12             217140132               31.45 ns/op
  7. BenchmarkStrconv-12             219136828               31.79 ns/op
  8. BenchmarkFmtSprint-12           70683580                89.53 ns/op
  9. BenchmarkFmtSprint-12           63881758                82.51 ns/op
  10. BenchmarkFmtSprint-12           64984329                82.04 ns/op
  11. PASS
  12. ok      main  54.296s
复制代码
结果变化也不大,看来strconv是真的比fmt.Sprint快很多。那快是快,会不会内存分配上情况就相反呢?
通过go test -bench . -benchmem 这个命令我们可以看到两个方法的内存分配情况,结果如下:
  1. goos: darwin
  2. goarch: amd64
  3. pkg: main
  4. cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
  5. BenchmarkStrconv-12         43700922    27.46 ns/op    7 B/op   0 allocs/op
  6. BenchmarkFmtSprint-12       143412      80.88 ns/op   16 B/op   2 allocs/op
  7. PASS
  8. ok      main  7.031s
复制代码
可以看到strconv在内存分配上是0次,每次运行使用的内存是7字节,只是fmt.Sprint的43.8%,简直是全方面的优于fmt.Sprint啊。那究竟是为什么strconv比fmt.Sprint好这么多呢?
通过查看strconv的代码,我们发现,对于小于100的数字,strconv是直接通过digits和smallsString这两个常量进行转换的,而大于等于100的数字,则是通过不断除以100取余,然后再找到余数对应的字符串,把这些余数的结果拼起来进行转换的。
  1. const digits = "0123456789abcdefghijklmnopqrstuvwxyz"
  2. const smallsString = "00010203040506070809" +
  3.   "10111213141516171819" +
  4.   "20212223242526272829" +
  5.   "30313233343536373839" +
  6.   "40414243444546474849" +
  7.   "50515253545556575859" +
  8.   "60616263646566676869" +
  9.   "70717273747576777879" +
  10.   "80818283848586878889" +
  11.   "90919293949596979899"
  12. // small returns the string for an i with 0 <= i < nSmalls.
  13. func small(i int) string {
  14.   if i < 10 {
  15.     return digits[i : i+1]
  16.   }
  17.   return smallsString[i*2 : i*2+2]
  18. }
  19. func formatBits(dst []byte, u uint64, base int, neg, append_ bool) (d []byte, s string) {
  20.     ...
  21.     for j := 4; j > 0; j-- {
  22.         is := us % 100 * 2
  23.         us /= 100
  24.         i -= 2
  25.         a[i+1] = smallsString[is+1]
  26.         a[i+0] = smallsString[is+0]
  27.     }
  28.     ...
  29. }
复制代码
而fmt.Sprint则是通过反射来实现这一目的的,fmt.Sprint得先判断入参的类型,在知道参数是int型后,再调用fmt.fmtInteger方法把int转换成string,这多出来的步骤肯定没有直接把int转成string来的高效。
  1. // fmtInteger formats signed and unsigned integers.
  2. func (f *fmt) fmtInteger(u uint64, base int, isSigned bool, verb rune, digits string) {
  3.     ...
  4.     switch base {
  5.   case 10:
  6.     for u >= 10 {
  7.       i--
  8.       next := u / 10
  9.       buf[i] = byte('0' + u - next*10)
  10.       u = next
  11.     }
  12.     ...
  13. }
复制代码
benchmark还有很多实用的函数,比如ResetTimer可以重置启动时耗费的准备时间,StopTimer和StartTimer则可以暂停和启动计时,让测试结果更集中在核心逻辑上。
 
2.2 pprof
2.2.1 使用介绍

pprof是go语言官方提供的profile工具,支持可视化查看性能报告,功能十分强大。pprof基于定时器(10ms/次)对运行的go程序进行采样,搜集程序运行时的堆栈信息,包括CPU时间、内存分配等,最终生成性能报告。pprof有两个标准库,使用的场景不同:
runtime/pprof的使用方法如下:
  1. package main
  2. import (
  3.   "os"
  4.   "runtime/pprof"
  5.   "time"
  6. )
  7. func main() {
  8.   w, _ := os.OpenFile("test_cpu", os.O_RDWR | os.O_CREATE | os.O_APPEND, 0644)
  9.   pprof.StartCPUProfile(w)
  10.   time.Sleep(time.Second)
  11.   pprof.StopCPUProfile()
  12. }
复制代码
我们也可以使用另外一种方法,net/http/pprof:
  1. package main
  2. import (
  3.     "net/http"
  4.     _ "net/http/pprof"
  5. )
  6. func main() {
  7.     err := http.ListenAndServe(":6060", nil)
  8.     if err != nil {
  9.         panic(err)
  10.     }
  11. }
复制代码
将程序run起来后,我们通过访问http://127.0.0.1:6060/debug/pprof/就可以看到如下页面:
点击profile就可以下载cpu profile文件。那我们如何查看我们的性能报告呢?
pprof支持两种查看模式,终端和web界面,注意: 想要查看可视化界面需要提前安装graphviz。这里我们以web界面为例,在终端内我们输入如下命令:
  1. go tool pprof -http :6060 test_cpu
复制代码
就会在浏览器里打开一个页面,内容如下:从界面左上方VIEW栏下,我们可以看到,pprof支持Flame Graph,dot Graph和Top等多种视图,下面我们将一一介绍如何阅读这些视图。2.2.1 火焰图 Flame Graph如何阅读

首先,推荐直接阅读火焰图,在查函数耗时场景,这个比较直观;最简单的:横条越长,资源消耗、占用越多; 注意:每一个function 的横条虽然很长,但可能是他的下层“子调用”耗时产生的,所以一定要关注“下一层子调用”各自的耗时分布;每个横条支持点击下钻能力,可以更详细的分析子层的耗时占比。2.2.2 dot Graph 图如何阅读

英文原文在这里:https://github.com/google/pprof/blob/master/doc/README.md








2.2.3 TOP 表如何阅读

2.3 trace
pprof已经有了对内存和CPU的分析能力,那trace工具有什么不同呢?虽然pprof的CPU分析器,可以告诉你什么函数占用了最多的CPU时间,但它并不能帮助你定位到是什么阻止了goroutine运行,或者在可用的OS线程上如何调度goroutines。这正是trace真正起作用的地方。我们需要更多关于Go应用中各个goroutine的执行情况的更为详细的信息,可以从P(goroutine调度器概念中的processor)和G(goroutine调度器概念中的goroutine)的视角完整的看到每个P和每个G在Tracer开启期间的全部“所作所为”,对Tracer输出数据中的每个P和G的行为分析并结合详细的event数据来辅助问题诊断的。Tracer可以帮助我们记录的详细事件包含有:
Tracer主要也是用于辅助诊断这三个场景下的具体问题的:
2.3.1 trace性能报告

打开trace性能报告,首页信息包含了多维度数据,如下图:


通常我们最为关注的是View trace和Goroutine analysis,下面将详细说说这两项的用法。2.3.2 view trace

如果Tracer跟踪时间较长,trace会将View trace按时间段进行划分,避免触碰到trace-viewer的限制:View trace使用快捷键来缩放时间线标尺:w键用于放大(从秒向纳秒缩放),s键用于缩小标尺(从纳秒向秒缩放)。我们同样可以通过快捷键在时间线上左右移动:s键用于左移,d键用于右移。(游戏快捷键WASD)

采样状态这个区内展示了三个指标:Goroutines、Heap和Threads,某个时间点上的这三个指标的数据是这个时间点上的状态快照采样:Goroutines:某一时间点上应用中启动的goroutine的数量,当我们点击某个时间点上的goroutines采样状态区域时(我们可以用快捷键m来准确标记出那个时间点),事件详情区会显示当前的goroutines指标采样状态:Heap指标则显示了某个时间点上Go应用heap分配情况(包括已经分配的Allocated和下一次GC的目标值NextGC):Threads指标显示了某个时间点上Go应用启动的线程数量情况,事件详情区将显示处于InSyscall(整阻塞在系统调用上)和Running两个状态的线程数量情况:P视角区这里将View trace视图中最大的一块区域称为“P视角区”。这是因为在这个区域,我们能看到Go应用中每个P(Goroutine调度概念中的P)上发生的所有事件,包括:EventProcStart、EventProcStop、EventGoStart、EventGoStop、EventGoPreempt、Goroutine辅助GC的各种事件以及Goroutine的GC阻塞(STW)、系统调用阻塞、网络阻塞以及同步原语阻塞(mutex)等事件。除了每个P上发生的事件,我们还可以看到以单独行显示的GC过程中的所有事件。事件详情区点选某个事件后,关于该事件的详细信息便会在这个区域显示出来,事件详情区可以看到关于该事件的详细信息:
2.3.3 Goroutine analysis

Goroutine analysis提供了从G视角看Go应用执行的图景。与View trace不同,这次页面中最广阔的区域提供的G视角视图,而不再是P视角视图。在这个视图中,每个G都会对应一个单独的条带(和P视角视图一样,每个条带都有两行),通过这一条带可以按时间线看到这个G的全部执行情况。通常仅需在goroutine analysis的表格页面找出执行最快和最慢的两个goroutine,在Go视角视图中沿着时间线对它们进行对比,以试图找出执行慢的goroutine究竟出了什么问题。

 
2.4 后记
虽然pprof和trace有着非常强大的profile能力,但在使用过程中,仍存在以下痛点:
那么能不能开发一个平台工具,解决以上的这些痛点呢?目前在阿里集团内部,高德的研发同学已经通过对go官方库的定制开发,实现了go语言性能平台,解决了以上这些痛点,并在内部进行了开源。该平台已面向阿里集团,累计实现性能场景快照数万条的获取和分析,解决了很多的线上服务性能调试和优化问题,这里暂时不展开,后续有机会可以单独分享。三、性能调优-技巧篇
除了前面提到的尽量用strconv而不是fmt.Sprint进行数字到字符串的转化以外,我们还将介绍一些在实际开发中经常会用到的技巧,供各位参考。3.1 字符串拼接
拼接字符串为了书写方便快捷,最常用的两个方法是运算符 + 和 fmt.Sprintf()运算符 + 只能简单地完成字符串之间的拼接,fmt.Sprintf() 其底层实现使用了反射,性能上会有所损耗。从性能出发,兼顾易用可读,如果待拼接的变量不涉及类型转换且数量较少(




欢迎光临 IT评测·应用市场-qidao123.com (https://dis.qidao123.com/) Powered by Discuz! X3.4