为什么要做优化

这是一个速度决定一切的时代,我们的生活在不断地数字化,线下的流程依然在持续向线上转移,转移过程中,作为工程师,我们会碰到各种各样的性能问题。

互联网公司本质是将用户共通的行为流程进行了集中化管理,通过中心化的信息交换达到效率提升的目的,同时用规模效应降低了数据交换的成本。

用人话来讲,公司希望的是用尽量少的机器成本来赚取尽量多的利润。利润的提升与业务逻辑本身相关,与技术关系不大。而降低成本则是与业务无关,纯粹的技术话题。这里面最重要的主题就是“性能优化”。

如果业务的后端服务规模足够大,那么一个程序员通过优化帮公司节省的成本,就可以负担他十年的工资了。

优化的前置知识

从资源视角出发来对一台服务器进行审视的话,CPU、内存、磁盘与网络是后端服务最需要关注的四种资源类型。

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

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

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

  • 要了解内存的多级存储结构:L1,L2,L3,主存。还要知道这些不同层级的存储操作时的大致延迟:latency numbers every programmer should know。
  • 要知道基本的文件系统读写 syscall,批量 syscall,数据同步 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[2]。

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

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

我们的性能优化主要聚焦在应用、Go 标准库、Go runtime。

大多数优化集中在应用代码 极少部分在标准库和 runtime.

优化是与业务场景相关的

不同的业务场景优化的侧重也是不同的。

对于大多数无状态业务模块来说,内存一般不是瓶颈,所以业务 API 的优化主要聚焦于延迟和吞吐。对于网关类的应用,因为有海量的连接,除了延迟和吞吐,内存占用可能就会成为一个关注的重点。对于存储类应用,内存是个逃不掉的瓶颈点。

在关注一些性能优化文章时,我们也应特别留意作者的业务场景。场景的侧重可能会让某些人去选择使用更为 hack 的手段进行优化,而 hack 往往也就意味着 bug。如果你选择了少有人走过的路,那你要面临的也是少有人会碰到的 bug。解决起来令人头疼。

优化的工作流程

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

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

可以使用的工具

pprof

memory profiler

Go 内置的内存 profiler 可以让我们对线上系统进行内存使用采样,有四个相应的指标:

  • inuse_objects:当我们认为内存中的驻留对象过多时,就会关注该指标
  • inuse_space:当我们认为应用程序占据的 RSS 过大时,会关注该指标
  • alloc_objects:当应用曾经发生过历史上的大量内存分配行为导致 CPU 或内存使用大幅上升时,可能关注该指标
  • alloc_space:当应用历史上发生过内存使用大量上升时,会关注该指标

网关类应用因为海量连接的关系,会导致进程消耗大量内存,所以我们经常看到相关的优化文章,主要就是降低应用的 inuse_space。

而两个对象数指标主要是为 GC 优化提供依据,当我们进行 GC 调优时,会同时关注应用分配的对象数、正在使用的对象数,以及 GC 的 CPU 占用的指标。

GC 的 CPU 占用情况可以由内置的 CPU profiler 得到。

cpu profiler

The builtin Go CPU profiler uses the setitimer(2) system call to ask the operating system to be sent a SIGPROF signal 100 times a second. Each signal stops the Go process and gets delivered to a random thread’s sigtrampgo() function. This function then proceeds to call sigprof() or sigprofNonGo() to record the thread’s current stack.

Go 语言内置的 CPU profiler 使用 setitimer 系统调用,操作系统会每秒 100 次向程序发送 SIGPROF 信号。在 Go 进程中会选择随机的线程执行 sigtrampgo 函数。该函数使用 sigprof 或 sigprofNonGo 来记录线程当前的栈。

Since Go uses non-blocking I/O, Goroutines that wait on I/O are parked and not running on any threads. Therefore they end up being largely invisible to Go’s builtin CPU profiler.

Go 语言内置的 cpu profiler 是在性能领域比较常见的 On-CPU profiler,对于瓶颈主要在 CPU 消耗的应用,我们使用内置的 profiler 也就足够了。

如果碰到的问题是应用的 CPU 使用不高,但接口的延迟却很大,那么就需要用上 Off-CPU profiler,遗憾的是官方的 profiler 并未提供该功能,我们需要借助社区的 fgprof。

fgprof

