ページネーションのガイドライン

このドキュメントでは、GitLab、特にPostgreSQLのデータに対するページネーションの現在の機能の概要とベストプラクティスを説明します。

なぜページ分割が必要なのでしょうか?

ページネーションは、1回のウェブリクエストで多くのデータを読み込まないようにするための一般的なテクニックです。これは通常、レコードのリストをレンダリングするときに起こります。よくあるシナリオは、UI上で親子関係を視覚化することです。

例: プロジェクト内のイシューの一覧表示

プロジェクト内のイシューの数が増えるにつれて、リストは長くなります。リストを表示するために、バックエンドは次のようにします:

  1. データベースからレコードを読み込みます。
  2. レコードをRubyでシリアライズします。Ruby(ActiveRecord)オブジェクトを構築し、JSONまたはHTML文字列を構築します。
  3. レスポンスをブラウザに送り返します。
  4. ブラウザはコンテンツをレンダリングします。

コンテンツのレンダリングには2つのオプションがあります:

  • HTML: バックエンドがレンダリングを行います (HAML テンプレート)。
  • JSON: クライアント(クライアントサイドJavaScript)がペイロードをHTMLに変換します。

長いリストのレンダリングは、フロントエンドとバックエンドの両方のパフォーマンスに大きく影響します:

  • データベースはディスクからたくさんのデータを読み込みます。
  • クエリの結果(レコード)は最終的にRubyオブジェクトに変換されるため、メモリ割り当てが増加します。
  • レスポンスが大きいと、ユーザーのブラウザに送信するのに時間がかかります。
  • 長いリストをレンダリングすると、ブラウザがフリーズする可能性があります(ユーザーエクスペリエンスが低下します)。

ページ分割では、データは同じ部分(ページ)に分割されます。最初の訪問では、ユーザーは限られた数のアイテム(ページサイズ)しか受け取りません。ページネーションによってユーザーはより多くの項目を見ることができますが、その結果、新しい HTTP リクエストと新しいデータベースクエリが発生します。

Project issues page with pagination

ページ分割の一般的なガイドライン

正しいアプローチの選択

ページネーション、フィルタリング、データ検索はデータベースに任せましょう。バックエンド(paginate_array from Kaminari)やフロントエンド(JavaScript)でインメモリのページ分割を実装することは、数百レコード程度であればうまくいくかもしれません。アプリケーションの制限が定義されていない場合、物事はすぐに制御不能になる可能性があります。

複雑さを減らす

ページ上でレコードを一覧表示する場合、追加のフィルタやさまざまなソートオプションを提供することがよくあります。これはバックエンド側を非常に複雑にします。

MVCバージョンでは、次のように考えます:

  • ソートオプションの数を最小限に減らします。
  • フィルター(ドロップダウンリスト、検索バー)の数を最小限に減らしてください。

ソートとページ分割を効率的に行うには、各ソートオプションに対して少なくとも2つのデータベースインデックス(昇順、降順)が必要です。もしフィルタオプション(州別や作成者別)を追加すれば、良いパフォーマンスをメンテナーするためにもっとインデックスが必要になるかもしれません。インデックスは無料ではなく、UPDATE クエリの所要時間に大きく影響します。

すべてのフィルタとソートの組み合わせを性能の良いものにすることは不可能なので、使用パターンによって性能を最適化するようにしましょう。

スケーリングの準備

オフセットベースのページネーションはレコードをページ分割する最も簡単な方法ですが、 大きなデータベーステーブルではうまく拡張できません。長期的な解決策としては、キーセットのページ分割が望ましいです。オフセット・ページ処理とキーセット・ページ処理の切り替えは一般的に簡単で、以下の条件を満たせばエンドユーザーに影響を与えることなく行えます:

  • トータルカウントの表示を避け、リミットカウントを優先します。
    • 例: 最大1001レコードをカウントし、UI上でカウントが1001の場合は1000+を表示し、そうでない場合は実際の数を表示します。
    • 詳しくはバッジカウンターのアプローチを参照してください。
  • ページ番号の使用は避け、次ページと前ページのボタンを使用してください。
    • キーセットのページネーションはページ番号をサポートしていません。
  • APIに関しては、次のページのURLを “手作業 “で構築しないようにアドバイスしてください。
    • 次のページと前のページのURLがバックエンドによって提供されるLink ヘッダー の使用を昇格させてください。
    • こうすることで、後方互換性を壊すことなくURL構造を変更することができます。
note
ページ番号が公開されないので、無限スクロールはユーザーエクスペリエンスに影響を与えることなくキーセットのページ分割を使うことができます。

ページネーションのオプション

オフセット

リストをページ分割する最も一般的な方法は、オフセットベースのページ分割です(UIとREST API)。これは人気のあるKaminariRuby gemによってサポートされており、ActiveRecordクエリにページ分割を実装する便利なヘルパーメソッドを提供しています。

オフセットベースのページ分割は、LIMITOFFSET SQL句を活用してテーブルから特定のスライスを取り出します。

プロジェクト内のイシューの2ページ目を探す場合のデータベースクエリの例:

