Go程序性能优化

Go 应用性能优化

  1. 减少变量逃逸,尽量使用局部变量,在栈上分配对象

  2. 切片等结构提前分配足够容量

  3. 减少对象分配数量,复用对象;或使用 sync.Pool

  4. 减少协程数量,复用协程处理多个任务

  5. 减小锁粒度,减少锁竞争

  6. pprof 分析程序性能瓶颈

寻找性能瓶颈

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

  1. 使用固定 QPS 压测,以阶梯形式逐渐增加压测 QPS,如 1000 -> 每分钟增加 1000 QPS

  2. 压测过程中观察系统的延迟是否异常

  3. 观察系统的 CPU 使用情况

  4. 如果 CPU 使用率在达到一定值之后不再上升,反而引起了延迟的剧烈波动,这时大概率是发生了阻塞,进入 pprof 的 web 页面,点击 goroutine,查看 top 的 goroutine 数,这时应该有大量的 goroutine 阻塞在某处,比如 Semacquire

  5. 如果 CPU 上升较快,未达到预期吞吐就已经过了高水位,则可以重点考察 CPU 使用是否合理,在 CPU 高水位进行 profile 采样,重点关注火焰图中较宽的“平顶山”

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

一些优化案例

gc mark 占用过多 CPU

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

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

优化主要是下面几方面:

1. 减少变量逃逸

尽量在栈上分配对象

2. 使用 sync.Pool 复用堆上对象

sync.Pool 用出花儿的就是 fasthttp 了,可以看看我之前写的这一篇:fasthttp 为什么快。

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

调度占用过多 CPU

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

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

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

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

  • 拆:将锁粒度进行拆分,比如全局锁,我能不能把锁粒度拆分为连接粒度的锁;如果是连接粒度的锁,那我能不能拆分为请求粒度的锁;在 logger fd 或 net fd 上加的锁不太好拆,那么我们增加一些客户端,比如从 1-> 100,降低锁的冲突是不是就可以了。

  • 缩:缩小锁的临界区,业务允许的前提下,可以把 syscall 移到锁外面;有时只是想要锁 map 的读写逻辑,但是却不小心锁了连接读写的逻辑,或许简单地用 sync.Map 来代替 map Lock,defer Unlock 就能简单地缩小临界区了。

timer 相关函数占用大量 CPU

同样是在网关和海量连接的应用中较常见,优化手段:

  • 使用时间轮/粗粒度的时间管理,精确到 ms 级一般就足够了

  • 升级到 Go 1.14+,享受官方的升级红利

模拟真实工作负载

在前面的论述中,我们对问题进行了简化。真实世界中的后端系统往往不只一个接口,压测工具、平台往往只支持单接口压测。

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

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

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

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

参考

曹春晖 - Go 应用性能优化指北

Last updated