优化M3: Uber如何通过(简要地)分叉Go编译器来减半我们的指标摄取延迟

0
优化M3: Uber如何通过(简要地)分叉Go编译器来减半我们的指标摄取延迟

在Uber的纽约工程办公室,我们的可观察性团队负责维护一个健壮的、可伸缩的度量和警报管道负责检测、缓解并在服务出现问题时立即通知工程师。监控我们数千个微服务的健康状况,有助于确保我们的平台为全球数百万用户顺畅高效地运行,包括乘客和司机合作伙伴,以及食客和餐厅合作伙伴。

几个月前,例行部署了一个核心服务M3我们的开源指标和监控平台,导致收集指标和将指标持久化到存储的总体延迟增加了一倍,将指标的P99从大约10秒提高到20秒以上。额外的延迟意味着Grafana仪表板与我们内部系统相关的参数将需要更长的时间来加载,我们对系统所有者的自动警报将需要更长的时间来触发。减轻这个问题很简单——我们只是恢复到最后一个已知的良好构建,但是我们仍然需要找出根本原因,这样我们才能修复它。

延迟图
图1:左边的箭头显示了我们典型的端到端延迟,在10秒左右徘徊,偶尔会出现峰值。右边的箭头显示了性能回归后的端到端延迟,我们看到常规峰值上升到20秒。

虽然已经写了很多关于如何分析用Go编写的软件的性能的文章,但大多数讨论的结论都是解释如何用Go可视化CPU和堆配置文件pprof诊断并解决了问题。在这种情况下,是我们的旅程开始使用CPU配置文件和pprof,但当这些工具失败时,我们很快就偏离了轨道,我们被迫回到更原始的工具,如git平分,读取Plan 9程序集,是的,分叉Go编译器。

优步的可观察性

Uber的可观察性团队负责开发和维护Uber的端到端指标平台.在我们摄取平台的体系结构中,如图2所示,主机上的应用程序向本地守护进程(“收集器”)发出指标,该守护进程以1秒的间隔聚合它们,然后将它们发出到聚合层,聚合层进一步以10秒和1分钟的间隔聚合它们。最后,它们被写入M3DB接收器,接收器的主要职责是将它们写入我们的存储层,M3DB

度量输入管道图
图2:在M3的度量摄取管道的高层视图中,度量是从不同的容器发出的UDP到每个主机上运行的名为Collector的本地守护进程。收集器使用接收到的分片感知拓扑etcd将指标转发到我们的聚合层,在那里它们被聚合成10秒和1分钟的块。最后,聚合层将瓦片刷新到各个后端,包括负责将瓦片写入M3DB的M3DB摄取器。

由于M3在摄取上进行聚合的性质,摄取者以预先聚合的瓦片的形式定期接收大量的指标,如下图3所示:

延迟图
图3:M3DB摄取者接收新指标的速率不是恒定的。每隔一段时间,由于聚合层正在创建和刷新各种大小的tile,摄取器将同时接收大量新指标。

因此,M3DB摄取器的行为就像一个临时队列,而摄取器将这些指标写入M3DB的速率控制着我们的端到端延迟。保持这项服务的端到端延迟很重要,因为延迟控制着优步内部团队查看最新指标的速度,以及我们的自动警报检测故障的速度。

角平分线生产

当M3DB摄取器的例行部署使该服务的端到端延迟增加了一倍时,我们从基础开始。我们获取了在生产环境中运行的服务的CPU配置文件,并将其可视化为火焰图像使用pprof。不幸的是,在这张火焰图中,没有什么是突出的原因。

由于我们在CPU概要中没有看到任何明显的东西,我们决定下一步应该是确定引入回归的提交,然后我们可以检查特定的代码更改。事实证明,这比预期的要困难得多,原因如下:

  1. M3DB摄取器已经有几个月没有部署了,在这段时间里做了很多代码更改。很难准确地识别是哪个更改导致了问题,因为摄取服务(以及我们团队的所有其他服务)的代码存储在一个单存储单元中,这使得提交历史非常嘈杂,许多提交与服务根本无关。但是,这些不相关的提交可能会间接地影响依赖性或导致问题。
  2. 这种倒退只在生产工作负载中表现出来,在生产工作负载中,流量往往是尖峰的,并且处于沉重的负载下。因此,我们无法在本地使用微基准测试或在我们的登台/测试环境中重现它。

