Go 是一门很有特色的编程语言,已经被广泛应用到不少领域,随着使用场景的发展,一些性能相关的问题也开始逐渐暴露出来。 本次分享将以字节跳动的性能优化工作为例,介绍基于 Go 生态的微服务体系下,分析系统性能、优化不同层次软件以提升运行性能、提高资源使用效率的一些实践和经验,会特别介绍在 Go 语言 SDK 侧的一些优化工作。
作者 | 陆传胜
微服务是一种将复杂应用拆分为微小的服务单元,每个服务单元都可以独立升级甚至替换,从而实现快速交付和迭代的文化。
字节跳动是对微服务技术使用得非常极致的企业之一:伴随业务的迅速扩张,微服务以其灵活迭代、高可扩展、高度兼容的特性,帮助字节跳动快速建立起一套基础设施系统,满足服务水平扩缩容、业务高速发展变化和不同团队灵活协作的需求。时至今日,字节跳动的在线微服务类型数量已超过 10 万。
但作为一家快速发展的企业,字节特殊的内部业务场景也对微服务落地提出了一些挑战,如:
- 大规模 :一是集群规模非常大,二是业务的领域比较广泛,业务领域涵盖了短视频、内容推荐、电商等各类场景;
- 快迭代 :一是演进速度快,很多新特性被很快发布出来,二是新技术演进快,开发者乐于学习使用新技术;
- 多语言 :字节内部的服务以 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 等信息,然后将它们打散,在不同的维度形成不同的组合并展示。如下图所示,首先我们在集群维度展示一个热力图。
该热力图基于整个业务线的角度,将许多的服务放在一起分析哪条业务线消耗资源最多;同时,我们也会在服务层汇聚一个 profiling 分析;最后我们基于两个角度在组件层定位问题,一是基于平台角度去看指标时是一个自底向上不停组合出不同指标的情况;二是用户在分析时是一个自上而下的钻探视图过程。
优化方案
软件类型一般划分为业务软件和系统软件。其中,SDK/三方库属于业务软件,基础库、语言运行时、容器/OS属于系统软件。业务代码的特征是:写很容易,修改很频繁,它的优化并不具备普适性;系统软件的特征是修改和维护比较费劲,优化具有普适性,可以被推广到很大范围,绝大部分业务都可以受益;同时修改业务软件的收益一般大于修改系统软件。
字节内部的优化方案是体系化优化,在单节点中从上到下,对业务层、基础库组件、编程语言每个层次进行优化,跨节点优化会涉及合并部署。某个性能优化项目数据显示,通过我们的优化手段,CPU 资源大约节约了 19%。
Go 服务性能优化
本章节将具体展示字节内部的 Go 服务性能优化手段和措施,涵盖了从业务到语言的实践过程。
业务层优化
业务层优化面临的挑战主要有两点:
- 服务间的差异性巨大 :比如推送文字服务和推送视频服务的业务代码之间存在很大的差异,难以出现通用优化技术;
- 工具如何更加有效 : 右下图展示了基本的业务代码分析思路,然而事实上大家工作重心不同,并不能要求所有同学都按同一个套路思考; 这时候打造一套好用、高效的工具,降低性能分析的心智负担就很重要了。
关于业务层优化,这里总结了几点比较容易获取收益的优化经验:
- 减少复杂度 : 不过度设计,简单而直接的做法往往会更高效,比如减少网络通信次数和数据量;
- 重视编码规范 : 问题如果能够在项目前期得到解决,将会带来更大的收益;
- 升级组件到“比较新”的版本 : 在控制好稳定性的前提下,新版本的软件一般会带来更好的性能,比如升级 Go v1.17 版本对于 calling convention 的优化具有一定的效果。
这里举一个业务层优化案例:A/B 测试。这是一种用户体验研究方法,被广泛应用于字节跳动产品命名、交互设计、推荐算法、用户增长、广告优化和市场活动等各方面决策上。
一开始我们并不知道 A/B 测试是瓶颈,只是性能平台按照从业务线到组件的方式下钻,会报告出这个组件消耗大量资源,优化之后可能带来可观的收益。
通过分析这个组件的关键特征数据,A/B 测试的参数规模引起了我们注意。下图展示了在较短时间内某个集群上 A/B 测试参数个数的变化情况。随着时间的推移和业务的增长,这个指标发生了巨大变化,同时伴随性能劣化的趋势。
在微服务系统中,众多的微服务都是通过网络松耦合在一起,如果需要将一个 A/B 测试配置传递给链路上的每个服务,将它放到参数中是一个比较简便的做法,事实上之前的系统确实也是这么做的,但是随着配置数据的增长,这个传递变成了性能瓶颈之一。
针对这个问题,我们最后采取的解决方案是短期缩减规模,调整业务系统将 A/B 测试参数进行分割、控制之后,系统达到了 10% 以上的优化效果。中长期来看,优化通信和系统架构,加强监控和审核会是更重要的发展方向。
基础库优化
我们认为能够脱离当前公司运维环境使用的公共代码大概率是属于基础库范畴的,字节跳动将这部分代码中的优秀组件独立成了一个开源项目——gopkg(https://github.com/bytedance/gopkg)。这里面的代码都是经过字节生产环境的残酷考验和反复验证,有较高的实用价值。
“库的设计其实就是语言的设计”,在字节内部我们还把基础库中最常用的优秀组件集成到了语言运行时中,比如各类算法和数据容器,让业务同学开箱即用,不引入额外依赖或修改源码即可受益。同时,我们也尝试向上游开源社区贡献相关代码,让更多人受益,比如近期我们将排序算法 PDQSort 贡献到 Golang 社区,成为 Go1.19 版本的标配。
语言运行时优化
为了实现更高的性能,字节跳动基础架构语言团队对 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 就可以把它分配到栈上。
这个编译器优化效果比较明显,在很多 Benchmark 上都取得了比较好的效果,如下图所示,time/op 越少越好:
调度器优化
调度器的优化思路比较简单,由于微服务面向网络通信,我们将业务代码中的 polling 动作和调度器里面的 polling 动作打通。这里不展开详细介绍,如下图所示:
下图展示了相关性能数据,蓝色部分展示了优化过的效果,可见效果比较理想。
未来展望
关于未来展望,字节语言团队未来主要会关注以下三个方向:
- 极速运行时 : 我们首先会关注如何将加速运行时,比如加入动态代码生成能力、Balanced GC 能力,支持 Adaptive Runtime、定制硬件支持; 同时欢迎对系统软件开发感兴趣的朋友加入我们一起研发;
- 生态友好 :字节在开源上比较活跃,字节内部同学的日常工作与 Github 联系十分紧密。字节内部框架团队同学开源了一系列微服务产品——CloudWeGo ( https://github.com/cloudwego ),欢迎大家使用。虽然字节在开源方面宣传并不多,但确实是一个不断实践开源的组织,之后我们也会将 Go 方面的许多优化开源出来,做一个对生态更友好的组织;
- 工具实践 : 与 其将思路强加给别人,不如将一个好用工具推荐给他们,用工具固化一些最佳实践,让更多用户可以轻松参与性能优化。
扫码二维码,加入字节跳动基础架构语言团队
- END -
近日,由稀土掘金技术社区主办的第二届稀土开发者大会将正式召开。在 7 月 23 日(周六)下午,「 字节跳动云原生实践与开源 」专场将从字节跳动云原生技术历程说起,讲透包括轻量级 K8s 多租户方案、高性能 K8s 元信息存储方案以及大规模集群下的请求治理等关键问题。
点击【 阅读原文 】,立即预约!