前言

从实习到正式工作,我使用 Golang 作为主力编程语言也已经有两年多的时间了;绝大多数的服务和需求我都会选择使用 Golang 实现,只有对性能不敏感、需要大量文本处理 / 数据处理的场景下我才会选择我的老相好 Python

Golang是一门简单的语言,最为大家所推崇的莫过于 Go 的并发,协程加信道,sync 加 select,我觉得很难再有那么一门语言,并发能够做得像 Go 一样简单;其开发者团队一直秉承着“大道至简”的设计理念,但那些存在于各个角落的“简单”特性也让实际使用者们又爱又恨

这两年是我吸收知识最快的一段时间,加上工作上的忙碌,以至于我攒下了许多“要写的文章”,偶尔发出来的文章也是零零散散的一些知识点。我还是希望自己能够希望能够将这些知识真正的体系化起来,不断地完善,而不是仅仅讲零散的知识点打上 tag

那么这个系列我们就来聊一聊我们在Golang日常开发中就可以直接用到的一些简单的性能优化

需要了解的词

  • L1, L2, L3缓存
    L1, L2, L3缓存都是计算机中的高速缓存,是计算机中的快速存储器,它们保存着CPU经常使用的数据,具体区别如下图:

    Difference Between L1 L2 and L3 Cache - Comparison Summary

  • benchmark

    性能的衡量基准,常常用到 ms/op, MB/op, allocs/op 等指标

  • pprof
    pprof是一个用于分析 Golang 应用性能数据的可视化和分析工具

为什么要做性能优化

首先,我们来谈一谈为什么要性能优化;这里我们不从理论展开,直接 showcase:

在这个降本增效的大环境下,我们首先直接从💰的角度来说:如果业务的后端服务规模足够大,那么一个程序员通过优化帮公司节省的成本,或许就可以负担他十年的工资了

再具体一点,将视线转移到我们的 K8S 集群,我们为所有的 deployment 设置了资源限制,有时我们会看到我们的一些 pod 正在重启——我们的OOM-killer 正在为我们“处理问题”,解决了我们的内存泄漏问题;当然,OOM 并不一定指向内存泄露,更通常指向常见的资源不足

不必要的资源消耗正在蚕食我们的钱包,我们必须做点什么!

那么现在我们来谈谈性能优化,之所以有性能优化这个步骤,是因为过早的优化 / 过度设计是无意义的,如果不从整个应用层面来看,我们无法知道某个子模块或是某个代码片段是否会成为性能瓶颈;我们需要的是快速构建出应用Demo,再来从应用层面考虑和测试其性能瓶颈

此外,每一次性能优化都得是合理的,有依据的;which means 每一次性能优化都得建立在完整建设的 benchmark 的基础上,这样才能量化其为我们带来的利益(以及确保自己没有写出负优化)

并且我们也不能忽略一件事情:大多数优化都会使代码的可读性 / 可维护性变差,我们还是需要把控好这个平衡

如何在全局看如何做优化

我认为从全局来看,首先我们在上一小节明确了,做优化就是为了省💰,把💰真正的花在刀刃上;具体到应用层面,则是为了解决性能瓶颈,保证我们的资源都得到合理的负载,才能最大化使用资源

那么我们再转换到资源的视角,从资源视角出发,我们需要审视CPU、内存、磁盘与网络这四个对于后台服务来说最重要的四种资源

对于计算密集型的程序来说,优化的主要精力会放在 CPU 上,要知道 CPU 基本的流水线概念,知道怎么样在使用少的 CPU 资源的情况下,达到相同的计算目标

对于 IO 密集型的程序(后端服务一般都是 IO 密集型)来说,优化可以是降低程序的服务延迟,也可以是提升系统整体的吞吐量

IO 密集型应用主要与磁盘、内存、网络打交道。因此我们需要知道一些基本的与磁盘、内存、网络相关的基本数据与常见概念:

  • 要了解内存的多级存储结构:L1,L2,L3,主存,还要对这些不同层级的存储操作时的大致延迟有基本的概念和意识:latency numbers every programmer should know
  • 要知道基本的文件系统读写 syscall,批量 syscall,数据同步 syscall,如果大家有去看过一些 pprof 的 gragh view 的话,就会发现往往大多数底层应用库的性能消耗都是在 syscall
  • 要熟悉项目中使用的网络协议,至少要对 TCP, HTTP 有所了解

性能优化的黄金准则

在我看来,性能优化的一条最重要的准则是:

优化越靠近应用层效果越好

Performance tuning is most effective when done closest to where the work is performed. For workloads driven by applications, this means within the application itself.

这也很好理解,我们在应用层的逻辑优化能够帮助应用提升几十倍的性能,而最底层的优化可能也就只能提升几个百分点了,因为应用层是对底层的调用,自上而下的优化自然是效率更高的

这里也有一个广为人知的例子:来自GTA Online 的新闻——rockstar thanks gta online player who fixed poor load times