SELECT issues.* FROM issues WHERE project_id = 1 ORDER BY id LIMIT 20 OFFSET 20
  1. 架空のポインタをテーブルの行の上に移動し、20行スキップします。
  2. 次の20行を取ります。

このクエリでは、主キー (id) によっても行の順序が指定されていることに注意してください。データをページ分割する場合、順序を指定することは非常に重要です。これがないと、返される行が非決定的になり、エンドユーザーを混乱させる可能性があります。

ページ番号

ページネーションバーの例

Page selector rendered by Kaminari

Kaminari gemはUI上にページ番号と、オプションでクイックショートカットの次、前、最初、最後のページボタンを表示します。これらのボタンを表示するために、Kaminariは行数を知る必要があり、そのためにカウントクエリが実行されます。

SELECT COUNT(*) FROM issues WHERE project_id = 1

パフォーマンス

インデックス・カバレッジ

良いパフォーマンスを得るためには、ORDER BY 節をインデックスでカバーする必要があります。

以下のようなインデックスがあるとします:

CREATE INDEX index_on_issues_project_id ON issues (project_id);

最初のページをリクエストしてみましょう:

SELECT issues.* FROM issues WHERE project_id = 1 ORDER BY id LIMIT 20;

Railsでも同じクエリを作成できます:

Issue.where(project_id: 1).page(1).per(20)

SQLクエリはデータベースから最大20行を返します。ただし、データベースがディスクから20行だけ読み取って結果を返すわけではありません。

このようなことが起こります:

  1. データベースは、テーブルの統計情報と利用可能なインデックスに基づいて、可能な限り最も効率的な方法で実行を計画しようとします。
  2. プランナは、project_id 列をカバーするインデックスがあることを知っています。
  3. データベースはproject_id のインデックスを使用して全ての行を読み取ります。
  4. この時点の行はソートされていないので、データベースは行をソートします。
  5. データベースは最初の20行を返します。

プロジェクトの行数が10,000行の場合、データベースは10,000行を読み込み、メモリ上(あるいはディスク上)でソートします。これは長期的にはうまくスケールしません。

これを解決するには、以下のインデックスが必要です:

CREATE INDEX index_on_issues_project_id ON issues (project_id, id);

id 列をインデックスの一部にすることで、前述のクエリは最大20行を読み取ります。このクエリはプロジェクト内のイシュー数に関係なくうまく実行されます。この変更により、初期ページのロード(ユーザーがイシュー・ページをロードしたとき)も改善されました。

note
ここでは、b-treeデータベース・インデックスの順序付きプロパティを活用しています。インデックス内の値はソートされているため、20行を読み取る際にさらにソートする必要はありません。

制限事項

COUNT(*) 大規模なデータセット

Kaminariはデフォルトで、ページリンクをレンダリングするためのページ数を決定するためにカウントクエリを実行します。カウントクエリは大きなテーブルでは非常に高価になります。不幸なことに、クエリはタイムアウトしてしまいます。

これを回避するために、カウントSQLクエリを実行せずにKaminariを実行することができます。

Issue.where(project_id: 1).page(1).per(20).without_count

この場合、カウントクエリは実行されず、ページネーションはページ番号を表示しません。次のリンクと前のリンクだけが表示されます。

OFFSET 大規模なデータセット

大きなデータセットに対してページ分割を行うと、レスポンスタイムがどんどん遅くなっていくことに気づくかもしれません。これは、OFFSET 節が行をシークし、N 行スキップするためです。

ユーザーから見ると、このことはあまり気にならないかもしれません。ユーザがページ送りをすると、前の行がまだデータベースのバッファキャッシュに残っているかもしれません。ユーザーがリンクを他の人と共有し、数分後あるいは数時間後に開かれた場合、レスポンスタイムは著しく高くなり、タイムアウトする可能性さえあります。

大きなページ数を要求する場合、データベースはPAGE * PAGE_SIZE 行を読み込む必要があります。このため、オフセットページ分割は大きなデータベーステーブルには不向きです。

例: 管理エリアのユーザーリスト

非常にシンプルなSQLクエリでユーザーをリストアップします:

SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 20 OFFSET 0

クエリの実行計画を見ると、このクエリは効率的で、データベースから20行を読み込むだけです (rows=20):

 Limit  (cost=0.43..3.19 rows=20 width=1309) (actual time=0.098..2.093 rows=20 loops=1)
   Buffers: shared hit=103
   ->  Index Scan Backward using users_pkey on users  (cost=0.43..X rows=X width=1309) (actual time=0.097..2.087 rows=20 loops=1)
         Buffers: shared hit=103
 Planning Time: 0.333 ms
 Execution Time: 2.145 ms
(6 rows)

実行計画の読み方については、理解するEXPLAIN計画を参照してください。

50_000番目のページを見てみましょう:

SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 20 OFFSET 999980;

この計画では、データベースが20行を返すために1_000_000行を読み込み、実行時間が非常に長くなっています(5.5秒):

