作者 | 曹春晖 责编 | 欧阳姝黎 为什么要做优化 这是一个速度决定一切的时代,我们的生活在不断地数字化,线下的流程依然在持续向线上转移,转移过程中,作为工程师,我们会碰到各种各样的性能问题。 互联网公司本质是将用户共通的行为流程进行了集中化管理,通过中心化的信息交换达到效率提升的目的,同时用规模效应降低了数据交换的成本。 如果业务的后端服务规模足够大,那么一个程序员通过优化帮公司节省的成本,就可以负担他十年的工资了。 用人话来讲,公司希望的是用尽量少的机器成本来赚取尽量多的利润。利润的提升与业务逻辑本身相关,与技术关系不大。而降低成本则是与业务无关,纯粹的技术话题。这里面最重要的主题就是“性能优化”。 优化的前置知识从资源视角出发来对一台服务器进行审视的话,CPU、内存、磁盘与网络是后端服务最需要关注的四种资源类型。 对于计算密集型的程序来说,优化的主要精力会放在 CPU 上,要知道 CPU 基本的流水线概念,知道怎么样在使用少的 CPU 资源的情况下,达到相同的计算目标。 对于 IO 密集型的程序(后端服务一般都是 IO 密集型)来说,优化可以是降低程序的服务延迟,也可以是提升系统整体的吞吐量。 IO 密集型应用主要与磁盘、内存、网络打交道。因此我们需要知道一些基本的与磁盘、内存、网络相关的基本数据与常见概念:
优化越靠近应用层效果越好
我们在应用层的逻辑优化能够帮助应用提升几十倍的性能,而最底层的优化则只能提升几个百分点。 这个很好理解,我们可以看到一个 GTA Online 的新闻:rockstar thanks gta online player who fixed poor load times[2]。 简单来说,GTA online 的游戏启动过程让玩家等待时间过于漫长,经过各种工具分析,发现一个 10M 的文件加载就需要几十秒,用户 diy 进行优化之后,将加载时间减少 70%,并分享出来:how I cut GTA Online loading times by 70%[3]。 这就是一个非常典型的案例,GTA 在商业上取得了巨大的成功,但不妨碍它局部的代码是一坨屎。我们只要把这里的重复逻辑干掉,就可以完成三倍的优化效果。同样的案例,如果我们去优化磁盘的读写速度,则可能收效甚微。 优化是与业务场景相关的不同的业务场景优化的侧重也是不同的。 对于大多数无状态业务模块来说,内存一般不是瓶颈,所以业务 API 的优化主要聚焦于延迟和吞吐。对于网关类的应用,因为有海量的连接,除了延迟和吞吐,内存占用可能就会成为一个关注的重点。对于存储类应用,内存是个逃不掉的瓶颈点。 在关注一些性能优化文章时,我们也应特别留意作者的业务场景。场景的侧重可能会让某些人去选择使用更为 hack 的手段进行优化,而 hack 往往也就意味着 bug。如果你选择了少有人走过的路,那你要面临的也是少有人会碰到的 bug。解决起来令人头疼。 优化的工作流程对于一个典型的 API 应用来说,优化工作基本遵从下面的工作流:
可以使用的工具pprofmemory profilerGo 内置的内存 profiler 可以让我们对线上系统进行内存使用采样,有四个相应的指标:
网关类应用因为海量连接的关系,会导致进程消耗大量内存,所以我们经常看到相关的优化文章,主要就是降低应用的 inuse_space。 而两个对象数指标主要是为 GC 优化提供依据,当我们进行 GC 调优时,会同时关注应用分配的对象数、正在使用的对象数,以及 GC 的 CPU 占用的指标。 GC 的 CPU 占用情况可以由内置的 CPU profiler 得到。 cpu profiler
Go 语言内置的 CPU profiler 使用 setitimer 系统调用,操作系统会每秒 100 次向程序发送 SIGPROF 信号。在 Go 进程中会选择随机的信号执行 sigtrampgo 函数。该函数使用 sigprof 或 sigprofNonGo 来记录线程当前的栈。
Go 语言内置的 cpu profiler 是在性能领域比较常见的 On-CPU profiler,对于瓶颈主要在 CPU 消耗的应用,我们使用内置的 profiler 也就足够了。 如果碰到的问题是应用的 CPU 使用不高,但接口的延迟却很大,那么就需要用上 Off-CPU profiler,遗憾的是官方的 profiler 并未提供该功能,我们需要借助社区的 fgprof。 fgprof
fgprof 是启动了一个后台的 goroutine,每秒启动 99 次,调用 runtime.GoroutineProfile 来采集所有 gorooutine 的栈。 虽然看起来很美好:
但调用 GoroutineProfile 函数的开销并不低,如果线上系统的 goroutine 上万,每次采集 profile 都遍历上万个 goroutine 的成本实在是太高了。所以 fgprof 只适合在测试环境中使用。 trace一般情况下我们是不需要使用 trace 来定位性能问题的,通过压测 + profile 就可以解决大部分问题,除非我们的问题与 runtime 本身的问题相关。 比如 STW 时间比预想中长,超过百毫秒,向官方反馈问题时,才需要出具相关的 trace 文件。比如类似 long stw[4] 这样的 issue。 采集 trace 对系统的性能影响还是比较大的,即使我们只是开启 gctrace,把 gctrace 日志重定向到文件,对系统延迟也会有一定影响,因为 gctrace 的日志 print 是在 stw 期间来做的:gc trace 阻塞调度[5]。 perf如果应用没有开启 pprof,在线上应急时,我们也可以临时使用 perf: 微观性能优化编写 library 时会关注关键函数的性能,这时可以脱离系统去探讨性能优化,Go 语言的 test 子命令集成了相关的功能,只要我们按照约定来写 Benchmark 前缀的测试函数,就可以实现函数级的基准测试。我们以常见的二维数组遍历为例: package mainimport 'testing'var x = make([][]int, 100)func init() { for i := 0; i < 100; i++ { x[i] = make([]int, 100) }}func traverseVertical() { for i := 0; i < 100; i++ { for j := 0; j < 100; j++ { x[j][i] = 1 } }}func traverseHorizontal() { for i := 0; i < 100; i++ { for j := 0; j < 100; j++ { x[i][j] = 1 } }}func BenchmarkHorizontal(b *testing.B) { for i := 0; i < b.N; i++ { traverseHorizontal() }}func BenchmarkVertical(b *testing.B) { for i := 0; i < b.N; i++ { traverseVertical() }} 执行 go test -bench=.
可见横向遍历数组要快得多,这提醒我们在写代码时要考虑 CPU 的 cache 设计及局部性原理,以使程序能够在相同的逻辑下获得更好的性能。 除了 CPU 优化,我们还经常会碰到要优化内存分配的场景。只要带上 -benchmem 的 flag 就可以实现了。 举个例子,形如下面这样的代码: logStr := 'userid :' + userID + '; orderid:' + orderID 你觉得代码写的很难看,想要优化一下可读性,就改成了下列代码: logStr := fmt.Sprintf('userid: %v; orderid: %v', userID, orderID) 这样的修改方式在某公司的系统中曾经导致了 p2 事故,上线后接口的超时俱增至 SLA 承诺以上。 我们简单验证就可以发现: BenchmarkPrin-12 7168467 157 ns/op 64 B/op 3 allocs/opBenchmarkPlus -12 43278558 26.7 ns/op 0 B/op 0 allocs/op 使用 + 进行字符串拼接,不会在堆上产生额外对象。而使用 fmt 系列函数,则会造成局部对象逃逸到堆上,这里是高频路径上有大量逃逸,所以导致线上服务的 GC 压力加重,大量接口超时。 出于谨慎考虑,修改高并发接口时,拿不准的尽量都应进行简单的线下 benchmark 测试。 当然,我们不能指望靠写一大堆 benchmark 帮我们发现系统的瓶颈。 实际工作中还是要使用前文提到的优化工作流来进行系统性能优化。也就是尽量从接口整体而非函数局部考虑去发现与解决瓶颈。 宏观性能优化接口类的服务,我们可以使用两种方式对其进行压测:
压测过程中需要采集不同 QPS 下的 CPU profile,内存 profile,记录 goroutine 数。与历史情况进行 AB 对比。 Go 的 pprof 还提供了 --base 的 flag,能够很直观地帮我们发现不同版本之间的指标差异:用 pprof 比较内存使用差异[6]。 总之记住一点,接口的性能一定是通过压测来进行优化的,而不是通过硬啃代码找瓶颈点。关键路径的简单修改往往可以带来巨大收益。如果只是啃代码,很有可能将 1% 优化到 0%,优化了 100% 的局部性能,对接口整体影响微乎其微。 寻找性能瓶颈在压测时,我们通过以下步骤来逐渐提升接口的整体性能:
重复上述步骤,直至系统性能达到或超越我们设置的性能目标。 一些优化案例gc mark 占用过多 CPU在 Go 语言中 gc mark 占用的 CPU 主要和运行时的对象数相关,也就是我们需要看 inuse_objects。 定时任务,或访问流量不规律的应用,需要关注 alloc_objects。 优化主要是下面几方面: 减少变量逃逸尽量在栈上分配对象,关于逃逸的规则,可以查看 Go 编译器代码中的逃逸测试部分: 查看某个 package 内的逃逸情况,可以使用 build + 全路径的方式,如: go build -gcflags='-m -m' github.com/cch123/elasticsql 需要注意的是,逃逸分析的结果是会随着版本变化的,所以去背诵网上逃逸相关的文章结论是没有什么意义的。 使用 sync.Pool 复用堆上对象sync.Pool 用出花儿的就是 fasthttp 了,可以看看我之前写的这一篇:fasthttp 为什么快[7]。 最简单的复用就是复用各种 struct,slice,在复用时 put 时,需要判断 size 是否已经扩容过头,小心因为 sync.Pool 中存了大量的巨型对象导致进程占用了大量内存。 调度占用过多 CPUgoroutine 频繁创建与销毁会给调度造成较大的负担,如果我们发现 CPU 火焰图中 schedule,findrunnable 占用了大量 CPU,那么可以考虑使用开源的 workerpool 来进行改进,比较典型的 fasthttp worker pool[8]。 如果客户端与服务端之间使用的是短连接,那么我们可以使用长连接来减少连接创建的开销,这里就包含了 goroutine 的创建与销毁。 进程占用大量内存当前大多数的业务后端服务是不太需要关注进程消耗的内存的。 我们经常看到做 Go 内存占用优化的是在网关(包括 mesh)、存储系统这两个场景。 对于网关类系统来说,Go 的内存占用主要是因为 Go 独特的抽象模型造成的,这个很好理解: 海量的连接加上海量的 goroutine,使网关和 mesh 成为 Go OOM 的重灾区。所以网关侧的优化一般就是优化:
很多项目都有相关的分享,这里就不再赘述了。 对于存储类系统来说,内存占用方面的不少努力也是在优化各种 buffer,比如 dgraph 使用 cgo + jemalloc 来优化他们的产品内存占用[9]。 堆外内存不会在 Go 的 GC 系统里进行管辖,所以也不会影响到 Go 的 GC Heap Goal,所以不会因为分配大量对象造成 Go 的 Heap Goal 被推高,系统整体占用的 RSS 也被推高。 锁冲突严重,导致吞吐量瓶颈我在 几个 Go 系统可能遇到的锁问题[10] 中分享过实际的线上 case。 进行锁优化的思路无非就一个“拆”和一个“缩”字:
timer 相关函数占用大量 CPU同样是在网关和海量连接的应用中较常见,优化手段:
模拟真实工作负载在前面的论述中,我们对问题进行了简化。真实世界中的后端系统往往不只一个接口,压测工具、平台往往只支持单接口压测。 公司的业务希望知道的是后端系统整体性能,即这些系统作为一个整体,在限定的资源条件下,能够承载多少业务量(如并发创建订单)而不崩溃。 虽然大家都在讲微服务,但单一服务往往也不只有单一功能,如果一个系统有 10 个接口(已经算是很小的服务了),那么这个服务的真实负载是很难靠人肉去模拟的。 这也就是为什么互联网公司普遍都需要做全链路压测。像样点的公司会定期进行全链路压测演练,以便知晓随着系统快速迭代变化,系统整体是否出现了严重的性能衰退。 通过真实的工作负载,我们才能发现真实的线上性能问题。讲全链路压测的文章也很多,本文就不再赘述了。 当前性能问题定位工具的局限性本文中几乎所有优化手段都是通过 Benchmark 和压测来进行的,但真实世界的软件还会有下列场景:
这些问题在压测中是发现不了的,需要有更为灵活的工具和更为强大的平台,关于这些问题,我将在 4 月 10 日的武汉 Gopher Meetup 上进行分享,欢迎关注。 参考链接: [1]latency numbers every programmer should know: https://colin-scott./personal_website/research/interactive_latency.html |
|
来自: 菌心说 > 《编程+、计算机、信息技术》