GraphQL 批量加载器
GitLab 使用 batch-loader Ruby gem 来优化并避免 N+1 SQL 查询。
正是 GraphQL 查询树的特性创造了这种批量处理的机会——断开的节点可能需要相同的数据,但它们无法相互知晓。
何时应该使用它?
在 GraphQL 查询 执行期间,我们应该尽可能批量处理数据库请求。在 变更 操作中不需要批量加载,因为它们是顺序执行的。如果您需要进行数据库查询,并且可以将两个相似(但不一定完全相同)的查询合并,那么可以考虑使用 batch-loader。
在实现新端点时,我们应该尽量减少 SQL 查询的数量。为了稳定性和可扩展性,我们还必须确保我们的查询不会受到 N+1 性能问题的影响。
实现
当一系列针对输入 Qα, Qβ, ... Qω 的查询可以合并为针对 Q[α, β, ... ω] 的单个查询时,批量加载很有用。一个例子是通过 ID 查找,我们可以通过用户名以查找一个用户的成本来查找两个用户,但现实世界的例子可能更复杂。
当结果集具有不同的排序顺序、分组、聚合或其他不可组合的特性时,批量加载不适用。
在代码中有两种使用 batch-loader 的方法。对于简单的 ID 查找,使用 ::Gitlab::Graphql::Loaders::BatchModelLoader.new(model, id).find。对于更复杂的情况,您可以直接使用 batch API。
例如,要通过 username 加载 User,我们可以按如下方式添加批量处理:
class UserResolver < BaseResolver
type UserType, null: true
argument :username, ::GraphQL::Types::String, required: true
def resolve(**args)
BatchLoader::GraphQL.for(username).batch do |usernames, loader|
User.by_username(usernames).each do |user|
loader.call(user.username, user)
end
end
end
endusername是我们要查询的用户名。它可以是一个或多个名称。loader.call用于将结果映射回输入键(这里用户被映射到其用户名)BatchLoader::GraphQL返回一个惰性对象(获取数据的延迟承诺)
这里有一个 示例 MR 说明了如何使用我们的 BatchLoading 机制。
BatchModelLoader
对于 ID 查找,建议使用 BatchModelLoader:
def project
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Project, object.project_id).find
end要预加载关联,您可以传递一个关联数组:
def issue(lookahead:)
preloads = [:author] if lookahead.selects?(:author)
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Issue, object.issue_id, preloads).find
end它究竟是如何工作的?
每个惰性对象都知道它需要加载数据以及如何批量处理查询。当我们需要使用惰性对象(通过调用 #sync 来通知)时,它们会与当前批次中所有其他相似的对象一起加载。
在代码块中,我们对我们的项目(User)执行批量查询。之后,我们只需要通过传递在 BatchLoader::GraphQL.for 方法中使用的项目(usernames)和加载的对象本身(user)来调用 loader:
BatchLoader::GraphQL.for(username).batch do |usernames, loader|
User.by_username(usernames).each do |user|
loader.call(user.username, user)
end
endbatch-loader 使用代码块的位置来确定哪些请求属于同一个队列,但每个批次只评估一个代码块实例。您无法控制是哪一个。
因此,重要的是:
- 代码块不能引用(捕获)对象上的任何实例状态。最佳实践
是在
for(data)调用中将代码块需要的所有数据传递给它。 - 代码块必须针对特定类型的批量数据。实现通用
加载器(如
BatchModelLoader)是可能的,但需要使用 单射的key参数。 - 批次不会共享,除非它们引用相同的代码块——两个具有相同行为、参数和键的相同代码块
不会共享。因此,永远不要自己实现批量 ID 查找,而是使用
BatchModelLoader以 实现最大共享。如果您看到两个字段定义了相同的批量加载,考虑 将其提取到一个新的Loader中,并让它们共享。
惰性是什么意思?
避免过早同步批次(强制评估)很重要。下面的示例展示了过早调用 sync 如何消除批量处理的机会。
这个示例过早地在 x 上调用 sync:
x = find_lazy(1)
y = find_lazy(2)
# calling .sync will flush the current batch and will inhibit maximum laziness
x.sync
z = find_lazy(3)
y.sync
z.sync
# => will run 2 queries然而,这个示例等待所有请求都排队,从而消除了额外的查询:
x = find_lazy(1)
y = find_lazy(2)
z = find_lazy(3)
x.sync
y.sync
z.sync
# => will run 1 query在批量加载的使用中没有依赖分析。有一个 待处理的请求队列,一旦需要任何结果,所有待处理的 请求都会被评估。
您永远不应该在解析器代码中调用 batch.sync 或使用 Lazy.force。
如果您依赖一个惰性值,请使用 Lazy.with_value:
def publisher
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Publisher, object.publisher_id).find
end
# Here we need the publisher to generate the catalog URL
def catalog_url
::Gitlab::Graphql::Lazy.with_value(publisher) do |p|
UrlHelpers.book_catalog_url(publisher, object.isbn)
end
end我们通常在通过 GitlabSchema.find_by_gid 或 .object_from_id 查找记录后在变更操作中使用 #sync,因为这些方法返回的结果被包装在批量加载器中。变更操作是顺序执行的,因此不需要批量加载,对象可以立即被评估。
测试
理想情况下,使用请求规范和 Schema.execute 进行所有测试。如果
这样做,您不需要自己管理惰性值的生命周期,并且
可以确保结果准确。
返回惰性值的 GraphQL 字段在测试中可能需要强制这些值。 强制指的是明确要求评估,这通常 由框架安排。
您可以使用 GraphQLHelpers 中可用的 GraphqlHelpers#batch_sync 方法,或使用 Gitlab::Graphql::Lazy.force 来强制惰性值。例如:
it 'returns data as a batch' do
results = batch_sync(max_queries: 1) do
[{ id: 1 }, { id: 2 }].map { |args| resolve(args) }
end
expect(results).to eq(expected_results)
end
def resolve(args = {}, context = { current_user: current_user })
resolve(described_class, obj: obj, args: args, ctx: context)
end我们还可以使用 QueryRecorder 来确保每次调用只执行 一个 SQL 查询。
it 'executes only 1 SQL query' do
query_count = ActiveRecord::QueryRecorder.new { subject }
expect(query_count).not_to exceed_query_limit(1)
end