合并请求性能指南
每个新引入的合并请求都应默认具备高性能。
为确保合并请求不会对 GitLab 的性能产生负面影响,每个合并请求都必须遵循本文件中概述的准则。除非与后端维护者和性能专家特别讨论并达成一致,否则此规则无例外。
强烈建议您阅读以下指南:
定义
根据 RFC 2119,术语 SHOULD 的含义为:
该词或形容词 “RECOMMENDED” 表示在特定情况下可能存在忽略某一项的有效理由,但在选择不同方案前,必须充分理解并谨慎权衡其全部影响。
理想情况下,每个此类权衡应在单独的问题中进行记录,标注相应标签并与原始问题和史诗(epic)关联。
影响分析
摘要:思考您的合并请求可能对性能及维护 GitLab 部署的人员造成的影响。
提交的任何变更不仅可能影响应用程序本身,还可能影响维护它的人员以及使其保持运行的人员(例如生产工程师)。因此,您应仔细考虑合并请求对应用程序及维持其运行的人员的影响。
所使用的查询是否可能使关键服务宕机并导致工程师夜间被叫醒?恶意用户能否滥用代码使 GitLab 实例宕机?我的变更是否会使某些页面加载变慢?当数据库中的负载或数据足够大时,执行时间是否会呈指数级增长?
这些都是提交合并请求前应自问的问题。有时评估影响可能较困难,此时应要求性能专家审查您的代码。更多细节见下文“评审”部分。
性能评审
摘要:如果您不确定影响,可请求性能专家评审代码。
有时难以评估合并请求的影响。在这种情况下,您应要求其中一位合并请求评审者审查您的变更。(评审者列表 可供参考。)评审者进而可请求性能专家评审变更。
跳出思维定式
每个人都有自己对新功能的使用方式认知。始终考虑用户可能如何使用该功能。通常,用户会以非常规方式测试我们的功能,例如通过暴力破解或滥用我们未覆盖的边缘条件。
数据集
合并请求处理的数据集应是已知且已记录的。该功能应明确说明此功能处理预期数据集的情况,以及可能引发的问题。
若思考以下示例——该示例着重强调了处理的数据集: 问题很简单:您想从某个 Git 仓库过滤文件列表。您的功能请求获取仓库中所有文件的列表并对文件集执行搜索。作为作者,在此问题背景下,您应考虑以下几点:
- 计划支持哪些仓库?
- 像Linux内核这样的大型仓库需要多长时间?
- 我们能否采取其他措施来避免处理如此大的数据集?
- 是否应构建某种故障安全机制以控制计算复杂度?通常,为单个用户提供降级服务比影响所有用户更好。
查询计划与数据库结构
查询计划可以告诉我们是否需要额外的索引,或者昂贵的过滤操作(例如使用顺序扫描)。
每个查询计划都应该针对大量数据集运行。
例如,如果你查找具有特定条件的issues,你应该考虑在少量(几百个)和大量(10万)issues上验证查询。观察当结果数量是几个到几千个时查询的表现。
这是必要的,因为我们的用户会用GitLab处理非常大的项目,并且以非常规的方式使用。即使看起来不太可能用到这么大的数据集,但我们的某个客户仍有可能遇到该功能的问题。
提前了解其在规模上的表现(即使我们接受现状),是期望的结果。我们应该始终有一个计划或理解,知道如何优化该功能以应对更高的使用模式。
每个数据库结构都应该进行优化,有时甚至过度描述,以便于扩展。发展到一定阶段后最困难的部分是数据迁移。迁移数百万行总是很麻烦,可能会对应用程序产生负面影响。
若想更好地了解如何获取查询计划审查的帮助,请阅读本节:如何准备数据库审查的合并请求。
查询次数
摘要:除非绝对必要,否则合并请求不应增加执行的SQL查询总数。
由合并请求修改或添加的代码所执行的查询总数必须不增加,除非绝对必要。在构建功能时,你可能确实需要一些额外查询,但应尽量减少。
举个例子,假设你引入一个功能,用相同值更新多个数据库行。用以下伪代码编写可能非常诱人(且简单):
objects_to_update.each do |object|
object.some_field = some_value
object.save
end这意味着每更新一个对象就执行一次查询。如果有足够多的待更新行或许多并行运行的此代码实例,这段代码很容易使数据库过载。这个特定问题被称为“N+1查询问题”。你可以编写一个使用QueryRecorder的测试来检测这一点并防止回归。
在这个特定情况下,解决方法相当简单:
objects_to_update.update_all(some_field: some_value)这使用了ActiveRecord的update_all方法在一次查询中更新所有行。这使得这段代码更难导致数据库过载。
尽可能使用只读副本
在数据库集群中,我们有许多只读副本和一个主库。扩展数据库的经典用法是将只读操作交给副本执行。我们使用 负载均衡 来分配这些负载。这使得副本能够随着数据库压力的增长而扩展。
默认情况下,查询会使用只读副本,但由于 主库粘滞,GitLab 会暂时使用主库,并在副本追上或 30 秒后切换回从库。这样做会导致主库产生大量不必要的负载。
为防止切换到主库,合并请求 56849 引入了 without_sticky_writes 块。通常,此方法可用于防止在 trivial 或无关紧要的写入后发生主库粘滞,这类写入不会影响同一会话中的后续查询。
若想了解何时更新使用时间戳会导致会话粘滞到主库,以及如何通过 without_sticky_writes 防止这种情况,请参阅 合并请求 57328。
作为 without_sticky_writes 方法的对应方案,合并请求 59167 引入了 use_replicas_for_read_queries。此方法强制其块内的所有只读查询无论当前主库粘滞状态如何,都读取副本。
该工具仅适用于查询能容忍复制延迟的情况。
内部而言,我们的数据库负载均衡器会根据查询的主要语句(如 select、update、delete 等)对查询进行分类。若有疑问,它会将查询重定向到主库。因此,存在一些负载均衡器不必要地将查询发送到主库的常见情况:
- 自定义查询(通过
exec_query、execute_statement、execute等) - 只读事务
- 正在进行的连接配置设置
- Sidekiq 后台作业
上述查询执行后,GitLab 会 粘滞到主库。
编写自定义只读 SQL 查询时,应使用 select_all 而非 execute,以便查询尽可能使用只读副本。使用 select_all 还可防止查询缓存被清除。
为了让事务和其他模糊查询优先使用副本,合并请求 59086 引入了 fallback_to_replicas_for_ambiguous_queries。此 MR 也是我们将一个昂贵且耗时的查询重定向到副本的示例。
明智地使用 CTEs
阅读有关 关系对象上的复杂查询 的注意事项,以了解如何使用 CTEs。我们在某些场景中发现,CTEs 可能会成为问题(类似于上述的 N+1 问题)。特别是像 AuthorizedProjectsWorker 中使用的分层递归 CTE 查询,非常难以优化且无法扩展。在实现需要任何层次结构的新功能时,我们应避免使用它们。
在许多简单的情况下,CTEs 已被有效用作优化屏障,例如此 示例。在使用支持的 PostgreSQL 版本时,必须通过 MATERIALIZED 关键字启用优化屏障行为。默认情况下,CTEs 是内联的,然后 默认优化。
构建 CTE 语句时,请使用 Gitlab::SQL::CTE 类。默认情况下,此 Gitlab::SQL::CTE 类通过添加 MATERIALIZED 关键字强制物化。
升级到 GitLab 14.0 需要 PostgreSQL 12 或更高版本。
缓存查询
摘要: 合并请求不应执行重复的缓存查询。
Rails 提供了一个 SQL 查询缓存,用于在请求期间缓存数据库查询的结果。
请参阅 为什么缓存查询被认为是不好的 和 如何检测它们。
合并请求引入的代码不应执行多个重复的缓存查询。
由合并请求修改或添加的代码执行的查询总数(包括缓存的)不应增加,除非绝对必要。执行的查询数量(包括缓存查询)不应依赖于集合大小。
您可以通过将 skip_cached 变量传递给 QueryRecorder 来编写测试以检测此问题并防止回归。
举个例子,假设你有一个 CI 管道。所有管道构建都属于同一个管道,因此它们也属于同一个项目(pipeline.project):
pipeline_project = pipeline.project
# Project Load (0.6ms) SELECT "projects".* FROM "projects" WHERE "projects"."id" = $1 LIMIT $2
build = pipeline.builds.first
build.project == pipeline_project
# CACHE Project Load (0.0ms) SELECT "projects".* FROM "projects" WHERE "projects"."id" = $1 LIMIT $2
# => true当我们调用 build.project 时,它不会命中数据库,而是使用缓存结果,但它会重新实例化相同的管道项目对象。事实证明,关联的对象并不指向内存中的相同对象。
如果我们尝试序列化每个构建:
pipeline.builds.each do |build|
build.to_json(only: [:name], include: [project: { only: [:name]}])
end它会为每个构建重新实例化项目对象,而不是使用相同的内存对象。
在这种情况下,解决方法相当简单:
ActiveRecord::Associations::Preloader.new(records: pipeline, associations: [builds: :project]).call
pipeline.builds.each do |build|
build.to_json(only: [:name], include: [project: { only: [:name]}])
endActiveRecord::Associations::Preloader 为相同的项目使用相同的内存对象。这避免了缓存的 SQL 查询,并且避免了为每个构建重新实例化项目对象。
在循环中执行查询
摘要: 除非绝对必要,否则不得在循环中执行 SQL 查询。
在循环中执行 SQL 查询可能导致根据循环迭代次数执行许多查询。这对于数据较少的开发环境可能没问题,但在生产环境中,这种情况可能会迅速失控。
有些情况可能需要这样做。如果是这种情况,应在合并请求描述中明确提及。
批处理过程
摘要: 对外部服务(例如 PostgreSQL、Redis、对象存储)的单个进程进行迭代应以批处理方式执行,以减少连接开销。
有关以批处理方式从各种表中获取行的信息,请参见 预加载 部分。
示例:从对象存储中删除多个文件
当您从对象存储(如 GCS)中删除多个文件时,多次执行单个 REST API 调用是一个相当昂贵的过程。理想情况下,这应该以批处理方式进行,例如 S3 提供 批量删除 API,因此考虑这种方法是个好主意。
FastDestroyAll 模块可能有助于这种情况。这是一个小型框架,当您以批处理方式删除大量数据库行及其相关数据时使用。
超时
摘要: 当系统调用外部服务(如 Kubernetes)的 HTTP 调用时,应设置合理的超时时间,并且应在 Sidekiw 中执行,而不是在 Puma 线程中。
通常,GitLab 需要与外部服务(如 Kubernetes 集群)通信。在这种情况下,很难估计外部服务何时完成所请求的处理,例如,如果它是由于某种原因不活跃的用户拥有的集群,GitLab 可能会永远等待响应(示例)。这可能导致 Puma 超时,应不惜一切代价避免。
您应设置合理的超时时间,优雅地处理异常,并在 UI 或内部日志记录中显示错误。
使用 ReactiveCaching 是获取外部数据的最佳解决方案之一。
保持数据库事务最小化
摘要:你应该避免在数据库事务期间访问外部服务(如 Gitaly),否则会导致严重的竞争问题,因为开放的事务基本上会阻塞 PostgreSQL 后端连接的释放。
为了尽可能减少事务,可以考虑使用 AfterCommitQueue 模块或 after_commit AR 钩子。
这里有一个示例,其中一个对 Gitaly 实例的请求在事务期间触发了约 “priority::1” 问题。
预加载
摘要:当检索多个数据库记录且需要使用任何关联时,必须预加载这些关联。
当你检索多个数据库记录并需要使用任何关联时,你必须预加载这些关联。例如,如果你正在检索博客文章列表并想显示其作者,则必须预加载作者关联。
换句话说,不要这样做:
Post.all.each do |post|
puts post.author.name
end而应该这样做:
Post.all.includes(:author).each do |post|
puts post.author.name
end还应考虑使用QueryRecoder 测试来防止预加载时的回归。
内存使用
摘要:合并请求不得增加内存使用量,除非绝对必要。
合并请求不得增加 GitLab 的内存使用量超过代码所需的最低限度。这意味着如果你需要解析某些大型文档(例如 HTML 文档),最好尽可能以流式方式解析它,而不是将整个输入加载到内存中。有时这不可行,在这种情况下,应在合并请求中明确说明这一点。
UI 元素的延迟渲染
摘要:仅在确实需要时才渲染 UI 元素。
某些 UI 元素可能并非总是需要。例如,当悬停在 diff 行上时,会显示一个小图标,可用于创建新评论。与其始终渲染此类元素,不如仅在真正需要时才渲染它们。这确保了我们不会在不使用时花费时间生成 Haml/HTML。
缓存的使用
摘要:当数据在事务中多次使用或需保存一段时间时,将其缓存在内存或 Redis 中。
有时某些数据位需要在事务的不同位置重用。在这些情况下,应将这些数据缓存在内存中以消除运行复杂操作获取数据的需要。如果数据应缓存一段时间而非事务持续时间,则应使用 Redis。
例如,假设你处理包含用户名提及的多个文本片段(例如 Hello @alice 和 How are you doing @alice?)。通过为每个用户名缓存用户对象,我们可以消除为每次 @alice 提及运行相同查询的需要。
按事务缓存数据可以使用RequestStore(使用 Gitlab::SafeRequestStore 以避免记住检查 RequestStore.active?)。在 Redis 中缓存数据可以使用Rails 缓存系统。
分页
每个呈现项目列表作为表格的功能都需要包含分页。
主要的分页样式有:
- 偏移分页:用户前往特定页面,例如第 1 页。用户看到下一页编号和总页数。此样式受 GitLab 所有组件的良好支持。
- 无计数的偏移分页:用户前往特定页面,例如第 1 页。用户仅看到下一页编号,但看不到总页数。
- 使用键集分页的下一页:用户只能前往下一页,因为我们不知道有多少页可用。
- 无限滚动分页:用户滚动页面,下一项异步加载。这是理想的,因为它具有与前一种完全相同的好处。
分页的最终可扩展解决方案是使用基于键集的分页。但是,目前 GitLab 尚不支持此功能。你可以通过查看API:键集分页了解进展。
选择分页策略时需考虑以下几点:
- 计算通过过滤的对象数量非常低效,此操作通常需要几秒钟,并且可能会超时,
- 获取更高序号页面的条目非常低效,例如第 1000 页。数据库必须对所有先前项目进行排序和迭代,此操作通常会对数据库造成大量负载。
你可以在分页指南中找到与分页相关的有用提示。
徽章计数器
计数器应始终被截断。这意味着当数值超过某个阈值时,我们不希望展示精确数字。原因是,当我们想计算项目的确切数量时,实际上需要对每个项目进行筛选以了解匹配的项目数量。
从用户体验角度看,看到你有超过1000个流水线,而非40000多个流水线,通常是可接受的,但这要以页面加载时间增加2秒为代价。
这种模式的示例是流水线和作业列表。我们将数字截断为1000+,但会显示正在运行的流水线的准确数量,这是最有趣的信息。
有一个可用于此目的的辅助方法——NumbersHelper.limited_counter_with_delimiter——它接受一个计数行的上限。
在某些情况下,希望徽章计数器异步加载。这能加快初始页面加载速度,并整体上提供更好的用户体验。
功能开关的使用
每个具有性能关键元素或已知性能缺陷的功能都需要附带功能开关来禁用它。
功能开关让我们的团队更满意,因为他们可以监控系统并快速响应,而用户不会察觉到问题。
性能缺陷应在我们合并初始更改后立即解决。
阅读更多关于何时以及如何使用功能开关的信息,请参阅GitLab开发中的功能开关。
存储
我们可以考虑以下类型的存储:
-
本地临时存储(非常短期的存储) 这种存储是系统提供的存储,例如
/tmp文件夹。对于所有临时任务,你理应使用这种类型的存储。每个节点都有自己的临时存储这一事实显著简化了扩展过程。这种存储通常基于SSD,因此速度要快得多。应用程序可以通过使用TMPDIR变量轻松配置本地存储。 -
共享临时存储(短期存储) 这种存储是基于网络的临时存储,通常由公共NFS服务器运行。截至2020年2月,我们的大多数实现仍在使用这种类型的存储。尽管这允许上述限制大幅扩大,但这并不意味着你可以使用更多。共享临时存储由所有节点共享。因此,使用大量该空间或执行大量操作的作业会在整个应用程序中创建对所有其他作业和请求执行的争用,这很容易影响整个GitLab的稳定性。请尊重这一点。
-
共享持久存储(长期存储) 这种存储使用基于网络的共享存储(例如,NFS)。这种解决方案主要由运行少量节点的小型安装客户使用。共享存储上的文件易于访问,但任何上传或下载数据的作业都会对其他所有作业造成严重争用。这也是Omnibus默认采用的方法。
-
基于对象的持久存储(长期存储) 这种存储使用外部服务,如AWS S3。对象存储可以视为无限可扩展且具备冗余性。访问此存储通常需要先下载文件才能操作。对象存储可被视为终极解决方案,因为它本质上可以假设能够处理无限的并发文件上传和下载。这也是确保应用程序能够在容器化部署(Kubernetes)中顺利运行的必要终极方案。
临时存储
生产节点的存储非常稀疏。应用程序应设计为能在非常有限的临时存储下运行。你可以预期你的代码所运行的系统总共拥有1G-10G的临时存储。然而,这个存储实际上是所有正在运行的作业共享的。如果你的作业需要使用超过100MB的空间,你应该重新考虑你所采取的方法。
无论你的需求是什么,如果你需要处理文件,都应明确记录下来。如果需要超过100MB,可以考虑向维护者寻求帮助,与他们合作寻找可能的更好解决方案。
#### Local temporary storage
本地临时存储的使用是一种理想的解决方案,
特别是在我们将应用程序部署到 Kubernetes 集群的情况下。
何时使用 `Dir.mktmpdir`?例如,当您想要解压/创建归档文件、对现有数据进行大量操作等情况。
```ruby
Dir.mktmpdir('designs') do |path|
# 对路径进行操作
# 一旦我们离开代码块,该路径将被删除
endShared temporary storage
如果您打算为基于磁盘的存储保留文件,而不是对象存储,则需要使用共享临时存储。
Workhorse 直接上传 在接收文件时可以将其写入共享存储,之后 GitLab Rails 可以执行移动操作。
在同一目标上的移动操作是即时的。
系统不会执行 copy 操作,而是将文件重新附加到新位置。
由于这会给应用程序带来额外的复杂性,您应该只尝试重用成熟的模式(例如 ObjectStorage 模块),而不是重新实现它。
对于所有其他用途,不建议使用共享临时存储。
Persistent storage
持久化存储
Object Storage
对象存储
所有持有持久文件的特性都必须支持将数据保存到对象存储。 跨节点以共享卷形式存在的持久化存储不具备可扩展性,因为它会导致所有节点的数据访问竞争。
GitLab 提供 ObjectStorage 模块 实现了对基于共享和对象存储的持久化存储的无缝支持。
Data access
数据访问
每个接受数据上传或允许下载数据的特性都需要使用 Workhorse 直接上传。这意味着上传需要由 Workhorse 直接保存到对象存储中, 并且所有下载都需要由 Workhorse 提供。
通过 Puma 执行上传/下载是一个昂贵的操作, 因为它会在整个处理槽位(线程)被阻塞的时间内阻止处理。
通过 Puma 执行上传/下载也存在一个问题,即操作可能会超时, 这对于慢速客户端尤其成问题。如果客户端花费很长时间来上传/下载, 则处理槽位可能会因请求处理超时而被终止(通常在 30 秒到 60 秒之间)。
出于上述原因,要求对所有文件上传和下载都实现 Workhorse 直接上传。