9月6日,中央纪委国家监委网站发布消息,十四届全国政协经济委员会副主任易会满涉嫌严重违纪违法,目前正接受中央纪委国家监委纪律审查和监察调查。易会满曾长...
2025-09-07 0
作者:rossixiao
性能优化是一个经久不衰的课题了,我们都常做。本文列举了很多常用的tips,基本都是我日常开发中遇到的问题,我将这些问题和方法梳理了下来。
这一节会较为详细介绍 go 的内存管理机制,也即 GC。之所以要重点介绍是因为:
STW 是指暂停所有 goroutine,标记可达和不可达的对象,最后清除不可达对象,完成垃圾回收的过程。在 go1.3之前垃圾回收就是依赖的全局 STW,因此性能很低,一次 STW 带来的停顿时间可达数百毫秒。
很多人把三色标记法称为三色并发标记法,因为它存储了对象的中间状态,不需要一次性遍历完。但实际上和程序并发运行时,对象之间的引用关系会发生更改(写操作),而染色会读引用关系,也即发生了读写冲突。这种冲突可能导致白色对象断开和灰色对象的链接,挂在一个黑色对象上,而黑色对象是不会作为扫描的根节点的,因此白色对象被误删除,如图中的对象3。因此内存回收的一个很关键的操作就是把白色对象保护起来,可以延时删除,但不能误删除。
其实只要破坏了这两个必要条件之一,就能避免白色对象被误删除。后面的优化都是围绕着这个规则来的。
go1.5就升级到了三色标记法+写屏障的策略,保证了扫描和程序可以并发执行,无需停顿。但由于栈写操作频繁且要保障运行效率,写屏障只运用到了堆上,如果白色节点被挂在黑色节点上,为了保障安全性,栈还是要进行一次 STW 扫描,以修正状态。这一 STW 停顿一般在10~100ms。
go1.8引入了混合写屏障,避免了对栈的重复扫描,极大减少了 STW 的时间。和写屏障对比,加了以下两个操作:
这样做的道理在,栈的可达对象全标黑了,受颜色保护。而也不会出现白色(不可达)对象被挂在黑色对象的情况,因为它,不可达。
引入混合写之后,以及几乎不需要 STW 了。
已知 go 已经把 STW 压缩到极致了,所以这并非是大多数系统的问题所在,真正消耗性能的是 gc 扫描的计算过程。和 GC 回收相比也是扫描过程更消耗 cpu。
扫描的时机:
内存回收的时机:
利用火焰图,可以看到 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 利用[]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 回收,可以适当调大
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) }
go 不支持
缓存在我们这的使用场景还是挺多的,我们需要合理设计缓存,使得对外接口的平均耗时在100ms 以下。我们曾经有因为没加缓存,在 for 循环中拉游戏详情,导致大部分线程都阻塞在 I/O 等待中,导致接口耗时高,系统吞吐量低。
for appid := range appids { detail := GetDetailInfo(appid) // rpc 调用 ...}
但加缓存并非是无脑加的,缓存本身也可能会到来性能问题。如:
对于耗时很高的非关键路径,要异步化处理,防止阻塞主流程。如一些非关键上报异步处理,再如畅玩好友在玩耗时较高,前端采用了异步加载的方式。
如果资源竞争激烈,很可能会导致锁等待时间太长,和增加调度压力而浪费 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 }() }}
尽管 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。
也即火焰图,伽利略已经集成了火焰图插件,可直接使用。下面一个例子是分析 game_switch 服务的性能瓶颈:
继续往下看,分析出 SsoGetShareTails 主要消耗在 Sprintf 函数上。
到这里已经可以猜测出:
代码定位如下图。序列化和压缩是框架自带的,符合前面的推断---回包太大导致的。
最后看看还存活的堆内存大小。发现是游戏详情的缓存,符合预期。虽然在存活的堆内存里它算大头,但和总的堆内存大小对比还是挺小的,不是目前主要优化点,可降低优先级后续优化。
对于一般的程序,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 的耗时,明显对体验是有损的,使得我们不得不推动下游做性能优化。除此之外也提醒了我不应该仅限于需求开发,而应该从用户的角度出发,思考需求是否合理、是否可优化,协同产品一起保障体验。
有时候产品会忘记提供排序策略,除了被指定的随机资源位,其他应该协调一个排序方案,避免每次刷新都展示不同的内容。
如前段时间遇到的不同页面展示的可领取礼包数不一致,实际是一个页面展示的所有礼包,一个页面没展示贵族礼包,这要求我们开发前和产品沟通数据和哪个需求的页面保持一致。
如果一次请求的数据量太多,不仅会给后台带来性能问题,也可能引起前端前端渲染卡顿了,所以必要时需要沟通设计分页或者分屏(如设计列表页和二级页)。
延迟加载非首屏内容,优先保障核心功能可快速操作。
如资料小卡的游戏段位图,一开始误给了一个1024 1024的高清图,会导致 UI 加载很慢,后续改成了120 120的就能满足清晰度要求。
区分强实时性和弱实时性。如对于礼包数量、游戏在玩人数等允许有一段时间的延时,以减少对 db 的压力。
如已经预约后按钮置灰、限制点击次数等,可以减少接口调用量,也能提高系统用户吞吐量。
如周报 tips,产品要求每个人都生成特有的图片,已知图像处理会带来很多额外的开销,应该提醒产品并切换其他方案。
回包太大不仅会影响服务性能,也会加大网络传输耗时和前端加载耗时。
相关文章
9月6日,中央纪委国家监委网站发布消息,十四届全国政协经济委员会副主任易会满涉嫌严重违纪违法,目前正接受中央纪委国家监委纪律审查和监察调查。易会满曾长...
2025-09-07 0
近日,高通CEO克里斯蒂亚诺・安蒙(Cristiano Amon)接受媒体采访时直言,Intel的芯片制造技术目前仍未达到高通需求,至少对 Snapd...
2025-09-07 0
亲,这款游戏可以开挂的,确实是有挂的,很多玩家在这款游戏中打牌都会发现很多用户的牌特别好,总是好牌,而且好像能看到-人的牌一样。所以很多小伙伴就怀疑这...
2025-09-07 0
8月27日,广东万和新电气股份有限公司(以下简称“万和电气”)在海南隆重举行了“向上·更可靠”32周年品牌战略暨航天新品发布会。万和电气董事长卢宇聪在...
2025-09-07 0
现在人们打棋牌麻将谁不想赢?手机微乐麻将必赢神器但是手机棋牌麻将是这么好赢的吗?在手机上打棋牌麻将想赢,不仅需要运气,也需要技巧。掌握的棋牌麻将技巧就...
2025-09-07 0
物料计量与气流结合,构成气固两相流动,确保物料输送的连续性与稳定性,支持多区域进料与卸料。以下是对计量配料原理的阐述:工作原理配料计量主要依赖空气动力...
2025-09-07 0
发表评论