fgprof is implemented as a background goroutine that wakes up 99 times per second and calls runtime.GoroutineProfile. This returns a list of all goroutines regardless of their current On/Off CPU scheduling status and their call stacks.

fgprof 是启动了一个后台的 goroutine,每秒启动 99 次,调用 runtime.GoroutineProfile 来采集所有 gorooutine 的栈。

虽然看起来很美好:

 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
func GoroutineProfile(p []StackRecord) (n int, ok bool) {
    .....
 stopTheWorld("profile")

 for _, gp1 := range allgs {
  ......
 }

 if n <= len(p) {
  // Save current goroutine.
  ........
  systemstack(func() {
   saveg(pc, sp, gp, &r[0])
  })

  // Save other goroutines.
  for _, gp1 := range allgs {
   if isOK(gp1) {
    .......
    saveg(^uintptr(0), ^uintptr(0), gp1, &r[0])
                .......
   }
  }
 }

 startTheWorld()

 return n, ok
}

但调用 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 前缀的测试函数,就可以实现函数级的基准测试。

逃逸分析

Go 可以自动的管理内存,这帮我们避免了大量潜在 bug,但它并没有将程序员彻底的从内存分配的事情上解脱出来。因为 Go 没有提供直接操作内存的方式,所以开发者必须要搞懂其内部机制,这样才能将收益最大化。

如果读了这篇文章后,你只能记住一点,那请记住这个:栈分配廉价,堆分配昂贵。现在让我们深入讲述下这是什么意思。

  • Go 有两个地方可以分配内存:一个全局堆空间用来动态分配内存,另一个是每个 goroutine 都有的自身栈空间。
  • Go 更倾向于在栈空间上分配内存 —— 一个 Go 程序大部分的内存分配都是在栈空间上的。它的代价很低,因为只需要两个 CPU 指令:一个是把数据 push 到栈空间上以完成分配,另一个是从栈空间上释放。

不幸的是, 不是所有的内存都可以在栈空间上分配的。栈空间分配要求一个变量的生命周期和内存足迹能在编译时确定。 否则就需要在运行时在堆空间上进行动态分配。

malloc 必须找到一块足够大的内存来存放新的变量数据。后续释放时,垃圾回收器扫描堆空间寻找不再被使用的对象。

不用多说,这明显要比只需两个指令的栈分配更加昂贵。

译者注: 内存足迹, 代表和一个变量相关的所有内存块。比如一个 struct 中含有成员 *int, 那么这个*int 所指向的内存块属于该 struct 的足迹。

编译器使用逃逸分析的技术来在这两者间做选择。基本的思路就是在编译时做垃圾回收的工作。

编译器会追踪变量在代码块上的作用域。变量会携带有一组校验数据,用来证明它的整个生命周期是否在运行时完全可知。如果变量通过了这些校验,它就可以在栈上分配。否则就说它 逃逸 了,必须在堆上分配。

逃逸分析的机制,并没有在 Go 语言官方说明上阐述。对 Go 程序员来说,学习这些规则最有效的方式就是凭经验。编译命令 go build -gcflags '-m' 会让编译器在编译时输出逃逸分析的结果。

让我们来看一个例子:

1
2
3
4
5
6
7
8
package main

import "fmt"

func main() {
        x := 42
        fmt.Println(x)
}
1
2
3
4
5
6
$ go build -gcflags '-m' ./main.go

# command-line-arguments

./main.go:7: x escapes to heap
./main.go:7: main ... argument does not escape

我们看到 x escapes to heap, 表示它会在运行时在堆空间上动态分配。 这个例子让人有些费解,直觉上,很明显变量 x 并没有逃出 main() 函数之外。 编译器没有说明它为什么认为这个变量逃逸了。为得到更详细的内容,多传几个 -m 参数给编译器,会打印出更详细的内容。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$ go build -gcflags '-m -m' ./main.go

# command-line-arguments

./main.go:5: cannot inline main: non-leaf function
./main.go:7: x escapes to heap
./main.go:7:         from ... argument (arg to ...) at ./main.go:7
./main.go:7:         from *(... argument) (indirection) at ./main.go:7
./main.go:7:         from ... argument (passed to call[argument content escapes]) at ./main.go:7
./main.go:7: main ... argument does not escape

是的,上面显示了,变量 x 之所以逃逸了,是因为它被传入了一个逃逸的函数内。

