GraphQL 分页
分页类型
GitLab 使用两种主要的分页类型:offset 和 keyset(有时称为基于游标的)分页。 GraphQL API 主要使用 keyset 分页,在需要时回退到 offset 分页。
性能考虑
更多信息请参阅通用分页指南部分。
Offset 分页
这是传统的逐页分页方式,最常见,并在 GitLab 的许多地方使用。你可以通过页面底部的页码列表来识别它,选择这些页码会将你带到相应的结果页面。
例如,当你选择第 100 页时,我们向后端发送 100。例如,如果每页有 20 个项目,后端会计算 20 * 100 = 2000,并通过偏移(跳过)前 2000 条记录来查询数据库,然后获取接下来的 20 条。
页码 * 页面大小 = 我的记录位置这有几个问题:
-
性能。当我们查询第 100 页(偏移量为 2000)时,数据库必须扫描到该特定偏移量,然后取出接下来的 20 条记录。随着偏移量的增加,性能会迅速下降。 更多信息请阅读 我爱的 SQL <3. 高效分页包含 1 亿条记录的表格。
-
数据稳定性。当你获取第 100 页的 20 个项目(偏移量为 2000)时,GitLab 显示这 20 个项目。如果有人在第 99 页或之前删除或添加记录,偏移量为 2000 的项目会变成不同的项目集合。你甚至可能遇到一种情况,在分页时跳过某些项目,因为列表在不断变化。 更多信息请阅读 分页:你可能做错了。
Keyset 分页
给定任何特定记录,如果你知道如何计算它后面的内容,你可以查询数据库获取这些特定记录。
例如,假设你有一个按创建日期排序的问题列表。如果你知道页面上的第一个项目有特定日期(比如 1 月 1 日),你可以请求所有在该日期之后创建的记录,并取前 20 个。无论删除或添加了多少记录,这都不再重要,因为你总是请求该日期之后的记录,从而获得正确的项目。
不幸的是,没有简单的方法知道 1 月 1 日创建的问题是在第 20 页还是第 100 页。
Keyset 分页的一些优点和权衡:
-
性能好得多。
-
对最终用户来说数据更稳定,因为由于删除或插入,记录不会从列表中丢失。
-
这是实现无限滚动的最佳方式。
-
编程和维护更困难。对于
updated_at和sort_order很简单,但对于复杂的排序场景则复杂(或不可能)。
实现
当查询支持分页时,GitLab 默认使用 keyset 分页。你可以在 pagination/connections.rb 中看到配置位置。如果查询返回 ActiveRecord::Relation,则自动使用 keyset 分页。
这是为了支持性能和数据稳定性而做出的有意选择。
然而,在某些情况下,我们必须使用 offset 分页连接,OffsetActiveRecordRelationConnection,例如在按标签优先级对问题进行排序时,因为排序的复杂性。
如果你的解析器返回的关系不适合 keyset 分页(例如由于排序顺序),你可以使用 BaseResolver#offset_pagination 方法将关系包装在正确的连接类型中。例如:
def resolve(**args)
result = Finder.new(object, current_user, args).execute
result = offset_pagination(result) if needs_offset?(args[:sort])
result
endKeyset 分页
Keyset 分页实现是 GraphQL::Pagination::ActiveRecordRelationConnection 的子类,它是 graphql gem 的一部分。这被安装为所有 ActiveRecord::Relation 的默认值。但是,GitLab 不使用基于偏移量的游标(这是默认的),而是使用更专业的游标。
游标是通过编码一个包含相关排序字段的 JSON 对象来创建的。例如:
ordering = {"id"=>"72410125", "created_at"=>"2020-10-08 18:05:21.953398000 UTC"}
json = ordering.to_json
cursor = Base64.urlsafe_encode64(json, padding: false)
"eyJpZCI6IjcyNDEwMTI1IiwiY3JlYXRlZF9hdCI6IjIwMjAtMTAtMDggMTg6MDU6MjEuOTUzMzk4MDAwIFVUQyJ9"
json = Base64.urlsafe_decode64(cursor)
Gitlab::Json.parse(json)
{"id"=>"72410125", "created_at"=>"2020-10-08 18:05:21.953398000 UTC"}在游标中存储排序属性值的好处:
- 如果只存储对象的 ID,可以查询对象及其属性。这将需要额外的查询,如果对象不再存在,则所需的属性不可用。
- 如果属性是
NULL,则可以使用一个 SQL 查询。如果不是NULL,则可以使用不同的 SQL 查询。
根据游标中正在排序的主要属性字段是否为 NULL,构建适当的查询条件。最后一个排序字段被认为是唯一的(主键),意味着该列从不包含 NULL 值。
查询复杂度
我们只支持两个排序字段,其中一个字段必须是主键。
以下是两个查询的伪代码示例:
-
双条件查询。
X代表游标中的值。C代表数据库中的列,按升序排序,使用:after游标,NULL值排在最后。X1 IS NOT NULL AND (C1 > X1) OR (C1 IS NULL) OR (C1 = X1 AND C2 > X2) X1 IS NULL AND (C1 IS NULL AND C2 > X2)以下是基于关系
Issue.order(relative_position: :asc).order(id: :asc)和游标relative_position: 1500, id: 500的示例:when cursor[relative_position] is not NULL ("issues"."relative_position" > 1500) OR ( "issues"."relative_position" = 1500 AND "issues"."id" > 500 ) OR ("issues"."relative_position" IS NULL) when cursor[relative_position] is NULL "issues"."relative_position" IS NULL AND "issues"."id" > 500 -
三条件查询。 以下示例不完整,但显示了添加一个条件的复杂性。
X代表游标中的值。C代表数据库中的列,按升序排序,使用:after游标,NULL值排在最后。X1 IS NOT NULL AND (C1 > X1) OR (C1 IS NULL) OR (C1 = X1 AND C2 > X2) OR (C1 = X1 AND X2 IS NOT NULL AND ((C2 > X2) OR (C2 IS NULL) OR (C2 = X2 AND C3 > X3) OR X2 IS NULL.....
通过使用
Gitlab::Graphql::Pagination::Keyset::QueryBuilder,
我们能够构建必要的 SQL 条件并将它们应用到 Active Record 关系中。
复杂的查询可能难以或无法使用。例如,
在 issuable.rb 中,
order_due_date_and_labels_priority 方法创建了一个非常复杂的查询。
这些类型的查询不受支持。在这些情况下,你可以使用 offset 分页。
注意事项
不要使用字符串语法定义集合的顺序:
# Bad
items.order('created_at DESC')相反,使用哈希语法:
# Good
items.order(created_at: :desc)第一个示例不会正确地将排序信息(如上面的 created_at)嵌入到分页游标中,这将导致错误的排序顺序。
Offset 分页
有时,排序的复杂性超过了我们的 keyset 分页能够处理的范围。
例如,在 ProjectIssuesResolver 中,
当按 priority_asc 排序时,我们不能使用 keyset 分页,因为排序过于复杂。更多信息,请阅读 issuable.rb。
在这种情况下,我们可以通过返回
Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection
而不是 ActiveRecord::Relation 来回退到常规的 offset 分页:
def resolve(parent, finder, **args)
issues = apply_lookahead(Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all)
if non_stable_cursor_sort?(args[:sort])
# 某些复杂排序尚未被稳定游标分页支持。
# 在这些情况下,我们使用 offset 分页,因此返回正确的连接。
offset_pagination(issues)
else
issues
end
end外部分页
有时,你可能需要通过 GitLab API 返回存储在另一个系统中的数据。在这些情况下,你可能需要对第三方 API 进行分页。
一个例子是我们的 错误跟踪 实现,我们通过 GitLab API 代理 Sentry 错误。我们通过调用 Sentry API 来实现这一点,该 API 强制执行自己的分页规则。这意味着我们无法访问 GitLab 中的集合来执行我们自己的自定义分页。
为了保持一致性,我们根据外部 API 返回的值手动设置分页游标,使用 Gitlab::Graphql::ExternallyPaginatedArray.new(previous_cursor, next_cursor, *items)。
你可以在以下文件中看到示例实现:
types/error__tracking/sentry_error_collection_type.rb它为field :errors添加了一个扩展。resolvers/error_tracking/sentry_errors_resolver.rb它从解析器返回数据。
测试
任何支持分页和排序的 GraphQL 字段都应该使用
graphql/sorted_paginated_query_shared_examples.rb
中的排序分页查询共享示例进行测试。它有助于验证你的排序键是否兼容,以及游标是否正常工作。
使用 keyset 分页时,这尤其重要,因为某些排序键可能不受支持。
在你的请求规范中添加一个部分,如下所示:
describe 'sorting and pagination' do
...
end然后你可以使用
issues_spec.rb
作为构建测试的示例。
graphql/sorted_paginated_query_shared_examples.rb
还包含一些关于如何使用共享示例的文档。
共享示例需要设置某些 let 变量和方法:
describe 'sorting and pagination' do
let_it_be(:sort_project) { create(:project, :public) }
let(:data_path) { [:project, :issues] }
def pagination_query(params)
graphql_query_for( :project, { full_path: sort_project.full_path },
query_nodes(:issues, :id, include_pagination_info: true, args: params))
)
end
def pagination_results_data(nodes)
nodes.map { |issue| issue['iid'].to_i }
end
context 'when sorting by weight' do
let_it_be(:issues) { make_some_issues_with_weights }
context 'when ascending' do
let(:ordered_issues) { issues.sort_by(&:weight) }
it_behaves_like 'sorted paginated query' do
let(:sort_param) { :WEIGHT_ASC }
let(:first_param) { 2 }
let(:all_records) { ordered_issues.map(&:iid) }
end
end
end