也许每个程序员都知道肯特·贝克(Kent Beck)的话:“使之工作,使其正确,使其快速”。 首先,您需要使程序正常运行,然后需要使其正确运行,然后才可以进行优化。

这篇文章的作者(我们已发表该文章的翻译)说,最近他决定对他的开源Go-project
Flipt进行概要分析。 他想在项目中找到可以轻松优化的代码,从而加快程序速度。 在分析过程中,他在Flipt用来组织路由和中间件支持的流行开源项目中发现了一些意外问题。 结果,可以将应用程序在操作期间分配的内存量减少100倍。 反过来,这又减少了垃圾收集操作的数量,并提高了项目的整体性能。 就是这样。
高流量产生
在开始进行性能分析之前,我知道首先需要生成大量进入应用程序的流量,这将有助于我了解其行为的某些模式。 在这里,我立即遇到一个问题,因为我没有什么东西可以在生产中使用Flipt并获得一些流量,这些流量可以让我评估负载下的项目工作。 结果,我找到了一个用于负载测试项目的好工具。 这是
贝吉塔 。 该项目的作者说,Vegeta是用于负载测试的通用HTTP工具。 这个项目源于对HTTP服务进行加载的需要,该服务需要以给定的频率向服务发送大量请求。
事实证明,Vegeta项目正是我需要的工具,因为它使我能够创建对应用程序的连续请求流。 有了这些请求,您就可以根据需要对应用程序进行“外壳化”,以找出指示符,例如堆上的分配/内存使用情况,goroutines的功能以及在垃圾回收上花费的时间。
经过一些实验后,我转到了Vegeta启动的以下配置:
echo 'POST http://localhost:8080/api/v1/evaluate' | vegeta attack -rate 1000 -duration 1m -body evaluate.json
此命令以
attack
模式启动
Vegeta ,在一分钟内以每秒1000个请求的速度发送HTTP POST
REST API Flipt请求 (诚然,这是一个沉重的负担)。 Flipt发送的JSON数据并不是特别重要。 仅对于正确形成请求主体才需要它们。 Flipt服务器收到了这样的请求,该服务器可以执行请求
验证过程。
请注意,我首先决定测试Flipt
/evaluate
。 事实是,它运行实现项目逻辑并执行“复杂”服务器计算的大多数代码。 我认为分析此端点的结果将为我提供有关可以改进的应用程序领域的最有价值的数据。
测量值
现在,我已经有了生成足够大量流量的工具,我需要找到一种方法来衡量此流量对正在运行的应用程序的影响。 幸运的是,Go拥有一套相当不错的标准工具,可以衡量程序性能。 关于
pprof软件包。
我将不介绍使用pprof的细节。 我认为我会做得比朱莉娅·埃文斯(Julia Evans)更好,他写了
这篇精彩的文章,介绍了如何使用pprof对Go程序进行概要分析(如果您还没有读过,我绝对建议您看一下)。
由于Flipt中的HTTP路由器是使用
go-chi / chi实现的 ,因此使用
适当的 Chi中间处理程序启用pprof对我来说并不困难。
因此,Flipt在一个窗口中为我工作,而Vegeta(在请求中填充Flipt)在另一个窗口中工作。 我启动了第三个终端窗口,以收集和检查堆概要分析数据:
pprof -http=localhost:9090 localhost:8080/debug/pprof/heap
它使用Google pprof工具,该工具可以直接在浏览器中可视化分析数据。
首先,我检查了
inuse_objects
和
inuse_space
,以了解堆上正在发生的事情。 但是,我找不到任何出色的东西。 但是当我决定看一下
alloc_objects
和
alloc_space
,有一些事情使我感到
alloc_space
。
分析结果分析( 原始 )感觉
flate.NewWriter
分配了19370 MB的内存一分钟。 顺便说一句,这超过了19 GB! 显然,这里发生了奇怪的事情。 但是到底是什么呢? 如果仔细观察上图的原始内容,就会发现从
gzip.(*Writer).Write
调用了
flate.NewWriter
,而从
middleware.(*compressResponseWriter).Write
flate.NewWriter
调用了
gzip.(*Writer).Write
。 我很快意识到发生的事情与Flipt代码无关。 问题出在用于压缩来自API响应的
Chi中间件代码中。
// r.Use(middleware.Compress(gzip.DefaultCompression))
我注释了以上内容,然后再次运行测试。 不出所料,大量的内存分配操作已消失。
在寻求解决此问题的方法之前,我想从另一端看一下这些内存分配操作,并了解它们如何影响性能。 我特别对它们对程序收集垃圾的时间产生的影响感兴趣。 我记得Go仍然有一个
跟踪工具,使您可以在程序执行过程中对其进行分析,并在特定时间段内收集有关它们的信息。 通过跟踪收集的数据包括诸如堆使用率,正在执行的goroutine的数量,有关网络和系统请求的信息,以及对我来说特别有价值的有关在垃圾收集器上花费的时间的信息等重要指标。
为了有效地收集有关正在运行的程序的信息,我需要减少使用Vegeta每秒发送给应用程序的请求数量,因为服务器会定期给我
socket: too many open files
错误
socket: too many open files
。 我以为这是因为
ulimit
在我的计算机上设置得太低,但是我当时不想进入。
因此,我使用以下命令重新启动了Vegeta:
echo 'POST http://localhost:8080/api/v1/evaluate' | vegeta attack -rate 100 -duration 2m -body evaluate.json
结果,如果将其与以前的方案进行比较,则只有十分之一的请求被发送到服务器,但是这样做的时间更长。 这使我可以收集有关程序工作的高质量数据。
在另一个终端窗口中,我运行了以下命令:
wget 'http://localhost:8080/debug/pprof/trace?seconds=60' -O profile/trace
结果,我可以随意使用一个文件,其中包含60秒内收集的跟踪数据。 您可以使用以下命令检查此文件:
go tool trace profile/trace
该命令的执行导致在浏览器中发现收集的信息。 它们以方便的图形形式呈现出来供研究。
可以在
这篇优秀文章中找到有关
go tool trace
详细信息。
Flipt跟踪结果。 堆上的内存分配的锯齿图清晰可见( 原始 )在此图上,很容易看出,分配在堆上的内存量倾向于快速增长。 在这种情况下,增长之后应该会急剧下降。 分配内存下降的位置是垃圾回收操作。 在这里,您可以在GC区域中看到明显的蓝色列,代表花费在垃圾收集上的时间。
现在,我已经收集了我需要的所有“犯罪”证据,可以开始寻找分配内存问题的解决方案。
解决问题
为了找出调用
flate.NewWriter
导致如此多的内存分配操作的原因,我需要查看
Chi源代码。 为了找出我使用的是哪个版本的Chi,我运行了以下命令:
go list -m all | grep chi github.com/go-chi/chi v3.3.4+incompatible
到达源代码
chi / middleware / compress.go @ v3.3.4之后 ,我能够找到以下方法:
func encoderDeflate(w http.ResponseWriter, level int) io.Writer { dw, err := flate.NewWriter(w, level) if err != nil { return nil } return dw }
在进一步的研究中,我发现通过中间处理程序调用
flate.NewWriter
方法的每个响应。 这对应于我之前看到的大量内存分配操作,每秒向API加载一千个请求。
我不想拒绝压缩API响应或寻找新的HTTP路由器和新的中间件支持库。 因此,我首先决定通过更新Chi来找出是否有可能解决我的问题。
我运行
go get -u -v "github.com/go-chi/chi"
,升级到Chi 4.0.2,但是在我看来,用于数据压缩的中间件代码看起来与以前相同。 当我再次运行测试时,问题并没有消失。
在结束此问题之前,我决定在Chi存储库中查找提及“压缩中间件”之类的问题或PR消息。 我遇到了一个带有以下标题的PR:“重新编写中间件压缩库”。 该PR的作者说:“此外,sync.Pool用于编码器,该编码器具有Reset方法(io.Writer),可减少内存负载。”
在这里! 幸运的是,此PR已添加到
master
分支中,但是由于未创建新的Chi版本,因此我需要像这样进行更新:
go get -u -v "github.com/go-chi/chi@master"
这个令我非常高兴的更新是向后兼容的,它的使用不需要更改我的应用程序的代码。
结果
我运行了负载测试并再次进行了性能分析。 这使我能够验证Chi更新是否解决了该问题。
现在flate.NewWriter使用以前使用的已分配内存量的百分之一( 原始 )再次查看跟踪结果,我发现堆大小现在增长得慢得多。 另外,垃圾收集所需的时间减少了。
再见-“锯”( 原始 )一段时间后,我
更新了Flipt存储库,比以前更有信心,我的项目将能够充分应对高负载。
总结
这是我设法找到并解决上述问题后所得出的结论:
- 您不应该基于这样的假设,即开源库(甚至是流行的库)已经过优化,或者它们没有明显的问题。
- 一个无辜的问题可能会导致严重的后果,尤其是在重负荷下会导致“多米诺效应”的表现。
- 如果可能,应使用sync.Pool 。
- 随身携带用于测试负载项目并对其进行性能分析的工具非常有用。
- 使用工具包和开源-太好了!
亲爱的读者们! 您如何研究Go项目的性能?