这个机制乍看上去有些难以捉摸,但多用几次这个工具后,就能搞明白这其中的规律了。长话短说,下面是一些我们找到的,能引起变量逃逸到堆上的典型情况:

  • 发送指针或带有指针的值到 channel 中。在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。

  • 在一个切片上存储指针或带指针的值。一个典型的例子就是 []*string。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。

  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量(cap)。slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。

  • 在 interface 类型上调用方法。在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r, 调用 r.Read(b) 会使得 r 的值和切片 b 的背后存储都逃逸掉,所以会在堆上分配。

用户声明的对象,被放在栈上还是堆上, 是由编译器的 escape analysis 来决定的

string 类型天然就是带指针的类型,比如一些 cache 服务,有几千万 entry,那么用 string 来做 key 和 value 可能成本就很高。

减少指针的手段:

用值类型代替指针类型,比如:

1
2
3
4
*int -> struct {value int, isNull bool}
string -> struct {value [12]byte, length int}
数值类型的 string -> int
*Host -> Host

减少逃逸的手段

  • 尽量少用 fmt.Printfmt.Sprint 系列的函数。

  • 设计函数签名时,参数尽量少用 interface

  • 少用闭包,被闭包引用的局部变量会逃逸到堆上

不过这些也就说说而已,真的每一条都遵循怕是写代码的时候已经疯了。况且 Go 的 defer 只能在函数作用域内运作,为了避免 panic 死锁,很多时候套个闭包的操作还是比较常见的。

map 结构的 128 阈值

  • key > 128 字节时,indirectkey = true
  • value > 128 字节时,indirectvalue = true

指针

一个经验是:指针指向的数据都是在堆上分配的。因此,在程序中减少指针的运用可以减少堆分配。这不是绝对的,但是我们发现这是在实际问题中最常见的问题。

一般情况下我们会这样认为:“值的拷贝是昂贵的,所以用一个指针来代替。” 但是,在很多情况下,直接的值拷贝要比使用指针廉价的多。你可能要问为什么。

  • 编译器会在解除指针时做检查。目的是在指针是 nil 的情况下直接 panic() 以避免内存泄露。这就必须在运行时执行更多的代码。如果数据是按值传递的,那就不需要做这些了,它不可能是 nil

  • 指针通常有糟糕的局部引用。一个函数内部的所有值都会在栈空间上分配。局部引用是编写高效代码的重要环节。它会使得变量数据在 CPU Cache(cpu 的一级二级缓存) 中的热度更高,进而减少指令预取时 Cache 不命中的的几率。

  • 在 Cache 层拷贝一堆对象,可粗略地认为和拷贝一个指针效率是一样的。CPU 在各 Cache 层和主内存中以固定大小的 cache 进行内存移动。x86 机器上是 64 字节。而且,Go 使用了Duff’s device 技术来使得常规内存操作变得更高效。

指针应该主要被用来做映射数据的所有权和可变性的。实际项目中,用指针来避免拷贝的方式应该尽量少用。

不要掉进过早优化的陷阱。养成一个按值传递的习惯,只在需要的时候用指针传递。另一个好处就是可以较少 nil 带来的安全问题。

减少程序中指针的使用的另一个好处是,如果可以证明它里面没有指针,垃圾回收器会直接越过这块内存。例如,一块作为 []byte 背后存储的堆上内存,是不需要进行扫描的。对于那些不包含指针的数组和 struct 数据类型也是一样的。

译者注: 垃圾回收器回收一个变量时,要检查该类型里是否有指针。如果有,要检查指针所指向的内存是否可被回收,进而才能决定这个变量能否被回收。如此递归下去。如果被回收的变量里面没有指针, 就不需要进去递归扫描了,直接回收掉就行。

减少指针的使用不仅可以降低垃圾回收的工作量,它会产生对 cache 更加友好的代码。读内存是要把数据从主内存读到 CPU 的 cache 中。 Cache 的空间是有限的,所以其他的数据必须被抹掉,好腾出空间。 被抹掉的数据很可能程序的另外一部分相关。 由此产生的 cache 抖动会引起线上服务的一些意外的和突然的抖动。

减少指针的使用就意味着要深入我们自定义的数据类型。我们的一个服务,用带有一组数据结构的循环 buffer 构建了一个失败操作的队列好做重试;它大致是这个样子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type retryQueue struct {
    buckets       [][]retryItem // each bucket represents a 1 second interval
    currentTime   time.Time
    currentOffset int
}