因此,我们认为识别错误提交的最佳方法是执行git平分,在生产中对我们的提交历史进行二进制搜索。虽然我们最终确定了错误的提交,但即使是git平分也比我们预期的要困难得多,因为错误的提交是在一个依赖的依赖中,这意味着我们必须执行一个三级git平分。换句话说,我们将问题缩小到内部单存储库中的一次提交,该提交更改了开源依赖项(M3DB)的版本,然后将其缩小到存储库中的一次提交,更改了其中一个的版本它的依赖项(M3X),这意味着我们必须git平分这个存储库。

ubermonorepo图
图4:执行git平分显示了M3DB中的版本更改,这反过来又需要对M3DB单存储单元进行另一次git平分,将我们引导到M3X单存储单元。

当一切都说了,做了,我们必须部署我们的服务81查找错误提交并缩小性能回归的次数我们对克隆方法做了一个小改动,如图5所示:

集成开发环境
图5:经过81次尝试后,我们的git平分终于显示出我们对Clone方法所做的一个小更改,该更改以某种方式导致了性能回归。

我们很难相信这个看似无害的改变会使我们的端到端延迟增加一倍,但我们不能忽视证据。如果我们使用左边的代码部署服务(图5),性能回归就会消失,如果我们使用右边的代码部署服务(也是图5),它就会返回。

从决定什么到问为什么

在发现导致这种变化的原因后,我们开始确定为什么这种变化对性能有如此巨大的影响。首先,我们评估了一些更明显的变化方面是否可能是问题所在,比如类型转换引入了额外的分配,或者额外的条件语句破坏了CPU的分支预测。不幸的是,我们很快就用微基准测试推翻了这两个理论。事实上,在我们的基准测试中,这两个函数在性能上根本没有明显的差异,这似乎也排除了函数调用开销作为潜在问题的可能性。此外,即使在进一步简化了新代码之后,如下面的图6所示,我们仍然可以看到生产部署中的倒退:

集成开发环境
图6:我们发现性能回归可以进一步缩小到用辅助函数替换一些现有内联代码的小变化。

我们不确定接下来要做什么,因为我们已经比较了两次提交的CPU配置文件,并且它们在花费的时间上没有差异克隆方法。作为最后的努力,我们决定比较这两种实现的Go程序集。我们使用objdump运行以下命令检查我们的生产二进制文件:

去工具objdump -S | grep /ident/identifier_pool。go -A 500

结果输出如下所示:

日志文件

为这两个函数生成的程序集有细微的差异,例如寄存器分配,但是我们没有注意到任何可能对性能有很大影响的东西,除了cloneBytesHelper函数没有内联。我们不愿意相信函数调用开销是问题的根源,特别是因为它似乎没有影响微基准测试,但这是两种实现之间唯一有意义的差异,似乎它可能会产生任何影响。

当我们检查装配的时候cloneBytes函数,我们注意到它调用runtime.morestack函数,如下所示:

日志文件

这并不奇怪,因为Go编译器必须插入这些函数调用,因为它不能证明这些函数不会超过堆栈的增长(稍后将详细介绍),但它确实将我们的注意力拉回到我们之前观察到的花费在堆栈中的时间量的差异上runtime.morestack函数,如下图7所示:

两张火焰图
图7:这两个火焰图显示了展示了性能回归的代码版本(右)如何显著地花费了更多的时间runtime.morestack函数。

左边的火焰图(图7)显示了在火焰中花费的时间runtime.morestack函数在引入回归之前,右边的函数显示了它在回归之后的函数中花费了多少。当我们最初检查CPU配置文件时,我们忽略了这个差异,因为它在运行时代码中,这是我们无法控制的,并且因为我们专注于试图确定性能上的差异克隆方法做了控制。这实际上是一个巨大的差异;带有回归的代码在这个函数中花费的时间多了50%,74秒的CPU执行时间中有4秒足以解释我们的放缓。

理解Go运行时

但是这个函数是干什么的呢?为了理解这一点,我们需要了解Go运行时是如何管理gorout例程堆栈的。

