打开网易新闻 查看精彩图片

作者:rossixiao

性能优化是一个经久不衰的课题了,我们都常做。本文列举了很多常用的tips,基本都是我日常开发中遇到的问题,我将这些问题和方法梳理了下来。
GC原理

这一节会较为详细介绍go的内存管理机制,也即GC。之所以要重点介绍是因为:

  1. GC是很多服务的性能瓶颈,在性能优化问题上是举足轻重的。

  2. 许多常见的优化手段都是围绕着内存管理进行的,只有了解了原理处理起来才游刃有余。另外go自身的内存管理方案也一直在迭代优化,了解后我们可能会发现自己遇到的性能问题是因为go版本太低了。

  3. go这一套内存自动管理方案本身很有借鉴意义,如果学习到,碰到相似的业务可以效仿其设计方案。如一些复杂的调度系统。

标记-清除(Stop-the-world)
打开网易新闻 查看精彩图片

STW是指暂停所有goroutine,标记可达和不可达的对象,最后清除不可达对象,完成垃圾回收的过程。 在go1.3之前垃圾回收就是依赖的全局STW,因此性能很低,一次STW带来的停顿时间可达数百毫秒。

三色标记法+写屏障三色标记法

  1. 初始时所有的对象节点都被标记成白色

打开网易新闻 查看精彩图片
  1. 第一次扫描从根节点出发,把能遍历到的对象从白色集合放到灰色集合

打开网易新闻 查看精彩图片
  1. 重复扫描灰色集合,将灰色对象引用的对象从白色集合放到灰色集合,然后把此灰色对象放到黑色集合

打开网易新闻 查看精彩图片
  1. 直到灰色集合清空,内存中只有黑白两种颜色。此时可以回收所有白色对象

打开网易新闻 查看精彩图片

很多人把三色标记法称为三色并发标记法,因为它存储了对象的中间状态,不需要一次性遍历完。但实际上和程序并发运行时,对象之间的引用关系会发生更改(写操作),而染色会读引用关系,也即发生了读写冲突。这种冲突可能导致白色对象断开和灰色对象的链接,挂在一个黑色对象上,而黑色对象是不会作为扫描的根节点的,因此白色对象被误删除,如图中的对象3。因此内存回收的一个很关键的操作就是把白色对象保护起来,可以延时删除,但不能误删除。

触发三色标记法不安全的必要条件
打开网易新闻 查看精彩图片
触发三色标记法不安全的必要条件