type retryItem struct {
    id   ksuid.KSUID // ID of the item to retry
    time time.Time   // exact time at which the item has to be retried
}

buckets 中外面的数组大小是固定的, 但是 []retryItemitem 的数量是在运行时变化的。重试次数越多, 切片增长的越大。

挖掘一下 retryItem 的具体实现,我们发现 KSUID 是 [20]byte 的别名, 里面没有指针,所以可以排除。currentOffset 是一个 int 类型, 也是固定长度的,故也可排除。接下来,看一下 time.Time 的实现:

1
2
3
4
5
type Time struct {
    sec  int64
    nsec int32
    loc  *Location // pointer to the time zone structure
}

time.Time 的结构体中包含了一个指针成员 loc。在 retryItem 中使用它会导致 GC 每次经过堆上的这块区域时。

都要去追踪到结构体里面的指针。

我们发现,这个案例很典型。 在正常运行期间失败情况很少。 只有少量内存用于存储重试操作。 当失败突然飙升时,重试队列中的对象数量每秒增长好几千,从而对垃圾回收器增加很多压力。

在这种情况下,time.Time 中的时区信息不是必要的。这些保存在内存中的时间截从来不会被序列化。所以可以重写这个数据结构来避免这种情况:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type retryItem struct {
    id   ksuid.KSUID
    nsec uint32
    sec  int64
}

func (item *retryItem) time() time.Time {
    return time.Unix(item.sec, int64(item.nsec))
}

