
作者:rossixiao
性能优化是一个经久不衰的课题了,我们都常做。本文列举了很多常用的tips,基本都是我日常开发中遇到的问题,我将这些问题和方法梳理了下来。GC原理
这一节会较为详细介绍go的内存管理机制,也即GC。之所以要重点介绍是因为:
GC是很多服务的性能瓶颈,在性能优化问题上是举足轻重的。
许多常见的优化手段都是围绕着内存管理进行的,只有了解了原理处理起来才游刃有余。另外go自身的内存管理方案也一直在迭代优化,了解后我们可能会发现自己遇到的性能问题是因为go版本太低了。
go这一套内存自动管理方案本身很有借鉴意义,如果学习到,碰到相似的业务可以效仿其设计方案。如一些复杂的调度系统。
STW是指暂停所有goroutine,标记可达和不可达的对象,最后清除不可达对象,完成垃圾回收的过程。 在go1.3之前垃圾回收就是依赖的全局STW,因此性能很低,一次STW带来的停顿时间可达数百毫秒。
三色标记法+写屏障三色标记法
初始时所有的对象节点都被标记成白色
第一次扫描从根节点出发,把能遍历到的对象从白色集合放到灰色集合
重复扫描灰色集合,将灰色对象引用的对象从白色集合放到灰色集合,然后把此灰色对象放到黑色集合
直到灰色集合清空,内存中只有黑白两种颜色。此时可以回收所有白色对象
很多人把三色标记法称为三色并发标记法,因为它存储了对象的中间状态,不需要一次性遍历完。但实际上和程序并发运行时,对象之间的引用关系会发生更改(写操作),而染色会读引用关系,也即发生了读写冲突。这种冲突可能导致白色对象断开和灰色对象的链接,挂在一个黑色对象上,而黑色对象是不会作为扫描的根节点的,因此白色对象被误删除,如图中的对象3。因此内存回收的一个很关键的操作就是把白色对象保护起来,可以延时删除,但不能误删除。
![]()
触发三色标记法不安全的必要条件
白色对象被黑色对象引用
且白色对象断开了所有灰色对象与它之间的可达关系
其实只要破坏了这两个必要条件之一,就能避免白色对象被误删除。后面的优化都是围绕着这个规则来的。
加入写屏障,保护白色节点
插入屏障:将B节点挂在A的下游时,B节点会被标记为灰色。 保障了白色节点不会被挂在黑色节点下。
删除屏障:对删除的对象,如果自身为白色,会被标记为灰色。保障了被删除的白色节点有灰色节点与之链接(对,自己给自己撑腰)。
go1.5就升级到了三色标记法+写屏障的策略,保证了扫描和程序可以并发执行,无需停顿。但由于栈写操作频繁且要保障运行效率,写屏障只运用到了堆上,如果白色节点被挂在黑色节点上,为了保障安全性,栈还是要进行一次STW扫描,以修正状态。这一STW停顿一般在10~100ms。
混合写屏障
go1.8引入了混合写屏障,避免了对栈的重复扫描,极大减少了STW的时间。和写屏障对比,加了以下两个操作:
GC开始时会将栈上的所有可达对象标记为黑色
gc期间,栈上新创建的对象会被初始化为黑色
这样做的道理在,栈的可达对象全标黑了,受颜色保护。而也不会出现白色(不可达)对象被挂在黑色对象的情况,因为它,不可达。
![]()
引入混合写之后,以及几乎不需要STW了。
gc优化GC瓶颈分析症结在GC扫描
已知go已经把STW压缩到极致了,所以这并非是大多数系统的问题所在,真正消耗性能的是gc扫描的计算过程。和GC回收相比也是扫描过程更消耗cpu。
扫描的时机:
堆内存达到阈值时触发。下次GC阈值 = 上次GC后存活对象大小 × (1 + GOGC/100),默认 GOGC=100(内存翻倍时触发),可通过环境变量调整。
定时触发。若持续 2 分钟未触发 GC,强制启动扫描(避免长期未回收的内存泄漏)。
手动触发。调用 runtime.GC() 强制启动扫描,常用于调试或内存敏感操作后。
内存分配时触发 。申请大对象(>32KB)或小对象时本地缓存不足(mcache 耗尽),可能触发扫描。
内存回收的时机:
标记终止后立即启动 。标记阶段完成后,清除阶段回收所有白色(未标记)对象,此阶段与用户代码并发执行。
内存分配时触发辅助回收 。若程序在 GC 过程中分配新内存,可能被要求协助执行部分回收任务。一般来讲,gc扫描是更加消耗性能的那一步,但我们一般不分开说,统称一次gc。
利用火焰图,可以看到gcBgMarkWorker占用cpu的百分比,一般超过10%就需要优化了。 需要留意的是mallocgc属于内存分配带来的瓶颈,并非gc扫描问题。
![]()
减少堆对象分配
gc优化的方向之一是减少堆对象的分配,这是因为和栈相比,堆对象要gc扫描的时候要递归扫描所有对象,且栈对象会随着生命周期的结束而被释放,而堆对象全部需要gc扫描来回收。
小对象使用结构体而非指针
func createUser() *User { return &User{ID: 1, Name: "Alice"} // 逃逸到堆 }改成下面的写法编成栈分配,随着生命周期被自动回收:
func createUser() User { return User{ID: 1, Name: "Alice"} // 栈分配,函数结束后自动回收 }通过参数传递替代闭包捕获func main() { x := 42 go func() { fmt.Println(x) // x 逃逸到堆 }() } func main() { x := 42 go func(val int) { fmt.Println(val) // val 通过值传递,保留在栈上 }(x) }利用bigcache存储大对象(GB级别)bigcache利用[]byte数组存储对象,会被当成是一个整体只会扫描一次,完全规避了gc扫描问题。但需要自己做内存管理,且使用的时候要加一次编解码操作。
内存池减少对象分配
var pool = sync.Pool{ New: func() interface{} { return make([]byte, 1024) }, } func processRequest() { buf := pool.Get().([]byte) // 从池中获取(可能复用) defer pool.Put(buf) // 放回池中 // 使用 buf... }减少gc扫描次数调整GOGC大小默认是增加一倍触发GC回收,可以适当调大
import "runtime/debug" func main() { debug.SetGCPercent(200) // 堆增长 200% 即触发 GC }抬高堆大小基数初始化的时候分配一个比较大的对象,提高触发GC的基数
func main() { ballast := make([]byte, 10<<30) // 10GB 虚拟内存(实际 RSS 不增加) runtime.KeepAlive(ballast) // 阻止回收 // 主逻辑... }预分配内存// 未优化:多次扩容 var data []int for i := 0; i < 1000; i++ { data = append(data, i) // 可能触发多次堆分配 }预分配内存,可以避免中途多次触发GC扫描,且也减少了数据迁移带来的开销
// 优化:单次预分配 data := make([]int, 0, 1000) // 一次性分配底层数组 for i := 0; i < 1000; i++ { data = append(data, i) }为什么不能手动回收,减少gc压力呢?go不支持
善用缓存
缓存在我们这的使用场景还是挺多的,我们需要合理设计缓存,使得对外接口的平均耗时在100ms以下。我们曾经有因为没加缓存,在for循环中拉游戏详情,导致大部分线程都阻塞在I/O等待中,导致接口耗时高,系统吞吐量低。
for appid := range appids { detail := GetDetailInfo(appid) // rpc调用 ... }但加缓存并非是无脑加的,缓存本身也可能会到来性能问题。如:
商城首页因异步更新缓存的时候频繁分配内存,导致cpu利用率出现周期性尖刺。这种也是会浪费cpu的,可以适当打散,减小尖刺。
使用了bigcache,但设置的内存清理时间太长,导致期间内内存打满引起OOM,机器频繁重启。
对于耗时很高的非关键路径,要异步化处理,防止阻塞主流程。如一些非关键上报异步处理,再如畅玩好友在玩耗时较高,前端采用了异步加载的方式。
尽量减少锁竞争或者无锁化
如果资源竞争激烈,很可能会导致锁等待时间太长,和增加调度压力而浪费cpu。
减少锁的范围
func main() { lock() defer Unlock() newA,newB := get() // 复杂的赋值操作 cache.A = newA cache.B = newB } func main() { newA,newB := get() newCache.A = newA newCache.B = newB // 先赋值,再直接替换,只需要锁住替换这一步 Lock() cache = newCache Unlock() }选用读写锁/乐观锁来优化锁方案func main() { var counter int32 = 0 // 模拟10个goroutine并发自增 for i := 0; i < 10; i++ { go func() { atomic.AddInt32(&counter, 1) // 原子加1 }() } }使用协程池防止OOM尽管go的协程已经非常轻量了,在一些场景还是要控制协程的数目,防止协程无节制得扩增,导致资源耗尽或者调度压力大。
一些高性能编程的好习惯避免大日志
在一次压测中,一个2核2G的服务,日志200k字节,吞吐量只能到80tps,后排查到瓶颈在于日志打太多了,减少日志输出后性能提升了几十倍。
![]()
可以看到当前主要性能消耗在字符串编解码这里。
避免深度拷贝
我们应该尽量减少深度拷贝的使用,在商城首页这里,由于过度使用了clone,导致了gc性能瓶颈。去掉clone,只对部分数据赋值后,性能提升了50%
![]()
避免反射
由于每个qgame的配置项是不同的结构体,为了通用化,qgameclient最开始利用反射来获取配置
type configItem struct { attr *qgame.ConfigAttr // 公共属性 item reflect.Value // 配置项 } // GetConfig 拉取配置 func GetConfig(ctx context.Context, qryReq *QueryReq, attrs map[string]*qgame.ConfigAttr, confs interface{}) error { itemType := reflect.TypeOf(confs).Elem().Elem() // 堆分配 // 获取配置 c, err := getConfWithCache(ctx, itemType, qryReq) if err != nil { return err } // 解析并填充attrs和confs d := reflect.New(itemType) // 堆分配 reflectMap := reflect.ValueOf(confs) // 堆分配 for k, v := range c { if d.Type() != v.item.Type() { return errs.Newf(ErrParams, "data type unmatch:%v,%v", d.Type(), v.item.Type()) } attrs[k] = v.attr reflectMap.SetMapIndex(reflect.ValueOf(k), v.item) // 堆分配 } returnnil }后续遇到了性能瓶颈,反射需要在运行时动态检查数据类型和创建临时对象(每次reflect.ValueOf()或reflect.TypeOf()调用至少产生1次堆分配)。引入泛型,泛型在编译时期会生成对应类型的代码,运行时无需校验类型和分配临时对象。
type ConfigItem[T any] struct { Attr *qgame.ConfigAttr // 公共属性 Config *T // config 配置map,key为配置id } func (c *QgameCli[T]) GetConfig(ctx context.Context, req *QueryReq) *QueryRsp[T] { rsp := &QueryRsp[T]{ Items: map[string]*ConfigItem[T]{}, Env: cache.env, } items := cache.getCachedConfig(req) for key, item := range items { rsp.Items[key] = item } return rsp }最后性能上:泛型>强制类型转换/断言>反射
任务打散
当某个机器性能跟不上任务的计算复杂度时,可以考虑把计算任务打散到不同机器执行。我们用生产消费者模式执行任务的时候,消费者经常利用北极星的负载均衡能力,把任务平均分配到每个机器执行。
// StartConsume 开始消费 func StartConsume() { for i := 0; i < 100; i++ { util.GoWithRecover(func() { for item := range ch { item := item consumeRpc(item) // 走rpc调用,打散消费任务 } }) } }编解码选型目前比较常用的编解码类型有pb、json、yaml和sonic,编解码性能还是相差很大的, 之前gameinfoclient序列化从json改成pb时,获取游戏详情的耗时从100ms优化到了20ms。vtproto是司内大神提供的pb编解码优化版本,去掉了pb官方编码中的反射过程。实践发现trpc-proxy利用了vtproto,性能提高了20%。
性能对比
这是ai给出的通识结论:
![]()
我自己实测了发现单编码json的编码性能居然高于pb(sonic > json > vtproto > proto > yaml),这不是一个结论别记忆,后面会解释:
BenchmarkProtoMarshal-16 1000000 1074 ns/op BenchmarkVtProtoMarshal-16 1157985 903.8 ns/op BenchmarkJsonMarshal-16 1743318 688.7 ns/op BenchmarkSonicMarshal-16 3684892 335.9 ns/op BenchmarkYamlMarshal-16 154058 7499 ns/op 测试对象: TestData := &pb.TestStruct{ A: 1, B: []string{ "test", }, C: map[int32]string{ 1: "test", }, }实际上是因为对于小对象而言json的反射机制开销较小,且go1.22版本优化了json反射机制,性能有所提升,所以表现为小对象 json的编码性能高于proto。 我尝试换成大对象,印证了这一点(sonic > vtproto > pb > json > yaml)
BenchmarkProtoMarshal-16 912 1325582 ns/op BenchmarkVtProtoMarshal-16 908 1316555 ns/op BenchmarkJsonMarshal-16 666 1792485 ns/op BenchmarkSonicMarshal-16 4705 252298 ns/op BenchmarkYamlMarshal-16 79 13914111 ns/op 测试对象: a := int32(12345) b := make([]string, 0, 5000) for i := 0; i < 5000; i++ { b = append(b, fmt.Sprintf("str-%d-abcdefghijklmnopqrstuvwxyz", i)) } c := make(map[int32]string, 3000) for i := int32(0); i < 3000; i++ { c[i] = fmt.Sprintf("value-%d-0123456789ABCDEF", i) } TestData = &TestDataStruct{ A: a, B: b, C: c, }最后,一般编码和解码是对称使用的,这里也测了一下对称使用编解码的性能:
小对象: BenchmarkProto-16 560961 2082 ns/op BenchmarkVtProto-16 559299 2011 ns/op BenchmarkJson-16 375512 3248 ns/op BenchmarkSonic-16 1224876 974.7 ns/op BenchmarkYaml-16 62400 18995 ns/op 大对象: BenchmarkProto-16 369 3260424 ns/op BenchmarkVtProto-16 364 3349230 ns/op BenchmarkJson-16 201 5934529 ns/op BenchmarkSonic-16 1483 798178 ns/op BenchmarkYaml-16 42 28831239 ns/op至此我的结论是: sonic性能最卓越,如果对压缩大小、平台不敏感,能使用sonic尽量使用sonic;如果对可读性要求比较高用json/yaml;跨端通信用pb,对性能敏感可升级用vtproto。
字符串拼接
strings.Builder性能最高,底层是[]byte,可动态扩容
strings.Join底层是strings.Builder,一次性计算分配内存,性能差不多
+运算符。需要不断创建新的临时对象
fmt.Sprintf()。性能很差,涉及到反射。性能敏感场景避免使用。
也即火焰图,伽利略已经集成了火焰图插件,可直接使用。下面一个例子是分析game_switch服务的性能瓶颈:
查看cpu time。发现SsoGetShareTails 、编解码、 GcBgMarkWorker占用了大部分cpu时间。
继续往下看,分析出SsoGetShareTails主要消耗在Sprintf函数上。
![]()
到这里已经可以猜测出:
编解码占大部分推测出可能回包包体很大,影响了性能。
gcBgMarkWorker表示gc扫描消耗的性能,可能频繁分配内存,或者内存占用比较高。
SsoGetShareTails中的Sprintf前面已经提到过是一个性能很低的字符串拼接方案,可以直接优化掉。
继续查看堆内存大小,包含gc回收的。可以定位到大头在接口、序列化和压缩上。
代码定位如下图。序列化和压缩是框架自带的,符合前面的推断---回包太大导致的。
![]()
最后看看还存活的堆内存大小。发现是游戏详情的缓存,符合预期。虽然在存活的堆内存里它算大头,但和总的堆内存大小对比还是挺小的,不是目前主要优化点,可降低优先级后续优化。
![]()
trace
对于一般的程序,pprof已经够用了。如果要更精细得定位问题,可以使用trace,和pprof不同的是,pprof是基于统计维度的,原理是定期采样生成cpu和内存的快照,而trace直接追踪到整个程序的运行,能提供时间线上的事件流。像这样:
![]()
上面提供是一段有死锁的代码:
func main() { // 创建trace文件 f, err := os.Create("deadlock_trace.out") if err != nil { log.Fatal(err) } defer f.Close() // 启动trace err = trace.Start(f) if err != nil { log.Fatal(err) } defer trace.Stop() // 创建两个互斥锁 var mutex1, mutex2 sync.Mutex // goroutine1 先锁mutex1,再尝试锁mutex2 f1 := func() { mutex1.Lock() log.Println("goroutine1 获得 mutex1") time.Sleep(1 * time.Second) // 确保死锁发生 mutex2.Lock() log.Println("goroutine1 获得 mutex2") mutex2.Unlock() mutex1.Unlock() } go f1() // goroutine2 先锁mutex2,再尝试锁mutex1 gofunc() { mutex2.Lock() log.Println("goroutine2 获得 mutex2") time.Sleep(1 * time.Second) // 确保死锁发生 mutex1.Lock() log.Println("goroutine2 获得 mutex1") mutex1.Unlock() mutex2.Unlock() }() for i := 0; i < 10; i++ { gofunc() { time.Sleep(5 * time.Second) }() } // 等待足够时间让死锁发生 for { } }通过 Goroutine analysis,可以看到func1对应的协程编号是23,且大部分时间都处于阻塞中:
![]()
点击查看协程23具体的事件流,func1最后一次执行停留在sleep这,虽然很疑惑为什么不在Lock()这里,但也印证了后续的流程被阻塞了
![]()
![]()
需求阶段
最近被问到:“如果下游接口就是很慢,你要怎么办?”。最近畅玩就遇到了类似的问题,畅玩要接入ams广告,但一个广告接口却有接近800ms的耗时,明显对体验是有损的,使得我们不得不推动下游做性能优化。除此之外也提醒了我不应该仅限于需求开发,而应该从用户的角度出发,思考需求是否合理、是否可优化,协同产品一起保障体验。
体验要稳定做好兜底
异常兜底。
如算法侧挂了,要展示兜底素材;游戏封面缺失,过滤不展示或者展示兜底封面。边界兜底。
有时候产品会忘记提供排序策略,除了被指定的随机资源位,其他应该协调一个排序方案,避免每次刷新都展示不同的内容。
数据源一致性
如前段时间遇到的不同页面展示的可领取礼包数不一致,实际是一个页面展示的所有礼包,一个页面没展示贵族礼包,这要求我们开发前和产品沟通数据和哪个需求的页面保持一致。
画面要流畅数据分页/分屏
如果一次请求的数据量太多,不仅会给后台带来性能问题,也可能引起前端前端渲染卡顿了,所以必要时需要沟通设计分页或者分屏(如设计列表页和二级页)。
隐藏高耗时的数据
延迟加载非首屏内容,优先保障核心功能可快速操作。
资源大小控制
如资料小卡的游戏段位图,一开始误给了一个10241024的高清图,会导致UI加载很慢,后续改成了120120的就能满足清晰度要求。
数据实时性分级
区分强实时性和弱实时性。如对于礼包数量、游戏在玩人数等允许有一段时间的延时,以减少对db的压力。
用户操作限频
如已经预约后按钮置灰、限制点击次数等,可以减少接口调用量,也能提高系统用户吞吐量。
成本沟通
如周报tips,产品要求每个人都生成特有的图片,已知图像处理会带来很多额外的开销,应该提醒产品并切换其他方案。
回包大小控制
回包太大不仅会影响服务性能,也会加大网络传输耗时和前端加载耗时。
![]()
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.