Go应用中热路径的迭代优化

在Samsara,我们为客户提供实时数据,同时从数千个连接设备中每分钟摄取数百万个事件。为此提供支持的软件是迅雷,一个内部构建的开源GraphQL框架。迅雷由多个部分组成,包括一个名为sqlgen的SQL生成器。这篇文章介绍了我们如何向sqlgen添加一个功能,分配增加了33%,内存增加了66%,以及我们如何在一些优化后返回基线数。

构建新功能:添加对JSON列的支持

什么是sqlgen?

sqlgen是一个轻量级的伪ORM,它将数据库表映射到它们的Go表示并减少样板代码。在我们深入研究我们添加的功能或其设计之前,让我们建立对sqlgen的基本级别的理解。

给定的表和相应的模型:

在sqlgen中表示如下:

在数据库中存储JSON

在产品基础架构团队中,我们所做的部分工作是确定阻止我们的工程团队使用良好实践并消除它们的摩擦点。我们看到的一种这样的模式是使用JSON blob在数据库中存储数据。sqlgen没有一个简单的方法来做到这一点,所以人们被迫这样的事情:

如你所见,编码和解码某些配置数据的简单操作为配置添加了第二种表示形式,以及任何读取或写入路径上的附加步骤(在本例中为Update和ById)。

我们希望我们的API很简单,并且对数据库之外的数据使用一种表示。我们想要的是这样的:

规划:Wrangling Go界面

要在sqlgen中添加JSON支持,我们必须包装我们的(de)序列化层以允许任意转换。值得庆幸的是,Go的sql包支持这一点。

在下面的声明中,有两个序列化方向:

*Go SQL (id)

*SQL Go (name)

Go SQL

当SQL驱动程序的参数转换器将id序列化为SQL值时,驱动程序将自动处理driver.Values的转换:int64,float64,bool,[] byte,string和time.Time - 以及driver.Valuer接口

SQL Go

类似地,在读取路径上,Scan支持指向driver.Values的指针类型,以及sql.Scanner接口。

有了这些信息,我们现在知道要实现哪些接口来处理JSON(反)序列化,以及使用哪些类型!

经过讨论和原型设计,我们为sqlgen的类型系统着手了这个设计:

Descriptorkeeps跟踪所需的所有类型信息,并提供为值生成Scanner和Valuer代理的功能。Valuer将struct值转换为driver.Value。最后,Scanner将driver.Value转换回我们的struct值。

使其成为现实:识别和解决性能问题

我们实施了上述计划,并增加了测试和基准。然后,我们将此更改部署到我们的canary GraphQL服务器,以评估其在实际工作负载下的性能。事实证明,没有像生产数据这样的数据。在内存分配方面,代码效率低得令人无法接受。

我们的基准测试表明,分配增加了33%。如果现实世界的影响不大,我们认为这是可以接受的。然而,我们的金丝雀测试显示随着时间的推移,内存使用量增加了66%。是时候回到绘图板了。

使用pprof查找内存问题

我们做的第一件事就是看看我们的一些分析工具。对于本地分析,我们强烈建议你在官方Go博客上查看此帖子。在Samsara,当我们的服务器低于预期负载时,我们会自动运行pprof。这为我们提供了对内存分配的代表性见解。

看一下我们的pprof报告的SVG表示,我们可以看到一些关于问题可能出现的提示:

查看我们的inuse_space报告,我们看到Descriptor.Scanner上使用了新的3%的分配,以及Scanner.Scan中使用的2.8%的分配。

这些数字可能看起来很低,但我们的缓存消耗了大约75%的堆。我们怀疑这些额外的分配给Go的垃圾收集器带来了压力。我们还认为我们可以为Go的逃逸分析提供更好的提示,以防止其中一些分配。

从基准开始

尝试查找问题或提高性能时,一个很好的起点是编写基准。这是Go非常容易的事情。为此,我们创建了两个基准。我们的Go基准测试,我们在sqlgen中的CRUD路径的集成测试,作为我们的微基准测试。我们的宏基准是一个测试服务器,我们使用我们专门为此构建的工具运行模拟流量。

我们的原始分配看起来像这样:

而我们的新代码在读取路径上的分配数量增加了33%:

当针对测试服务器运行模拟的只读流量负载时,我们能够从这些分配中重现内存增加66%。我们设定了目标:减少分配并获得原始内存使用量的10%。我们主要关注的是读取路径,因为这是我们最热门的代码路径。