func makeRetryItem(id ksuid.KSUID, time time.Time) retryItem {
    return retryItem{
        id:   id,
        nsec: uint32(time.Nanosecond()),
        sec:  time.Unix(),
}

注意现在的 retryItem 不包含任何指针。这大大降低了 gc 压力,因为 retryItem 的整个足迹都可以在编译时知道。

数据分配密集型

让我们举一个简单的例子,说明何时要为使用值而共享结构体:

1
2
3
4
5
type S struct {
   a, b, c int64
   d, e, f string
   g, h, i float64
}

这是一个可以由副本或指针共享的基本结构体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func byCopy() S {
   return S{
      a: 1, b: 1, c: 1,
      e: "foo", f: "foo",
      g: 1.0, h: 1.0, i: 1.0,
   }
}

func byPointer() *S {
   return &S{
      a: 1, b: 1, c: 1,
      e: "foo", f: "foo",
      g: 1.0, h: 1.0, i: 1.0,
   }
}

基于这两种方法,我们现在可以编写两个基准测试,其中一个是通过副本传递结构体的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func BenchmarkMemoryStack(b *testing.B) {
   var s S

   f, err := os.Create("stack.out")
   if err != nil {
      panic(err)
   }
   defer f.Close()

   err = trace.Start(f)
   if err != nil {
      panic(err)
   }

   for i := 0; i < b.N; i++ {
      s = byCopy()
   }

   trace.Stop()

   b.StopTimer()

   _ = fmt.Sprintf("%v", s.a)
}

另一个非常相似,它通过指针传递:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func BenchmarkMemoryHeap(b *testing.B) {
var s*S

   f, err := os.Create("heap.out")
   if err != nil {
      panic(err)
   }
   defer f.Close()

   err = trace.Start(f)
   if err != nil {
      panic(err)
   }

   for i := 0; i < b.N; i++ {
      s = byPointer()
   }

   trace.Stop()

   b.StopTimer()

   _ = fmt.Sprintf("%v", s.a)
}

让我们运行基准测试:

1
2
go test ./... -bench=BenchmarkMemoryHeap -benchmem -run=^$ -count=10 > head.txt && benchstat head.txt
go test ./... -bench=BenchmarkMemoryStack -benchmem -run=^$ -count=10 > stack.txt && benchstat stack.txt

以下是统计数据:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
name          time/op
MemoryHeap-4  75.0ns ± 5%
name          alloc/op
MemoryHeap-4   96.0B ± 0%
name          allocs/op
MemoryHeap-4    1.00 ± 0%
------------------

name           time/op
MemoryStack-4  8.93ns ± 4%
name           alloc/op
MemoryStack-4   0.00B
name           allocs/op
MemoryStack-4    0.00

在这里,使用结构体副本比指针快 8 倍。

为了理解原因,让我们看看追踪生成的图表:

第一张图非常简单。由于没有使用堆,因此没有垃圾收集器,也没有额外的 goroutine。 对于第二张图,使用指针迫使 go 编译器将变量逃逸到堆,由此增大了垃圾回收器的压力。如果我们放大图表,我们可以看到,垃圾回收器占据了进程的重要部分。

在这张图中,我们可以看到,垃圾回收器每隔 4ms 必须工作一次。 如果我们再次缩放,我们可以详细了解正在发生的事情:

蓝色,粉色和红色是垃圾收集器的不同阶段,而棕色的是与堆上的分配相关(在图上标有 “runtime.bgsweep”):

清扫是指回收与堆内存中未标记为使用中的值相关联的内存。当应用程序 Goroutines 尝试在堆内存中分配新值时,会触发此活动。清扫的延迟被添加到在堆内存中执行分配的成本中,并且与垃圾收集相关的任何延迟没有关系。

即使这个例子有点极端,我们也可以看到,与栈相比,在堆上为变量分配内存是多么消耗资源。在我们的示例中,与在堆上分配内存并共享指针相比,代码在栈上分配结构体并复制副本要快得多。

如果你不熟悉堆栈或堆,如果你想更多地了解栈或堆的内部细节,你可以在网上找到很多资源,比如 Paul Gribble 的这篇文章。

如果我们使用 GOMAXPROCS = 1 将处理器限制为 1,情况会更糟:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
name        time/op
MemoryHeap  114ns ± 4%
name        alloc/op
MemoryHeap  96.0B ± 0%
name        allocs/op
MemoryHeap   1.00 ± 0%
------------------

name         time/op
MemoryStack  8.77ns ± 5%
name         alloc/op
MemoryStack   0.00B
name         allocs/op
MemoryStack    0.00

如果栈上分配的基准数据不变,则堆上的基准从 75ns/op 降低到 114ns/op。

方法调用密集型

对于第二个用例,我们将在结构体中添加两个空方法,稍微调整一下我们的基准测试:

1
2
3
func (s S) stack(s1 S) {}

func (s *S) heap(s1*S) {}

在栈上分配的基准测试将创建一个结构体并通过复制副本传递它:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func BenchmarkMemoryStack(b *testing.B) {
   var s S
   var s1 S

   s = byCopy()
   s1 = byCopy()
   for i := 0; i < b.N; i++ {
      for i := 0; i < 1000000; i++  {
         s.stack(s1)
      }
   }
}

堆的基准测试将通过指针传递结构体:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func BenchmarkMemoryHeap(b *testing.B) {
var s*S
   var s1 *S

   s = byPointer()
   s1 = byPointer()
   for i := 0; i < b.N; i++ {
      for i := 0; i < 1000000; i++ {
         s.heap(s1)
      }
   }
}

正如预期的那样,结果现在大不相同:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
name          time/op
MemoryHeap-4  301µs ± 4%
name          alloc/op
MemoryHeap-4  0.00B
name          allocs/op
MemoryHeap-4   0.00
------------------

name           time/op
MemoryStack-4  595µs ± 2%
name           alloc/op
MemoryStack-4  0.00B
name           allocs/op
MemoryStack-4   0.00

字符串拼接

用加号连接,和 Sprintf 差别还是比较大的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func BenchmarkBytesBufferAppend(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var msg bytes.Buffer
		msg.WriteString("userid : " + "1")
		msg.WriteString("location : " + "ab")
	}
}

func BenchmarkBytesBufferAppendSprintf(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var msg bytes.Buffer
		msg.WriteString(fmt.Sprintf("userid : %d", 1))
		msg.WriteString(fmt.Sprintf("location : %s", "ab"))
	}
}

猜猜哪个是 3?

fmt.打印系列大部分会造成变量逃逸(interface 参数)。

Zero Garbage/Allocation

Zero garbage 一般是利用 sync.Pool 来将堆分配完全消灭的手段 在一些 http router 框架中会提这个概念

Gin 的 benchmark 中的 zero allocation

sync.Pool 才能实现 zero garbage。benchmark 中的 0 alloc,其实是因为对象有复用,alloc 平均 < 1。

struct 可以复用(p = Person{},用零值覆盖一次就可以),slice 可以复用(a = a[:0]),但 map 不太好复用(得把所有 kv 全清空才行,成本可能比新建一个还要高)。比如 fasthttp 里,把本来应该是 map 的 header 结构变成了 slice,牺牲一点查询速度,换来了复用的方便。

  • 复用本身可能导致 bug,例如:
  • 拿出时不 Reset,内含脏数据:
  • slice 缩容时,被缩掉对象如果不置 nil,是不会释放的