Go中的每个goroutine都以2 kibibyte的堆栈开始。当分配更多的项和堆栈帧并且所需的堆栈空间量超过分配的量时,运行时将通过分配a来增加堆栈(以2的幂为单位)新堆栈的大小是前一个堆栈的两倍,并将旧堆栈中的所有内容复制到新堆栈中。

千比特堆栈比较图
图8:runtime.morestack函数将通过暂停执行、分配新堆栈、将旧堆栈复制到新堆栈,然后恢复函数调用来增加需要更多堆栈空间的Goroutine的堆栈。

这给了我们一个新的理论:现有的代码运行非常接近于必须增长其堆栈的边界,并且额外调用cloneBytesHelper方法将它推到边缘,导致额外的堆栈增长发生。

这种增长足以导致我们所看到的回归,与我们的CPU配置文件一致,也解释了为什么我们无法用微基准测试重现这个问题。当我们运行微基准测试时,我们的调用堆栈非常浅,但是在生产中克隆方法被调用了30次函数调用(如图9所示)。因此,性能差异只能在我们调用函数的特定上下文中观察到。

图9:在生产环境中,我们的堆栈有超过30个函数调用,这使得触发堆栈增长问题成为可能。然而,在我们的基准测试中,堆栈非常浅,我们不太可能超过默认的堆栈大小。

我们想要一个快速简单的方法来验证这个理论。M3DB摄取器的工作方式是,将指标写入M3DB的所有繁重工作都由一个实例创建的gorout例程执行这个工人群

重要的代码在图10中重现:

代码
图10:Go中常用的杠杆模式是使用通道作为控制并发的信号量。只有在保留了令牌之后才能执行工作,因此并发的总量受到令牌数量(换句话说,通道的大小)的限制。

对于每一批传入的写操作,我们分配一个新的gor例程。工作通道,标记为workCh变量充当信号量,限制在任何时候可以活动的goroutine的最大数量。这让摄取器表现得像一个队列,并缓冲我们的高峰值工作负载,因此即使发送到M3DB摄取器的指标数量非常高,M3DB接收到的写入在较长时间内都是平滑的。

如果我们的理论是正确的,那么我们可以通过重用goroutine来缓解这个问题,而不是不断地产生新的goroutine。虽然Go运行时最初为每个新的gor例程分配2 kibibyte的堆栈,并根据需要增加它们,但在gor例程被垃圾收集之前,它永远不会释放扩展的堆栈。(这背后的真相其实有点复杂。在某些情况下,运行时可能会尝试将例程“移动”到更小的堆栈中,但从统计上来说,goroutine需要为任何给定的函数调用增长其堆栈的可能性要低得多)。

为了验证我们的理论,我们写了一个新员工池这将预先生成所有的gorout例程,然后使用几个不同的“工作通道”(以减少锁争用)将工作分配到gorout例程,而不是为每个请求创建一个新的gorout例程。

代码
图11:工作池的实现采用了不同的方法。我们没有使用令牌来限制可以生成的goroutine的数量,而是预先生成所有的goroutine,然后使用通道为它们分配工作。这仍然将并发性限制在指定的极限,但防止我们不得不一遍又一遍地分配新的goroutine堆栈。

我们假设这种方法应该可以防止现有实现中出现的过多的堆栈增长。虽然每个goroutine在第一次运行有问题的代码时仍然需要增加它的堆栈,但在随后的调用中,它应该能够将其堆栈框架扩展到已经分配的内存中,而不会产生额外的堆分配和堆栈复制的成本。

为了安全起见,我们还包含了一个小概率,即每个goroutine在每次完成一些工作时终止并为自己生成一个替换,以防止具有过大堆栈的goroutine永远保留在内存中。这种额外的预防措施可能过于热心了,但我们从经验中了解到,只有偏执狂才能生存。

我们使用新的工作人员池部署了我们的服务,并很高兴地看到花费在runtime.morestack函数下降明显,如下图12所示:

火焰图像
图12:在新的工作池中,花费的时间runtime.morestack甚至比我们引入性能回归之前还要低。

此外,我们的端到端延迟实际上比我们引入回归之前下降得更多,如下图13所示:

