Go 是一门很有特色的编程语言,已经被广泛应用到不少领域,随着使用场景的发展,一些性能相关的问题也开始逐渐暴露出来。本次分享将以字节跳动的性能优化工作为例,介绍基于 Go 生态的微服务体系下,分析系统性能、优化不同层次软件以提升运行性能、提高资源使用效率的一些实践和经验,会特别介绍在 Go 语言 SDK 侧的一些优化工作。
多语言:字节内部的服务以 Go 语言为主,占据 55% 以上,同时兼容了许多其它语言;字节早期创业阶段的微服务主要是使用 Python 进行编写,后期逐步转到 Go 语言。
从编程语言的角度看,Golang 能在字节内部得到大规模应用,离不开它对于微服务的几大优势:
简单易用:上手简单,很多人只需花费一周左右就能开始独立承接任务;
高并发:Go 语言天然适合 I/O 密集场景,支持高并发,能更好地利用多核心 CPU 的能力,很适合编写包含大量网络通信的微服务系统;
性能合适:Go 语言编译速度很快,程序启动也很迅速,同时具有还算不错的运行时性能。
当然,世上没有完美的事物。从性能角度来看,微服务也为字节跳动基础架构团队带来了两个性能代价:通信代价,不同服务之间通过网络进行通信,用户必须压缩数据包,将其变成与平台、语言无关的协议发送出去,由对方解码之后使用,因此会造成通信上的开销。特别是在 Service Mesh 被大规模推广和使用后,通信需要消耗更多的资源;治理负担,微服务架构是一个松耦合架构,其要求各个微服务自发进行演化生长。如果组织缺乏自上向下的管理,很容易导致微服务野蛮生长,造成治理负担。 Go 服务性能分析
集群性能优化一般有如下思路:收集原始性能数据——建立指标体系——跟踪监控异常/手动分析——定位性能瓶颈——优化方案。
需要注意的是,只做一次优化是远远不够的,我们更希望将相关最佳实践做成系统或工具,日常运行下去,在字节内部,我们的做法是构建统一性能平台。 收集原始性能数据
原始数据共有三种来源,一是业务数据,包括 QPS、RT 等;二是系统数据,包括 CPU、内存等;三是运行时数据,包括 PProf 和 FuncProf 数据。
其中,PProf 是通过采样方式,在一秒钟内默认打 100 个点,如果踩到了一个点就相当于占了 1% 时间。字节跳动基础架构语言团队在内部的 Go 发行版增加了 FuncProf 的功能,开始执行时进行计时,停止执行时按下暂停,最后将数据合并。下图展示了数据的流向,我们需要从业务集群拉取业务数据,同时可能还需要和监控系统、运维系统进行交互。
建立指标体系
获取原始数据之后,我们需要依靠指标体系对数据进行分析和判断。指标体系能够帮助我们揭示集群性能特征,回答基本问题(比如性能对不对,是否变差)。同时,指标的选择至关重要,不同的指标选择会导致完全不同的结论。
字节跳动基础架构语言团队秉承着指标选择的规范——保证指标的可扩展性和可迭代性,弱指标强于没指标。该指标可能并不足以完全解释数据,但是能揭示部分问题也比没有指标强。
当衡量 CPU 时,业界有很多成熟的算法,比如将 workload 的使用关系和资源挂钩,这需要该领域的专家协助执行,我们目前采用的方式是单核 QPS。当然,不同类型服务的请求特征是不一样的,比如打包发送视频业务和账户查询业务肯定有完全不同的请求特征;而 CPU 核心的差别更大,芯片技术一直在高速发展,不同型号的 CPU 单核性能可能相差数倍。
然而我们认为“表达能力偏弱的指标强于没有指标”。并且在进行比较时,我们会避免绝对值的比较,尽量采用相对值进行比较,从而更充分地利用原始指标。举一个例子:
上图显示了一天内单节点 CPU 的利用率变化情况,变化幅度大,并且波峰和波谷的差距很大。那么图中哪个时间段对性能分析是有意义的?我们会更关注 T1 时段,即峰值 CPU 利用率。团队将峰值的数据采集完之后,会在集群维度进行一定程度的归一化处理,利用规模效应磨平单点上的偏差。
图中可以看到处理结果呈现单核 QPS 趋势,在实际应用中,这个指标很大程度上能反映系统的性能特征。当然,我们也在尝试更多精细化的分析工作,欢迎对这方面感兴趣的朋友加入我们团队共同探索。 性能追踪
性能追踪方法包括自动和手动两种方法,自动方法是指代码主动识别问题,手动方法需要人工操作去触发。其中,自动发现问题分为两个维度:单机维度和集群维度,我们可以在单机和集群维度上检查是否存在问题并做出响应。
如下图所示,字节内部使用 Agent 在后台自动检测单机是否存在性能瓶颈,如果发现问题,它会通知性能平台及时采样案发现场数据,由此我们可以在单机维度抓取性能下降的数据。
定位性能问题
在分析完性能问题之后,我们需要对具体的组件进行修改。我们的思路是为性能平台用户提供自顶向下的逐步钻探的分析流程。
我们在单机收集数据,包括 CPU 利用率、代码的 Stack 、Frame 等信息,然后将它们打散,在不同的维度形成不同的组合并展示。如下图所示,首先我们在集群维度展示一个热力图。
语言运行时优化
为了实现更高的性能,字节跳动基础架构语言团队对 Go SDK 进行了定制优化,在兼容社区版本的前提下,面向后端服务优化。
一般我们认为 Go SDK 包含两个部分:接口和实现。接口层优化包含语法、标准库和一些常见的命令,比如 go build、go tool 等;而实现层一般是用户不会直接接触的编译器、垃圾回收器、标准库实现等,这部分的改动大部分是对用户代码透明的,用户不用改代码就可以享受收益。
为了达到优化性能的目的,我们的思路是:对接口层只增加不修改;对实现层做有意义的性能改进,并保留切换社区行为的开关。这样既保持和社区生态极高的兼容性,又能对更影响性能的实现逻辑进行高度优化。
内存管理优化
我们认为 Go 的内存管理面临的问题之一是过于为 GC 暂停优化(虽然这是它最大的卖点),它为此付出了分配效率、GC 吞吐等代价。其中最容易在微服务上观察到的问题是:内存分配动作占用过多的 CPU。一些典型服务上大约百分之十几的 CPU 资源都被用来运行内存分配动作,这些动作分散在一次请求处理的各处代码中,最终直接拖慢了整体执行效率。
对于 15% 的代价,我们做了一些详细的分析,发现在字节的微服务系统上,大部分分配的对象都是小对象,并且很多对象都没有指针(Go 会将有指针和无指针的对象存储在不同内存区域),所以我们思考有没有更快的分配思路?
Go 的内存分配使用类似 TCMalloc (https://google.github.io/tcmalloc/) 的分配方式,如下图所示。它的做法是:用户先去查找 mcache,它会通过索引把一个 size 取整到一个固定大小,比如将 19 取整到 24,然后查找 24 对应的 bucket 池, 然后找出一个空 bucket 返回给用户。这种逻辑涉及到 bucket 的查找,分配的不同对象可能位于较远的地址空间,局部较差。
为了简化这部分开销,我们选择了 Bump-pointer 分配方式,如下图所示。Bump-pointer 分配的做法非常简单:使用一个指针 P 指向一段连续的空闲内存空间,需要分配 N 个字节的内存时,就把 P 的值返回给用户,同时执行 P += N 即可。
我们制作了一个特性:GAB(Goroutine-Allocation-Buffer),为每个 Goroutine 保留一块用于 Bump-pointer 分配的 Buffer,让堆内存分配的请求尽量落到这个 Buffer。为什么做 G 这层,而不是 M 或 P 层呢?这是经过测试的经验性结论,G 层效果最好。为了保证兼容性,我们把这个 Buffer 直接映射为 TCMalloc 风格管理的一个 bucket 中,因此它与现有 Go 运行时的管理机制完全兼容。最后效果上表现为一个 TCmalloc 的 bucket 中汇聚了多个 Bump-pointer 快速分配的对象。
对象的分配只是第一步,如果我们从不回收内存,最终还是会 OOM 挂掉。GAB 内存的回收仍然是依赖于 Go 运行时自身的标记-清理算法,如果 bucket 作为一个整体死掉,就可以一次性批量回收大量 GAB 对象,性能很高,微服务的内存使用行为很多时候符合分代假说,所以大部分对象都可以轻松回收。但是如果 bucket 中有少量活跃对象呢?比如少量请求数据被放到了 cache,这样正常路径就无法回收,为此我们制作了 CopyGC 的回收机制,通过移动对象的方式回收空闲空间。
这个特性整体效果比较明显,如下图所示,CPU 占用率降低了 5% ~12%。
编译器 Beast mode
Go 编译器虽然编译速度很快,但是并没有选择生成性能最高的代码,因此字节跳动基础架构语言团队研发了一个额外的编译模式,即编译器 Beast mode。正如隐身战斗机会有个额外的 Beast mode 用于火力压制,编译器 Beast mode 拥有更多的优化手段,执行效率更高。我们选择在开发阶段使用标准编译模式,提高开发效率;发布到线上时使用 Beast mode 编译生成性能更高的二进制。
这里举一个额外优化的例子:常量传播优化。比如说要在 Go 中分配一个 slice ,N 被赋值 1 ,如果后面没有对 N 进行修改,Go 之后会一直将 slice 分配在堆上。当我们进行了常量传播优化之后,这个常量会直接被各个编译器吃掉,Go 就可以把它分配到栈上。