在 Put 回 Pool 时,不判断大小,导致了进程占内存越来越大(标准库发生过这样的问题,在用户看起来,整个进程占用的内存一直在上涨,像是泄露一样)

第二点可以看下面这张图理解一下:

a = a[:1],如果后面的元素都是指针,都指向了 500MB 的一个大 buffer,没法释放,GC 认为你还是持有引用的。这种情况需要自己先把后面的元素全置为 nil,再缩容。

False Sharing

从 CPU 到内存需要经过多级 cache

我们要同时并发修改 x 和 y 哪一种设计更快?

数组横着遍历,竖着遍历,哪种更快?

宏观性能优化

接口类的服务,我们可以使用两种方式对其进行压测:

  • 固定 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 比较内存使用差异[6]。

总之记住一点,接口的性能一定是通过压测来进行优化的,而不是通过硬啃代码找瓶颈点。关键路径的简单修改往往可以带来巨大收益。如果只是啃代码,很有可能将 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 采样,重点关注火焰图中较宽的“平顶山”

重复上述步骤,直至系统性能达到或超越我们设置的性能目标。

模拟真实工作负载

真实世界中的后端系统往往不只一个接口,压测工具、平台往往只支持单接口压测。

公司的业务希望知道的是后端系统整体性能,即这些系统作为一个整体,在限定的资源条件下,能够承载多少业务量(如并发创建订单)而不崩溃。

虽然大家都在讲微服务,但单一服务往往也不只有单一功能,如果一个系统有 10 个接口(已经算是很小的服务了),那么这个服务的真实负载是很难靠人肉去模拟的。

这也就是为什么互联网公司普遍都需要做全链路压测。像样点的公司会定期进行全链路压测演练,以便知晓随着系统快速迭代变化,系统整体是否出现了严重的性能衰退。

通过真实的工作负载,我们才能发现真实的线上性能问题。讲全链路压测的文章也很多,本文就不再赘述了。

观察指标

业务指标:

服务指标:

  • Goroutine 数,线程数
  • 如果 Goroutine 数很多,那这些 Goroutine 在干什么?
  • GC 频率,gctrace 的内容(线上保存 gctrace 的话,注意硬盘类型),GC 的stw 时间
  • Memstats 中的其它指标:

基本套路

  1. 排除外部问题,例如依赖的上游服务(包括 DB、redis、MQ)延迟 过高,在监控系统中查看
  2. CPU 占用过高 -> 看 CPU profile -> 优化占用 CPU 较多的部分逻 辑
  3. 内存占用过高 -> 看 prometheus,内存 RSS 是多少,goroutine 数量多少,goroutine 栈占用多少 -> 如果 goroutine 不多,那么重点关注 heap profile 中的 inuse -> 定时任务类需要看 alloc
  4. goroutine 数量过多 -> 从 profile 网⻚进去看看 goroutine 都在干什 么 -> 查死锁、阻塞等问题 -> 个别不在意延迟的选择第三方库优化

高频接口滥用外部命令

在线上 exec 命令是非常危险的

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
	"os/exec"
	"testing"

	uuid "github.com/satori/go.uuid"
)

var uu []byte
var u1 uuid.UUID

func BenchmarkUUIDExec(b *testing.B) {
	for i := 0; i < b.N; i++ {
		uu, _ = exec.Command("uuidgen").Output()
	}
}

func BenchmarkUUIDLib(b *testing.B) {
	for i := 0; i < b.N; i++ {
		u1 = uuid.NewV4()
	}
}

锁冲突严重,导致吞吐量瓶颈

进行锁优化的思路无非就一个“拆”和一个“缩”字:

  • 拆:将锁粒度进行拆分,比如全局锁,我能不能把锁粒度拆分为连接粒度的锁;如果是连接粒度的锁,那我能不能拆分为请求粒度的锁;在 logger fd 或 net fd 上加的锁不太好拆,那么我们增加一些客户端,比如从 1-> 100,降低锁的冲突是不是就可以了。
  • 缩:缩小锁的临界区,业务允许的前提下,可以把 syscall 移到锁外面;有时只是想要锁 map 的读写逻辑,但是却不小心锁了连接读写的逻辑,或许简单地用 sync.Map 来代替 map Lock,defer Unlock 就能简单地缩小临界区了。