延迟图
图13:新的工作池非常有效,即使我们使用最初导致性能问题的代码部署服务,端到端延迟仍然比引入回归之前要低。这意味着有了新的工作人员池,我们可以安全地编写代码,而不必担心常规堆栈增长的成本。

有趣的是,一旦我们开始使用新的工作池实现,那么使用哪个版本的克隆()我们使用的性能方法是相同的,不管是否cloneBytes ()Helper是否内联。这是很有希望的,因为这意味着未来的工程师不需要担心他们的更改会重新引入这个问题,这也为我们的堆栈增长理论提供了额外的可信度。

找到确凿的证据

即使在看到这些结果之后,我们仍然觉得自己没有充分证明导致性能下降的根本原因。例如,如果我们的性能节省只是由于不需要不断生成新的goroutine或一些我们还不完全理解的其他过程?

就在那时,我们偶然发现了这个github的问题其中一位来自CockroachDB团队遇到了类似的与大堆栈大小相关的性能问题,并设法通过分叉Go编译器和添加额外的插装(读取:打印语句)来证明堆栈增长是原因。

我们决定做同样的事情,但是由于我们计划使用fork编译器来构建一个生产服务,所以我们引入了打印语句的采样,以防止过多的日志记录导致服务速度大大降低。具体来说,我们修改了newstack函数,每次goroutine需要增长堆栈时都会调用它,这样每调用1000次,它就会打印一个堆栈跟踪,以便我们可以看到哪些代码路径触发了堆栈增长。

日志文件

接下来,我们使用fork Go编译器和仍然具有性能回归的提交来编译我们的服务。我们将其发布到生产环境中,几乎立即开始看到日志,这些日志显示goroutine堆栈的增长发生在有问题的代码周围:

日志文件

我们现在有证据表明,堆栈增长往往发生在有问题的代码周围,在本例中,似乎goroutine堆栈从4 kibibytes增长到8 kibibytes,这对于每个请求来说是一个巨大的分配。但这还不够。我们需要知道怎么做经常它发生了,以及引入回归的代码是否更有可能触发堆栈增长。

我们再次使用fork编译器构建了我们的服务,这一次使用了三次不同的提交,并测量了在两分钟内发生了多少次类似于上面的堆栈增长:

提交 抽样平均事件数
与回归 15685年
回归修复 3465年
有了新的员工队伍 171

有了这些测量,我们更加有信心,我们已经彻底解决了问题的根源,我们的新员工队伍将防止类似的流氓问题在未来再次出现。更重要的是,我们晚上终于可以睡觉了,因为我们真正的理解这个问题。

关键的外卖

整个研究的端到端延迟摄入回归M3两名工程师花了大约一周的时间,从检测回归,从根源原因到发布修复到生产。我们学到了一些重要的经验:

  1. 当试图分离困难的问题时,通常需要一种系统的方法。我们能够将问题缩小到几行代码,因为我们有条不紊地使用git平分。
  2. 尽可能地从根源上引起问题,可以更好地理解问题,在我们的例子中,还可以带来更好的性能。我们本可以回滚更改并就此结束,但在本例中,进一步使我们能够在回归之前将端到端摄取延迟减少一半。这意味着今后我们只需要一半的硬件就可以维持相同的sla。
  3. 深入了解编程语言的内部结构对于进行性能优化非常重要,特别是在分析工具不满足要求的情况下(这种情况比您想象的要多)。
  4. 在Go中,对象池很重要,但是程序池也很重要。

最后,我很幸运,谷歌Go工程团队的一名成员在Uber的NYC Go聚会上看到了我关于这个问题的演讲,并邀请了我在Go GitHub存储库上提交一个关于它的问题,然后改进了运行时分析这样,时间就花在了runtime.morestack现在正确地归因于触发堆栈增长的函数调用,以便其他工程师将来可以更容易地诊断此问题。我们非常感谢Go团队,感谢他们积极地处理和解决影响生产系统的问题。

如果你有任何问题或者只是想讨论优步的M3度量标准堆栈,加入M3DB Gitter通道

一定要去参观Uber的官方开源页面查看有关M3和其他项目的更多信息。

如果你对大规模应对基础设施挑战感兴趣,可以考虑申请一个角色在我们队。

评论