白色对象被黑色对象引用

  • 且白色对象断开了所有灰色对象与它之间的可达关系

  • 其实只要破坏了这两个必要条件之一,就能避免白色对象被误删除。后面的优化都是围绕着这个规则来的。

    加入写屏障,保护白色节点

    1. 插入屏障:将B节点挂在A的下游时,B节点会被标记为灰色。 保障了白色节点不会被挂在黑色节点下。

    2. 删除屏障:对删除的对象,如果自身为白色,会被标记为灰色。保障了被删除的白色节点有灰色节点与之链接(对,自己给自己撑腰)。

    go1.5就升级到了三色标记法+写屏障的策略,保证了扫描和程序可以并发执行,无需停顿。但由于栈写操作频繁且要保障运行效率,写屏障只运用到了堆上,如果白色节点被挂在黑色节点上,为了保障安全性,栈还是要进行一次STW扫描,以修正状态。这一STW停顿一般在10~100ms。

    混合写屏障

    go1.8引入了混合写屏障,避免了对栈的重复扫描,极大减少了STW的时间。和写屏障对比,加了以下两个操作:

    1. GC开始时会将栈上的所有可达对象标记为黑色

    2. gc期间,栈上新创建的对象会被初始化为黑色

    这样做的道理在,栈的可达对象全标黑了,受颜色保护。而也不会出现白色(不可达)对象被挂在黑色对象的情况,因为它,不可达。

    打开网易新闻 查看精彩图片

    引入混合写之后,以及几乎不需要STW了。

    gc优化GC瓶颈分析症结在GC扫描

    已知go已经把STW压缩到极致了,所以这并非是大多数系统的问题所在,真正消耗性能的是gc扫描的计算过程。和GC回收相比也是扫描过程更消耗cpu。

    扫描的时机:

    1. 堆内存达到阈值时触发。下次GC阈值 = 上次GC后存活对象大小 × (1 + GOGC/100),默认 GOGC=100(内存翻倍时触发),可通过环境变量调整。

    2. 定时触发。若持续 2 分钟未触发 GC,强制启动扫描(避免长期未回收的内存泄漏)。

    3. 手动触发。调用 runtime.GC() 强制启动扫描,常用于调试或内存敏感操作后。

    4. 内存分配时触发 。申请大对象(>32KB)或小对象时本地缓存不足(mcache 耗尽),可能触发扫描。

    内存回收的时机:

    1. 标记终止后立即启动 。标记阶段完成后,清除阶段回收所有白色(未标记)对象,此阶段与用户代码并发执行。

    2. 内存分配时触发辅助回收 。若程序在 GC 过程中分配新内存,可能被要求协助执行部分回收任务。一般来讲,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调用     ... }

    但加缓存并非是无脑加的,缓存本身也可能会到来性能问题。如:

    1. 商城首页因异步更新缓存的时候频繁分配内存,导致cpu利用率出现周期性尖刺。这种也是会浪费cpu的,可以适当打散,减小尖刺。

    打开网易新闻 查看精彩图片
    1. 使用了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。

    字符串拼接

    1. strings.Builder性能最高,底层是[]byte,可动态扩容

    2. strings.Join底层是strings.Builder,一次性计算分配内存,性能差不多

    3. +运算符。需要不断创建新的临时对象

    4. fmt.Sprintf()。性能很差,涉及到反射。性能敏感场景避免使用。

    利用工具排查性能问题pprof

    也即火焰图,伽利略已经集成了火焰图插件,可直接使用。下面一个例子是分析game_switch服务的性能瓶颈:

    1. 查看cpu time。发现SsoGetShareTails 、编解码、 GcBgMarkWorker占用了大部分cpu时间。

    打开网易新闻 查看精彩图片

    继续往下看,分析出SsoGetShareTails主要消耗在Sprintf函数上。

    打开网易新闻 查看精彩图片

    到这里已经可以猜测出:

    1. 编解码占大部分推测出可能回包包体很大,影响了性能。

    2. gcBgMarkWorker表示gc扫描消耗的性能,可能频繁分配内存,或者内存占用比较高。

    3. SsoGetShareTails中的Sprintf前面已经提到过是一个性能很低的字符串拼接方案,可以直接优化掉。

    4. 继续查看堆内存大小,包含gc回收的。可以定位到大头在接口、序列化和压缩上。

    打开网易新闻 查看精彩图片

    代码定位如下图。序列化和压缩是框架自带的,符合前面的推断---回包太大导致的。

    打开网易新闻 查看精彩图片

    最后看看还存活的堆内存大小。发现是游戏详情的缓存,符合预期。虽然在存活的堆内存里它算大头,但和总的堆内存大小对比还是挺小的,不是目前主要优化点,可降低优先级后续优化。

    trace
    打开网易新闻 查看精彩图片
    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的耗时,明显对体验是有损的,使得我们不得不推动下游做性能优化。除此之外也提醒了我不应该仅限于需求开发,而应该从用户的角度出发,思考需求是否合理、是否可优化,协同产品一起保障体验。

    体验要稳定做好兜底

    1. 异常兜底。

    2. 如算法侧挂了,要展示兜底素材;游戏封面缺失,过滤不展示或者展示兜底封面。边界兜底。

    如帖子浏览完了的文案兜底;完善错误码机制,提示用户当前状态。稳定排序

    有时候产品会忘记提供排序策略,除了被指定的随机资源位,其他应该协调一个排序方案,避免每次刷新都展示不同的内容。

    数据源一致性

    如前段时间遇到的不同页面展示的可领取礼包数不一致,实际是一个页面展示的所有礼包,一个页面没展示贵族礼包,这要求我们开发前和产品沟通数据和哪个需求的页面保持一致。

    画面要流畅数据分页/分屏

    如果一次请求的数据量太多,不仅会给后台带来性能问题,也可能引起前端前端渲染卡顿了,所以必要时需要沟通设计分页或者分屏(如设计列表页和二级页)。

    隐藏高耗时的数据

    延迟加载非首屏内容,优先保障核心功能可快速操作。

    资源大小控制

    如资料小卡的游戏段位图,一开始误给了一个10241024的高清图,会导致UI加载很慢,后续改成了120120的就能满足清晰度要求。

    数据实时性分级

    区分强实时性和弱实时性。如对于礼包数量、游戏在玩人数等允许有一段时间的延时,以减少对db的压力。

    用户操作限频

    如已经预约后按钮置灰、限制点击次数等,可以减少接口调用量,也能提高系统用户吞吐量。

    成本沟通

    如周报tips,产品要求每个人都生成特有的图片,已知图像处理会带来很多额外的开销,应该提醒产品并切换其他方案。

    回包大小控制

    回包太大不仅会影响服务性能,也会加大网络传输耗时和前端加载耗时。

    打开网易新闻 查看精彩图片