临界区里有慢操作

性能敏感场合,全局锁,比如 rand 的全局锁。单机 10w+ QPS 即可能触发该瓶颈(和环境以及程序行为有关)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
type lockedSource struct {
	lk  sync.Mutex
	src Source64
}

func (r *lockedSource) Int63() (n int64) {
	r.lk.Lock()
	n = r.src.Int63()
	r.lk.Unlock()
	return
}

func (r *lockedSource) Uint64() (n uint64) {
	r.lk.Lock()
	n = r.src.Uint64()
	r.lk.Unlock()
	return
}

有些开源库设计是一个 struct 对应一个 sync.Pool,这种时候,如果你不对该 struct 进行复用,就会触发 runtime 中的锁冲突:

在后端系统开发中,锁瓶颈是较常⻅的问题,比如文件锁

还有一些公司的 metrics 系统设计,本机上会有 udp 通信

锁瓶颈的一般优化手段

  • 缩小临界区:只锁必须锁的对象,临界区内尽量不放慢操作,如 syscall
  • 降低锁粒度:全局锁 -> 对象锁,全局锁 -> 连接锁,连接锁 -> 请求锁,文 件锁 -> 多个文件各种锁
  • 同步改异步:如同步日志 -> 异步日志,若队列满则丢弃,不阻塞业务逻辑

使用双 buffer/RCU 完全消除读阻塞:

全量更新: 直接替换原 config

部分更新: 先拷⻉原 config 更新 key,然后替换

如果更新可能并发,那么在更新时需要加锁

解决方案

  • map → sync.Map(读多写少)
  • 换用无锁结构,如 lock free queue、stack 等
  • 分段锁
  • copy on write,业务逻辑允许的前提下,在修改时拷贝一份,再修改

CPU使用过高

编解码使用 CPU 过高

通过更换 json 库,就可以提高系统的吞吐量 本质上就是请求的 CPU 使用被优化了 我们可以使用固定 QPS 压测来验证该结论

gc mark 占用过多 CPU

在 Go 语言中 gc mark 占用的 CPU 主要和运行时的对象数相关,也就是我们需要看 inuse_objects。

定时任务,或访问流量不规律的应用,需要关注 alloc_objects。

优化主要是下面几方面:

减少变量逃逸

尽量在栈上分配对象,关于逃逸的规则,可以查看 Go 编译器代码中的逃逸测试部分:

图片

查看某个 package 内的逃逸情况,可以使用 build + 全路径的方式,如:

1
go build -gcflags="-m -m" github.com/cch123/elasticsql

需要注意的是,逃逸分析的结果是会随着版本变化的,所以去背诵网上逃逸相关的文章结论是没有什么意义的。

使用 sync.Pool 复用堆上对象

最简单的复用就是复用各种 struct,slice,在复用时 put 时,需要判断 size 是否已经扩容过头,小心因为 sync.Pool 中存了大量的巨型对象导致进程占用了大量内存。

减少创建goroutine

goroutine 频繁创建与销毁会给调度造成较大的负担,如果我们发现 CPU 火焰图中 schedule,findrunnable 占用了大量 CPU,那么可以考虑使用开源的 workerpool 来进行改进,比较典型的 fasthttp worker pool。

如果客户端与服务端之间使用的是短连接,那么我们可以使用长连接来减少连接创建的开销,这里就包含了 goroutine 的创建与销毁。

堆外内存offheap

如果数据不可变,只作查询,也可以考虑 offheap,但局限性较大。

可以将变化较少的结构放在堆外,通过 cgo 来管理内存,让 GC 发现不了这些对象,也就不会扫描了

Off heap 也可以减少 Go 进程的内存占用和内存使用波动,但要用到 cgo

最近 dgraph 有一篇分享,用 jemalloc 和封装的 cgo 方法,可以把一些 hotpath 上分配的对象放在堆外,这个库的局限是在堆外分配的对象不能引用任何 Go 内部的对象,否则可能破坏 GC 时的引用关系。

理论上一些 QPS 较低,但每次请求很大的系统,或许可以参考这个库,把 buffer 放在堆外。

调度占用过多 CPU

