1. 前言
过往我们一般会压测工具 + Go Pprof 采样来识别性能热点、评估性能优化结果,但是总会出现“明明逻辑优化了,压测数据反而变得更差了”的情况。
探索发现受计算集群实例超卖、业务离线+在线混部、CPU 型号不同、CPU 新旧等因素影响, 云上的「不同实例」或「同一实例不同时间段」性能差异都较大(±30%)。压测数据受其干扰较大,导致无法用性能优化前后的压测数据来进行对照从而明确性能优化成果。
本文主要介绍一种性能优化思路,相较于压测,其会受外部影响更小,结果相对更客观,但是其模拟线上真实流量成本较高。
2. 解决方法
一句话描述:基于 go benchmark + benchstat 构建「性能指标度量反馈循环」系统,使用其持续地牵引着系统不断进行性能优化。
主要流程如下:
| 阶段 | 建设思路 |
|---|---|
| 撰写 Benchmark 代码 | 基于基准分支:根据梳理接口列表、场景场景接口参数,Mock 外部依赖,实现对应 Benchmark 代码并提交 |
| 优化前 Benchmark,采样 Pprof,识别性能热点 | 基于基准分支:执行 Benchmark,获取对应 CPU、Mem 等 Pprof 结果,根据 Pprof 识别性能热点区域 |
| 性能优化 | 切换性能优化分支:根据下文 [常见代码优化思路] 来优化热点区域代码,减少代码 CPU 占用和内存分配次数等 |
| 获取优化前后 Benchmark 数据并对比 | 在基准分支和性能优化分支分别执行 Benchmark 获取数据,使用 benchstat 对比明确优化的收益 |
3. 举个例子🌰
3.1 Demo
package main
type AStruct struct {
A int
B int
C int
}
func NewStructA() *AStruct {
return &AStruct{}
}
func (aStruct *AStruct) ADD() {
aStruct.C = aStruct.A + aStruct.B
}
type BStruct struct {
A int
B int
C int
}
func NewStructB() BStruct {
return BStruct{}
}
func (bStruct *BStruct) ADD() {
bStruct.C = bStruct.A + bStruct.B
}
func DoSomething() {
for i := 100000; i > 0; i-- {
a := NewStructA()
a.ADD()
}
for i := 100000; i > 0; i-- {
b := NewStructB()
b.ADD()
}
}
3.2 撰写 Benchmark 代码
由于 Demo 没有入参,所以此处省略梳理使用场景和构造入参
撰写 Benchmark 代码:
package main
import "testing"
func BenchmarkDoSomething(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
DoSomething()
}
}
提交并合入基准分支:
git add .
git commit -m ":art: 撰写 benchmark 代码"
git push origin xxx
3.3 优化前 Benchmark,采样 Pprof,识别性能热点
获得 Pprof 采样:
执行以下命令,将获得 cpu.pprof 和 mem.pprof
-gcflags="all=-l -N" 用于避免编译器优化
go test -bench=. ./... -memprofile mem.pprof -cpuprofile cpu.pprof -gcflags="all=-l -N"
执行以下命令可以渲染图形化页面:
图形化页面提前安装 graphviz,可通过
brew install graphviz等方式安装也可直接 go tool pprof cpu.out 进入 pprof 终端,使用 top、list 等命令发现性能瓶颈,具体可参考 golang2.eddycjy.com/posts/ch6/0…
go tool pprof -http=":1234" cpu.out
基于 source 和 flamegraph 两种视图可分析发现性能热点主要是 NewStructA,且NewStructA相较于 NewStructB 性能开销多很多,而NewStructA 和 NewStructB 不同点在于是否返回指针类型,基于下文[常见代码优化思路]分析返回指针会触发内存逃逸,需要优化。
3.4 性能优化
进行性能优化:
// 优化前
func NewStructA() *AStruct {
return &AStruct{}
}
// 优化后
func NewStructA() AStruct {
return AStruct{}
}
3.5 获取优化前后 Benchmark 数据并对比
执行以下命令获取优化后的性能数据
// 切换基准分支,执行以下命令,并将 old_benchmark.txt 拷贝出
go test -bench=. ./... -benchmem -cpu=4 -count 10 -gcflags="all=-l -N" > ./old_benchmark.txt
// 切换性能优化分支,执行以下命令
go test -bench=. ./... -benchmem -cpu=4 -count 10 -gcflags="all=-l -N" > ./new_benchmark.txt
得到 old_benchmark.txt 如下
goos: darwin
goarch: arm64
pkg: xxx
BenchmarkDoSomething-4 538 2049147 ns/op 2400003 B/op 100000 allocs/op
BenchmarkDoSomething-4 585 2113461 ns/op 2400006 B/op 100000 allocs/op
BenchmarkDoSomething-4 586 2049284 ns/op 2400008 B/op 100000 allocs/op
BenchmarkDoSomething-4 586 2102973 ns/op 2400005 B/op 100000 allocs/op
BenchmarkDoSomething-4 571 2088303 ns/op 2400008 B/op 100000 allocs/op
BenchmarkDoSomething-4 574 2076815 ns/op 2400003 B/op 100000 allocs/op
BenchmarkDoSomething-4 578 2083408 ns/op 2400002 B/op 100000 allocs/op
BenchmarkDoSomething-4 577 2086304 ns/op 2400007 B/op 100000 allocs/op
BenchmarkDoSomething-4 580 2107555 ns/op 2400004 B/op 100000 allocs/op
BenchmarkDoSomething-4 555 2061220 ns/op 2400007 B/op 100000 allocs/op
PASS
ok xxx 14.710s
得到 new_benchmark.txt 如下
goos: darwin
goarch: arm64
pkg: xxxx
BenchmarkDoSomething-4 2713 438091 ns/op 0 B/op 0 allocs/op
BenchmarkDoSomething-4 2732 440362 ns/op 0 B/op 0 allocs/op
BenchmarkDoSomething-4 2724 443018 ns/op 0 B/op 0 allocs/op
BenchmarkDoSomething-4 2649 448859 ns/op 0 B/op 0 allocs/op
BenchmarkDoSomething-4 2684 444539 ns/op 0 B/op 0 allocs/op
BenchmarkDoSomething-4 2668 446069 ns/op 0 B/op 0 allocs/op
BenchmarkDoSomething-4 2712 442734 ns/op 0 B/op 0 allocs/op
BenchmarkDoSomething-4 2671 442440 ns/op 0 B/op 0 allocs/op
BenchmarkDoSomething-4 2744 440489 ns/op 0 B/op 0 allocs/op
BenchmarkDoSomething-4 2745 441778 ns/op 0 B/op 0 allocs/op
PASS
ok xxx 13.999s
获取收益:
执行以下命令
更多用法见:benchstat 使用文档
benchstat ./old_benchmark.txt ./new_benchmark.txt
可以得到 sec/op 优化了 78.77%,allocs/op 内存分配优化了 100%,并且发现内存分配次数是性能重要影响因素。
goos: darwin
goarch: arm64
pkg: xxxx
│ ./old_benchmark.txt │ ./new_benchmark.txt │
│ sec/op │ sec/op vs base │
DoSomething-4 2084.9µ ± 2% 442.6µ ± 1% -78.77% (p=0.000 n=10)
│ ./old_benchmark.txt │ ./new_benchmark.txt │
│ B/op │ B/op vs base │
DoSomething-4 2.289Mi ± 0% 0.000Mi ± 0% -100.00% (p=0.000 n=10)
│ ./old_benchmark.txt │ ./new_benchmark.txt │
│ allocs/op │ allocs/op vs base │
DoSomething-4 100.0k ± 0% 0.0k ± 0% -100.00% (p=0.000 n=10)
3.6 分支性能比对脚本(流程优化)
一般会单独切一个分支进行性能优化,下文脚本会在基准分支和当前分支上执行 Benchmark 结果,并将结果进行对照比对。
#!/bin/bash
# 对比两个分析版本的benchmark差异的bash脚本
# 准备环境
go install golang.org/x/perf/cmd/benchstat@latest
# 获取当前分支
NOW_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
DIR_SOURCE="$(pwd)"
rm "${DIR_SOURCE}""/old_benchmark.txt"
rm "${DIR_SOURCE}""/now_benchmark.txt"
rm "${DIR_SOURCE}""/benchmark_diff.txt"
touch "${DIR_SOURCE}""/old_benchmark.txt"
touch "${DIR_SOURCE}""/now_benchmark.txt"
DIR_OLD="$(mktemp -d)"
function cleanup() {
rm -rf "${DIR_OLD}"
}
trap cleanup EXIT
# 使用 Git 检出比对的目标代码分支
TARGET_BRANCH="${CI_EVENT_CHANGE_TARGET_BRANCH:-master}"
echo "Checking for performance change relative to ${TARGET_BRANCH}"
if [ -z "$(git show-ref "refs/heads/${TARGET_BRANCH}")" ]; then
git branch "${TARGET_BRANCH}" "origin/${TARGET_BRANCH}"
fi
git clone -b "${TARGET_BRANCH}" . "${DIR_OLD}"
echo "运行旧版本基准测试..."
cd $DIR_OLD
go mod tidy
go test -bench=. ./... -benchmem -cpu=4 -count 10 |grep Benchmark > "${DIR_SOURCE}""/old_benchmark.txt"
echo "运行新版本基准测试..."
cd $DIR_SOURCE
go mod tidy
go test -bench=. ./... -benchmem -cpu=4 -count 10 |grep Benchmark > "${DIR_SOURCE}""/now_benchmark.txt"
# 使用benchstat工具对比两个基准测试结果
echo "对比测试结果..."
benchstat "${TARGET_BRANCH}"="${DIR_SOURCE}""/old_benchmark.txt" "${NOW_BRANCH}"="${DIR_SOURCE}""/now_benchmark.txt" > "${DIR_SOURCE}""/benchmark_diff.txt"
4. 常见代码性能优化思路
4.1 优化前思考
一般性能优化都到还不需要到「优化代码性能」的地步,所以做「代码性能优化」前可能需要做出一些思考:
- 是否有其他方式?——> 代码性能优化往往开发成本较高,优化后代码可维护性,通用性可能都会降低
- 当前的逻辑架构是否合理? ——> 换个架构是不是性能就翻倍了
- 是否能减少下游服务/存储调用?——> 减少一次外部调用可能比把代码优化到极致收益还要大很多
- 是否可以减少日志打印,指标埋点?——> 同上
4.2 提前终止、避免执行多余逻辑
举例: 下面的代码明明设置了日志等级为 Info,但是打印 Debug 日志仍然会消耗很多日志
日志等级 Info>Debug,设置 Info 日志等级,Debug 日志不会输出
import (
"encoding/json"
"fmt"
"testing"
"go.uber.org/zap"
)
type AStruct struct {
A int
B int
C int
}
func NewStructA() AStruct {
return AStruct{}
}
func BenchmarkLogDebug(b *testing.B) {
config := zap.NewProductionConfig()
config.Level.SetLevel(zap.InfoLevel)
logger, _ := config.Build()
defer logger.Sync()
sugar := logger.Sugar()
structA := NewStructA()
b.Run("Log_Base", func(b *testing.B) {
for i := 0; i < b.N; i++ {
sugar.Debugf("structA is %s", ToString(structA))
}
})
}
func ToString(v interface{}) string {
str, _ := json.Marshal(v)
return string(str)
}
// BenchmarkLogDebug/Log_Base-12 7869234 141.2 ns/op 88 B/op 4 allocs/op
通过 benchmark 与 Pprof 分析可以得到其执行了 json.Marshal 多余逻辑
所以我们可以通过延迟执行等方式避免代码执行多余逻辑:
import (
"encoding/json"
"fmt"
"testing"
"go.uber.org/zap"
)
func BenchmarkLog(b *testing.B) {
config := zap.NewProductionConfig()
config.Level.SetLevel(zap.InfoLevel)
logger, _ := config.Build()
defer logger.Sync()
sugar := logger.Sugar()
structA := NewStructA()
b.Run("Log_Base", func(b *testing.B) {
for i := 0; i < b.N; i++ {
sugar.Debugf("structA is %s", ToString(structA))
}
})
b.Run("Log2", func(b *testing.B) {
for i := 0; i < b.N; i++ {
sugar.Debugf("structA is %s", Lazy(structA))
}
})
b.Run("Log3", func(b *testing.B) {
for i := 0; i < b.N; i++ {
sugar.Debugf("structA is %s", NewLogString(structA))
}
})
}
type AStruct struct {
A int
B int
C int
}
func NewStructA() AStruct {
return AStruct{}
}
func ToString(v interface{}) string {
str, _ := json.Marshal(v)
return string(str)
}
type Func func() string
// String an implement of stringer interface
func (f Func) String() string {
return f()
}
func Lazy(v interface{}) Func {
return func() string {
str, _ := json.Marshal(v)
return string(str)
}
}
var _ fmt.Stringer = logString{}
type logString struct {
obj interface{}
}
func NewLogString(v interface{}) logString {
return logString{obj: v}
}
func (l logString) String() string {
str, _ := json.Marshal(l.obj)
return string(str)
}
// BenchmarkLog/Log_Base-12 7129472 140.9 ns/op 88 B/op 4 allocs/op
// BenchmarkLog/Log2-12 35531034 32.91 ns/op 48 B/op 2 allocs/op
// BenchmarkLog/Log3-12 35388964 32.94 ns/op 40 B/op 2 allocs/op
4.3 避免内存分配、逃逸
ns/op是指每次执行的平均执行时间(nanoseconds per operation),值越小表示操作执行得更快。*纳秒(ns) *:是时间的最小单位之一,等于一秒的十亿分之一(1/1,000,000,000 秒)。纳秒通常用于测量非常短暂、高精度的时间间隔,例如计算机内部操作的速度、函数的执行时间等
B/op是指每次执行的平均内存分配量,单位为字节(bytes)。
allocs/op是指每次执行的内存分配次数(allocations per operation)
通过 benchmark 测试发现,往往ns/op 越大则 allocs/op 越大,通过下文 Benchmark 可得出,一次内存分配相当于执行加法计算 70 余次,所以往往我们做代码性能优化最重要的是避免内存分配。
func BenchmarkAllocs(b *testing.B) {
logs.SetLevel(logs.LevelInfo)
log.V2.SetLevel(logs.LevelInfo)
b.Run("Calc", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for i := 75; i > 0; i-- {
_ = i + 1
}
}
})
b.Run("Allocs", func(b *testing.B) {
for i := 0; i < b.N; i++ {
slice := make([]int, 4)
for i := 0; i < 4; i++ {
slice = append(slice, 0)
}
_ = slice
}
})
}
BenchmarkAllocs/Calc-12 50459848 23.55 ns/op 0 B/op 0 allocs/op
BenchmarkAllocs/Allocs-12 46024180 25.79 ns/op 64 B/op 1 allocs/op
常见避免内存分配的方法如下:
分析内存逃逸可参考:Go 逃逸分析
4.3.1 预分配,避免动态扩容
Bad Case:
length := 100
slice := make([]string,0)
for i := 0; i < length; i++ {
slice = append(slice, i)
}
Good Case:
length := 100
slice := make([]int, 0, length)
for i := 0; i < length; i++ {
slice = append(slice, i)
}
4.3.2 避免返回局部变量地址(避免内存逃逸)
NewStructA 函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收,因此只能分配逃逸到堆上。
当然有时编译器会自动帮我们内联优化,避免内存逃逸
Bad Case:
type AStruct struct {
A int
B int
C int
}
func NewStructA() *AStruct {
return &AStruct{}
}
func (aStruct *AStruct) ADD() {
aStruct.C = aStruct.A + aStruct.B
}
func main() {
a := NewStructA()
a.ADD()
}
Good Case:
type AStruct struct {
A int
B int
C int
}
func NewStructA() AStruct {
return AStruct{}
}
func (aStruct *AStruct) ADD() {
aStruct.C = aStruct.A + aStruct.B
}
func main() {
a := NewStructA()
a.ADD()
}
4.3.3 尽量避免使用 interface{} 和 %v(避免内存逃逸)
interface{} 支持代码更抽象,所以有时也需要在抽象和性能间做权衡
interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体类型,也会发生逃逸。
4.3.4 使用 Sync.Pool大杀器
如果你能掌握一个 变量的生命周期,那么就可以使用 Sync.Pool 复用内存来避免内存分配,比如场景:
接口每个请求内部逻辑都需要 New 一个 Struct,并且在请求返回前可以回收
4.4 选择高性能依赖
| 基础库 | 高性能实现 |
|---|---|
| Json | github.com/bytedance/s… |
| Protobuf | github.com/gogo/protob… |
| Hash | github.com/twmb/murmur3 |
| 一致性 Hash | writings.sh/post/consis… |
5. 最后
技术本身是没有优劣之分的,其优劣往往取决于具体的使用场景和需求
感谢你的阅读,都看到这儿了,不妨点个赞再走 😘