简单来说,GTA online 的游戏启动过程让玩家等待时间过于漫长,经过各种工具分析,发现一个 10M 的文件加载就需要几十秒,用户 diy 进行优化之后,将加载时间减少 70%,并分享出来:how I cut GTA Online loading times by 70%

这就是一个非常典型的案例,GTA 在商业上取得了巨大的成功,但不妨碍它局部的代码是a piece of shit。我们只要把这里的重复逻辑干掉,就可以完成三倍的优化效果。同样的案例,如果我们去优化磁盘的读写速度,则可能收效甚微

优化的一般流程

我认为对于一个典型的 API 应用来说,优化工作基本遵从下面的工作流:

  1. 建立评估指标,例如固定 QPS 压力下的延迟或内存占用,或模块在满足 SLA 前提下的极限 QPS
  2. 通过自研、开源压测工具进行压测,直到模块无法满足预设性能要求:如大量超时,QPS 不达预期,OOM
  3. 通过内置 profile 工具寻找性能瓶颈
  4. 本地 benchmark 证明优化效果
  5. 集成 patch 到业务模块,回到 2

自下而上的性能优化

在编写一些核心组件 / library 组件时,我们会关注关键的函数性能,这时可以脱离系统自下而上地去探讨性能优化,Go 语言的 test 子命令集成了相关的功能,只要我们按照约定来写 Benchmark 前缀的测试函数,就可以实现函数级的基准测试,如下面的横向数组遍历和纵向数组遍历的基准测试对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import "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=. 输出为:

1
2
BenchmarkHorizontal-12    	  102368	     10916 ns/op
BenchmarkVertical-12 66612 18197 ns/op

可见横向遍历数组要快得多,这提醒我们在写代码时要考虑 CPU 的 cache 设计及局部性原理,以使程序能够在相同的逻辑下获得更好的性能

除了 CPU 优化,我们还经常会碰到要优化内存分配的场景。只要带上 -benchmem 的 flag 就可以实现了

出于谨慎考虑,修改高并发接口时,拿不准的尽量都应进行简单的线下 benchmark 测试;当然,我们不能指望靠写一大堆 benchmark 帮我们发现系统的瓶颈,实际工作中还是要使用前文提到的优化工作流来进行系统性能优化。也就是尽量从接口整体而非函数局部考虑去发现与解决瓶颈

自上而下的性能优化

从整个服务的视角来看,如常见的 API 服务,我们可以使用两种方式对其进行压测:

  • 固定 QPS 压测:在每次系统有大的特性发布时,都应进行固定 QPS 压测,与历史版本进行对比,需要关注的指标包括,相同 QPS 下的系统的 CPU 使用情况,内存占用情况(监控中的 RSS 值,即包括内存碎片),goroutine 数,GC 触发频率和相关指标(是否有较长的 stw,mark 阶段是否时间较长等),平均延迟,p99 延迟
  • 极限 QPS 压测:极限 QPS 压测一般只是为了 benchmark show,没有太大意义。系统满负荷时,基本 p99 已经超出正常用户的忍受范围了

压测过程中需要采集不同 QPS 下的 CPU profile,内存 profile,记录 goroutine 数。与历史情况进行 AB 对比

Go 的 pprof 还提供了 --base 的 flag,能够很直观地帮我们发现不同版本之间的指标差异:用 pprof 比较内存使用差异

总之记住一点,接口的性能一定是通过压测来进行优化的,而不是通过硬啃代码找瓶颈点。关键路径的简单修改往往可以带来巨大收益。如果只是啃代码,很有可能将 1% 优化到 0%,优化了 100% 的局部性能,对接口整体影响微乎其微

寻找性能瓶颈

在压测时,我们通过以下步骤来逐渐提升接口的整体性能:

  1. 使用固定 QPS 压测,以阶梯形式逐渐增加压测 QPS,如 1000 -> 每分钟增加 1000 QPS
  2. 压测过程中观察系统的延迟是否异常
  3. 观察系统的 CPU 使用情况
  4. 如果 CPU 使用率在达到一定值之后不再上升,反而引起了延迟的剧烈波动,这时大概率是发生了阻塞,进入 pprof 的 web 页面,点击 goroutine,查看 top 的 goroutine 数,这时应该有大量的 goroutine 阻塞在某处,比如 Semacquire
  5. 如果 CPU 上升较快,未达到预期吞吐就已经过了高水位,则可以重点考察 CPU 使用是否合理,在 CPU 高水位进行 profile 采样,重点关注火焰图中较宽的“平顶山”

挖坑

说完了性能优化本身,最后我们来为 Golang 中真正的实用优化点基于 Golang 中的基本概念分个类,也是为本系列文章先挖好坑:

  • 数组和切片 Done
  • 字符串 Done
  • 结构体
  • 函数
  • 映射表
  • 接口
  • 指针
  • Goroutine
  • 通道(channel)
  • 边界检测消除
  • gc mark / 变量逃逸