goroutine 频繁创建与销毁会给调度造成较大的负担,如果我们发现 CPU 火焰图中 schedule,findrunnable 占用了大量 CPU,那么可以考虑使用开源的 workerpool 来进行改进.

如果客户端与服务端之间使用的是短连接,那么我们可以使用长连接来减少连接创建的开销,这里就包含了 goroutine 的创建与销毁。

ballast

调大 GOGC: 程序启动阶段 make 一个全局的超大 slice(如 1GB)

这种方式只适合那些内存不紧张,且希望提高整体吞吐量的服务

内存占用过高

当前大多数的业务后端服务是不太需要关注进程消耗的内存的。

我们经常看到做 Go 内存占用优化的是在网关(包括 mesh)、存储系统这两个场景。

对于网关类系统来说,Go 的内存占用主要是因为 Go 独特的抽象模型造成的,这个很好理解:

海量的连接加上海量的 goroutine,使网关和 mesh 成为 Go OOM 的重灾区。所以网关侧的优化一般就是优化:

  • goroutine 占用的栈内存
  • read buffer 和 write buffer 占用的内存

对于存储类系统来说,内存占用方面的不少努力也是在优化各种 buffer,比如 dgraph 使用 cgo + jemalloc 来优化他们的产品内存占用。

堆外内存不会在 Go 的 GC 系统里进行管辖,所以也不会影响到 Go 的 GC Heap Goal,所以不会因为分配大量对象造成 Go 的 Heap Goal 被推高,系统整体占用的 RSS 也被推高。

goroutine占用内存过高

实例分析,TLS 的 write buffer 瓶颈优化过程

goroutine 数量太多导致内存占用高:

这些内存的构成部分:

  1. Goroutine 栈占用的内存(难优化,一条 tcp 连接至少对应一个 goroutine 2. Tcp read buffer 占用的内存(难优化,因为大部分连接阻塞在 read 上,read buffer 基本没有可以释放的时机)
  2. Tcp write buffer 占用的内存(易优化,因为活跃连接不多)

原因:

  1. 你 park 起来的 goroutine还要占内存呢
  2. 阻塞的 read buffer 也很难找到时机释放

在一些不太重视延迟的场景(例如推送系统,抖一下死不了),可以使用下列库进行优化

  • evio
  • gev
  • gnet
  • easygo
  • gaio
  • netpoll

一定要用自己的真实业务场景做压测 不要相信 readme 里的压测数据

总结

CPU 使用太高:

  • 应用逻辑导致
    • JSON 序列化
      • 使用一些优化的 JSON 库替换标准库
      • 使用二进制编码方式替代 JSON 编码
      • 同物理节点通信,使用共享内存 IPC,直接干掉序列化开销
    • MD5 算 hash 成本太高 -> 使用 cityhash,murmurhash
    • 其它应用逻辑 -> 只能 case by case 分析了

GC使用CPU过高:

  • 减少堆上对象分配
    • sync.Pool 进行堆对象重用
    • Map -> slice
    • 指针->非指针对象
    • 多个小对象 -> 合并为一个大对象
  • offheap
  • 降低GC频率
    • 修改 GOGC
    • Make 全局大 slice
  • 调度相关的函数使用 CPU 过高
    • 尝试使用 goroutine pool 减少 goroutine 的创建与销毁
    • 控制最大 goroutine 数量

内存使用过高:

  • 堆内存使用过多
    • sync.Pool 对象复用
    • 为不同大小的对象提供不同大小 level 的 sync.Pool
    • offheap
  • Goroutine 栈占用过多内存
    • 减少 goroutine 数量
      • 如每个连接一读一写 -> 合并为一个连接一个 goroutine
      • Goroutine pool 限制最大 goroutine 数量
      • 使用裸 epoll 库(evio,gev 等)修改网络编程方式(只适用于对延迟不敏感的业务)
    • 通过修改代码,减少函数调用层级(难)

阻塞问题:

  • 上游系统阻塞
    • 让上游赶紧解决!
  • 锁阻塞
    • 减少临界区范围
    • 降低锁粒度
      • Global lock -> sharded lock
      • Global lock -> connection level lock
      • Connection level lock -> request level lock
    • 同步改异步
      • 日志场景:同步日志 -> 异步日志
      • Metrics 上报场景:select -> select+default
    • 个别场景使用双 buffer 完全消灭阻塞

参考

Go:我应该用指针替代结构体的副本吗?

Go 语言性能优化

Go 应用性能优化指北