首页 抖音推荐文章正文

Golang后台服务性能优化,实用Tips梳理大全

抖音推荐 2025年09月07日 16:59 1 admin

作者:rossixiao

Golang后台服务性能优化,实用Tips梳理大全

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

C GC原理

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

  1. GC是很多服务的性能瓶颈,在性能优化问题上是举足轻重的。
  2. 许多常见的优化手段都是围绕着内存管理进行的,只有了解了原理处理起来才游刃有余。另外go自身的内存管理方案也一直在迭代优化,了解后我们可能会发现自己遇到的性能问题是因为go版本太低了。
  3. go这一套内存自动管理方案本身很有借鉴意义,如果学习到,碰到相似的业务可以效仿其设计方案。如一些复杂的调度系统。

标记-清除(Stop-the-world)

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

三色标记法+写屏障

三色标记法

  1. 初始时所有的对象节点都被标记成白色
  2. 第一次扫描从根节点出发,把能遍历到的对象从白色集合放到灰色集合
  3. 重复扫描灰色集合,将灰色对象引用的对象从白色集合放到灰色集合,然后把此灰色对象放到黑色集合
  4. 直到灰色集合清空,内存中只有黑白两种颜色。此时可以回收所有白色对象

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

触发三色标记法不安全的必要条件

  1. 白色对象被黑色对象引用
  2. 且白色对象断开了所有灰色对象与它之间的可达关系

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

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

  1. 插入屏障:将B节点挂在A的下游时,B节点会被标记为灰色。 保障了白色节点不会被挂在黑色节点下。
  2. 删除屏障:对删除的对象,如果自身为白色,会被标记为灰色。保障了被删除的白色节点有灰色节点与之链接(对,自己给自己撑腰)。

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

混合写屏障

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

  1. GC开始时会将栈上的所有可达对象标记为黑色
  2. gc期间,栈上新创建的对象会被初始化为黑色

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

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

C 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 []intfor 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的,可以适当打散,减小尖刺。
  2. 使用了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/opBenchmarkVtProtoMarshal-16 1157985 903.8 ns/opBenchmarkJsonMarshal-16 1743318 688.7 ns/opBenchmarkSonicMarshal-16 3684892 335.9 ns/opBenchmarkYamlMarshal-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/opBenchmarkVtProtoMarshal-16 908 1316555 ns/opBenchmarkJsonMarshal-16 666 1792485 ns/opBenchmarkSonicMarshal-16 4705 252298 ns/opBenchmarkYamlMarshal-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/opBenchmarkVtProto-16 559299 2011 ns/opBenchmarkJson-16 375512 3248 ns/opBenchmarkSonic-16 1224876 974.7 ns/opBenchmarkYaml-16 62400 18995 ns/op 大对象:BenchmarkProto-16 369 3260424 ns/opBenchmarkVtProto-16 364 3349230 ns/opBenchmarkJson-16 201 5934529 ns/opBenchmarkSonic-16 1483 798178 ns/opBenchmarkYaml-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

对于一般的程序,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,再尝试锁 mutex1gofunc() { 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. 如算法侧挂了,要展示兜底素材;游戏封面缺失,过滤不展示或者展示兜底封面。边界兜底。

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

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

数据源一致性

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

画面要流畅

数据分页/分屏

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

隐藏高耗时的数据

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

资源大小控制

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

数据实时性分级

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

用户操作限频

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

成本沟通

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

回包大小控制

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

发表评论

泰日号Copyright Your WebSite.Some Rights Reserved. 网站地图 备案号:川ICP备66666666号 Z-BlogPHP强力驱动