Help us learn about your current experience with the documentation. Take the survey.

GraphQL 分页

分页类型

GitLab 使用两种主要的分页类型:offsetkeyset(有时称为基于游标的)分页。 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_atsort_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
end

Keyset 分页

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)

你可以在以下文件中看到示例实现:

测试

任何支持分页和排序的 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