优化1:按值传递

只需查看代码,第一次优化就变得清晰了。扫描仪和估价器保持描述符指针。但是,我们创建新的Valuers和Scanner的方法没有使用* Descriptor。Go函数是按值传递的,这意味着传递给函数的所有值,甚至指针都是副本。因为我们正在复制值,而不是指向值的指针,所以我们在堆上为每个Valuer和Scanner分配一个全新的描述符。

通过一些快速调整:

我们能够显着减少分配的对象和字节:

优化2:切断中间人

我们在我们的pprof报告中注意到我们的反映。新的分配增加了15%。我们最初的方法是使用reflect.New值初始化Descriptor.Scanner,我们将使用CopyTo方法将其移动到我们的模型结构中。但是,通过跨越几个方法边界,我们使得模型的中间值被堆分配。

我们决定了一种直接分配给最终模型结构的新方法。

而不是扫描到一个新值,然后复制到我们的模型,我们直接针对我们的最终目的地:

通过削减中间人,我们能够减少模型上每列的额外分配。

这导致另一次记忆减少7.5%,使我们比基线高出39%。朝着我们的目标稳步前进!

优化3:重新使用分配

pprof还告诉我们,大约3%的非缓存内存用于Descriptor.Scanner。如果我们查看代码,我们可以看到我们的扫描程序正在转移到堆,因为我们将它们传递给rows.Scan。Go的标准库有sync.Pool,这是一个允许我们重用分配的API,只有在并发访问时才创建新的分配。

我们知道什么?我们知道我们的列几乎总是一起访问。我们还怀疑我们并不经常同时反序列化数据,因为IO时序可能会将访问分散开来。因此,我们应该能够为每个表创建一个sync.Pool并重新使用相同的扫描程序。

如果我们运行我们的基准测试,我们会立即降低每个基准行的4个分配。这是有道理的,因为我们的基准不是并发的,所以我们应该在100%的时间内重新使用池。当我们运行流量模拟时,我们看到CPU和响应时间保持不变,内存使用率下降8%(比基线高28%)。更接近我们目标的又一步。

优化4:防止复杂数据类型的转义

在这一点上,我们已经完成了pprof提供的提示,并尽可能地进行了优化。我们主要关注Scanner,因为它是读取路径优化最明显的候选者。

但是,我们的其他Valuerabstraction是我们尚未真正检查的东西,并且在制作WHERE子句时它在读取路径上被广泛使用。

值得注意的是,interface {}就像跨越函数边界的指针一样。这意味着当我们分配我们的估价值时,它将逃到堆上。

因为我们将接口{} ...传递给我们的SQL驱动程序,所以我们知道必须将值分配给堆。但是,我们的值比有效的驱动程序更昂贵。值将是,因为它至少包括它自己和描述符引用。

我们在这里可以做的是进行更改,以便我们只允许更简单的数据类型转义到堆:

并验证我们的基准:

我们的阅读基准没有下降 - 这符合我们的预期,因为只有在包含WHERE时它才会下降。我们可以做的是添加一个额外的“读取位置”基准:

每个过滤器值导致2分配减少。但是对真实传递的影响是什么?我们再次运行我们的交通模拟以找到......

内存使用率下降了17%,这意味着我们现在只使用比基线多5.6%的内存。任务完成!

其他优化

我们考虑了其他优化但未实现的优化。 通过将它们与使用基本SQL驱动程序进行比较,我们可以使基准测试更具信息性。 我们还可以在初始运行时预先计算自定义类型,从而节省后续代码路径上的CPU周期。

我们可能会进行大量其他优化。 在快速移动和快速编写代码之间总是需要权衡。 由于我们已经达到了将原始内存使用率提高到10%以内的目标,因此我们很高兴现在就可以使用它。

向前进

自从进行这些分配改进以来,我们进行了另一个生产金丝雀版本以测试新实现的执行方式。 CPU使用率和内存消耗几乎与我们的主分支相同。

从启动此优化路径开始,我们将GraphQL服务器的内存使用量降低了50%,以与添加新功能之前的内存使用量相匹配。 我们甚至降低了读取路径分配,这将导致随着时间的推移减少垃圾收集。

最重要的是,我们能够为JSON字段发送sqlgensupport!

  • 发表于:
  • 原文链接:https://kuaibao.qq.com/s/20181129A0BB9H00?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券