Limit  (cost=137878.89..137881.65 rows=20 width=1309) (actual time=5523.588..5523.667 rows=20 loops=1)
   Buffers: shared hit=1007901 read=14774 written=609
   I/O Timings: read=420.591 write=57.344
   ->  Index Scan Backward using users_pkey on users  (cost=0.43..X rows=X width=1309) (actual time=0.060..5459.353 rows=1000000 loops=1)
         Buffers: shared hit=1007901 read=14774 written=609
         I/O Timings: read=420.591 write=57.344
 Planning Time: 0.821 ms
 Execution Time: 5523.745 ms
(8 rows)

典型的なユーザーはこれらのPagesを訪問しないと主張することができますが、APIユーザーは非常に高いページ数(スクレイピング、データ収集)に簡単にナビゲートすることができます。

キーセットのページ分割

キーセットによるページネーションは、大きなページをリクエストしたときに前の行を “スキップ “してしまうというパフォーマンス上の問題に対処するものです。しかし、オフセットによるページネーションに取って代わるものではありません。APIエンドポイントをオフセットベースのページ処理からキーセットベースのページ処理に移行する場合は、両方をサポートする必要があります。1つのタイプのページ分割を完全に削除することは、大きな変更です。

キーセットのページネーションはGraphQL APIREST API の両方で使用されています。

次のissues テーブルを考えてみましょう:

idproject_id-- 11 21 32 41 51 62 72 81 91 102

テーブル全体を主キー (id) でページ分割してみましょう。最初のページのクエリはオフセットのページ分割クエリと同じですが、簡単のためにページサイズとして5を使用します:

SELECT "issues".* FROM "issues" ORDER BY "issues"."id" ASC LIMIT 5

OFFSET 節を追加していないことに注意してください。

次のページに移動するには、最後の行からORDER BY 節の一部である Pages を抽出する必要があります。この場合、必要なのはidの値だけです:

SELECT "issues".* FROM "issues" WHERE "issues"."id" > 5 ORDER BY "issues"."id" ASC LIMIT 5

クエリの実行計画を見ると、このクエリは5行しか読み込んでいないことがわかります(オフセットベースのページネーションでは10行読み込むことになります):

 Limit  (cost=0.56..2.08 rows=5 width=1301) (actual time=0.093..0.137 rows=5 loops=1)
   ->  Index Scan using issues_pkey on issues  (cost=0.56..X rows=X width=1301) (actual time=0.092..0.136 rows=5 loops=1)
         Index Cond: (id > 5)
 Planning Time: 7.710 ms
 Execution Time: 0.224 ms
(5 rows)

制限事項

ページ番号なし

オフセットページ分割は特定のページをリクエストする簡単な方法を提供します。URL を編集してpage= URL パラメータを修正できます。ページングロジックが異なるカラムに依存する可能性があるため、キーセットページ分割はページ番号を提供できません。

前の例では、カラムはid ですので、URLのように表示されるかもしれません:

id_after=5

GraphQLでは、パラメーターはJSONにシリアライズされ、エンコードされます:

eyJpZCI6Ijk0NzMzNTk0IiwidXBkYXRlZF9hdCI6IjIwMjEtMDQtMDkgMDg6NTA6MDUuODA1ODg0MDAwIFVUQyJ9
note
ページネーションパラメーターはユーザーから見えるので、どのカラムで並べるか注意してください。

キーセットのページ分割は次、前、最初、最後のページしか提供できません。

複雑さ

しかし、タイブレーカーや複数カラムの順序を使用する場合は複雑になります。カラムが null 可能な場合は、複雑さが増します。

例:idcreated_at where created_atが null 可能なカラムによる並べ替えで、2 ページ目を取得するクエリ:

SELECT "issues".*
FROM "issues"
WHERE (("issues"."id" > 99
        AND "issues"."created_at" = '2021-02-16 11:26:17.408466')
       OR ("issues"."created_at" > '2021-02-16 11:26:17.408466')
       OR ("issues"."created_at" IS NULL))
ORDER BY "issues"."created_at" DESC NULLS LAST, "issues"."id" DESC
LIMIT 20
ツール

GitLabプロジェクトでは、汎用的なキーセットのページ分割ライブラリが利用可能です。このライブラリは、大規模なデータセットを扱う際に、既存のKaminariベースのページ分割を簡単に置き換えることができ、パフォーマンスも大幅に向上します。

使用例:

# first page
paginator = Project.order(:created_at, :id).keyset_paginate(per_page: 20)
puts paginator.to_a # records

# next page
cursor = paginator.cursor_for_next_page
paginator = Project.order(:created_at, :id).keyset_paginate(cursor: cursor, per_page: 20)
puts paginator.to_a # records

包括的な概要については、キーセットのページネーションガイドページをご覧ください。

パフォーマンス

キーセットのページ分割は、進むページ数に関係なく安定したパフォーマンスを提供します。このパフォーマンスを達成するために、ページ分割されたクエリには、オフセットページ分割と同様に、ORDER BY 節のすべてのカラムをカバーするインデックスが必要です。

一般的なパフォーマンスのガイドライン

ページネーションに関する一般的なガイドラインのページをご覧ください。