Back

MongoDBにおけるページネーションパターン

MongoDBにおけるページネーションパターン

フィード、商品リスト、または検索結果ページを構築しているとします。コレクションが数百万のドキュメントに成長すると、突然ページネーションクエリがミリ秒ではなく秒単位で時間がかかるようになります。原因はほぼ常に同じです:大規模なデータに対してskipとlimitを使用しているのです。

この記事では、MongoDBの主要なページネーションパターン(オフセットベースとカーソル/キーセットベース)を取り上げ、それぞれがどのような場合に有効で、どのような場合に有効でないかを説明します。フレームワーク固有のアドバイスではなく、データが成長しても有効なパターンを紹介します。

重要なポイント

  • skip()limit()を使用したオフセットページネーションは直感的ですが、オフセット値の増加に伴い線形的に性能が劣化します。浅いページングや小規模データセットに限定して使用してください。
  • キーセット(カーソルベース)ページネーションは範囲クエリを使用し、位置に関係なく一貫したパフォーマンスを提供するため、大規模データセットに最適です。
  • キーセットページネーションでは、重複やドキュメントのスキップを防ぐために、常に一意のタイブレーカーフィールド(通常は_id)を使用してください。
  • データサイズ、アクセスパターン、ユーザーが順次ナビゲーションを必要とするか任意のページジャンプを必要とするかに基づいて、ページネーションパターンを選択してください。

MongoDB Skip Limit ページネーション:馴染みのあるアプローチ

オフセットベースのページネーションは、skip()limit()を使用して結果をページに分割します。直感的で、「ページ1、ページ2、ページ3」というUIパターンに直接対応します。

db.posts.find()
  .sort({ createdAt: -1 })
  .skip((page - 1) * pageSize)
  .limit(pageSize)

このパターンは、浅いページング(結果の最初の数ページ)、限られたデータを持つ管理ダッシュボード、またはパフォーマンスが重要でない内部ツールには問題なく機能します。

Skipが大規模データで劣化する理由

MongoDBのインデックスはB-tree構造であり、配列ではありません。データベースはインデックス内を効率的にシークできますが、要求されたオフセットに到達するためにスキップされたエントリを順に進む必要があります。10ドキュメントをスキップすると10個のインデックスエントリを通過します。100,000をスキップすると100,000個のエントリを通過します。

つまり、ページ1は高速ですが、ページ100は遅くなり、ページ10,000はさらに大幅に遅くなります。CPU使用率は、ページサイズに関係なく、オフセット値に比例して増加します。

オフセットページネーションを使用すべき場合:

  • ユーザーが最初の数ページを超えてナビゲートすることがほとんどない場合
  • 総データセットが小規模(10,000ドキュメント未満)の場合
  • 「ページXにジャンプ」機能が必要で、トレードオフを受け入れられる場合
  • クエリ時間がそれほど重要でない内部ツールを構築する場合

MongoDB カーソルベースページネーション:一貫したパフォーマンス

MongoDBキーセットページネーション(カーソルベースページネーションとも呼ばれる)は、位置的なオフセットの代わりに範囲クエリを使用します。「1,000ドキュメントをスキップ」と言う代わりに、「この特定のポイント以降のドキュメントを取得」と指定します。

db.posts.find({ createdAt: { $lt: lastSeenDate } })
  .sort({ createdAt: -1 })
  .limit(pageSize)

データベースは効率的なインデックスシークを実行して開始点を特定し、必要なドキュメントのみを読み取ります。ページ1とページ10,000は同一のパフォーマンス特性を持ちます。

タイブレーカーの必要性

単一のソートフィールドは、値が一意でない場合に問題を引き起こします。複数のドキュメントが同じcreatedAtタイムスタンプを共有している場合、一部がページ間でスキップされたり重複したりする可能性があります。

解決策は、一意のタイブレーカー(通常は_id)を使用した複合ソートです:

db.posts.find({
  $or: [
    { createdAt: { $lt: lastDate } },
    { createdAt: lastDate, _id: { $lt: lastId } }
  ]
})
.sort({ createdAt: -1, _id: -1 })
.limit(pageSize)

これには、対応する複合インデックスが必要です:

db.posts.createIndex({ createdAt: -1, _id: -1 })

最適なパフォーマンスを得るには、インデックスフィールドの順序がソート順序と一致する必要があります。

一貫性に関する注意点

カーソルベースのページネーションはオフセットページネーションよりも安定していますが、魔法のように一貫性があるわけではありません。リクエスト間でドキュメントが挿入、削除された場合、または可変のソートフィールドが変更された場合、ユーザーは依然として重複を見たり、アイテムを見逃したりする可能性があります。違いは、キーセットページネーションが位置ではなく特定の値に固定されるため、同時書き込みによって発生する可視的な異常が通常少なくなることです。

完全な一貫性が必要ないフィードやリストの場合、このトレードオフは通常許容されます。

Atlas Search ページネーション:異なるメカニズム

MongoDB Atlas Searchを$searchステージで使用している場合、ページネーションの動作は異なります。Atlas Searchは、_idカーソルではなく、searchAfterおよびsearchBeforeパラメータを使用して、独自のトークンベースシステム(内部的にはsearchSequenceToken経由)を使用します。これらのアプローチを混在させないでください。クエリタイプに合ったページネーション方法を使用してください。

適切なパターンの選択

シナリオ推奨パターン
無限スクロールフィードキーセットページネーション
「さらに読み込む」ボタンキーセットページネーション
ページ番号付き管理テーブルオフセット(浅いページのみ)
順次ナビゲーションを伴う大規模データセットキーセットページネーション
小規模で静的なデータセットどちらでも可

決定はしばしばUI要件に依存します。ユーザーが任意のページにジャンプする必要がある場合、オフセットページネーションまたはハイブリッドアプローチに制約されます。順次ナビゲーション(次へ、前へ、さらに読み込む)で十分な場合、キーセットページネーションの方がスケーラビリティに優れています。

結論

skipとlimitを使用したオフセットページネーションはシンプルですが、オフセットサイズに比例して線形的に劣化します。浅いページングまたは小規模データセット用に予約してください。

キーセットページネーションは位置に関係なく一貫したパフォーマンスを維持しますが、一意のタイブレーカーを使用した決定論的なソートが必要です。フィード、リスト、およびユーザーが大規模な結果セットを順次ナビゲートするインターフェースには、こちらの方が適しています。

どちらのパターンも普遍的に間違っているわけではありません。データサイズ、アクセスパターン、UI要件に基づいて選択してください。

よくある質問

はい、キーセットページネーションで複数のソートフィールドを使用できます。重要な要件は、最終フィールドがタイブレーカーとして機能するために一意である必要があることです。複合インデックスは、すべてのソートフィールドの正確な順序と方向に一致する必要があります。各追加フィールドによってクエリロジックはより複雑になります。各先行フィールドの等価性ケースを処理するために、ネストされたOR条件が必要になるためです。

後方ページネーションの一般的なアプローチの1つは、比較演算子とソート方向を逆にすることです。前方が降順ソートで$ltを使用する場合、後方は昇順ソートで$gtを使用します。結果を取得した後、アプリケーションでそれらを逆順にして表示順序を維持します。双方向ナビゲーションを可能にするために、各ページの最初と最後のドキュメントカーソルの両方を保存します。

ユーザーがページネーション中にドキュメントのソートフィールドが変更されると、カーソル位置に対して前方または後方に移動したかどうかに応じて、そのドキュメントが2回表示されたり、完全に見逃されたりする可能性があります。頻繁に更新されるフィールドの場合、_idのような不変値を主要なソートキーとして使用することを検討するか、これをリアルタイムデータの固有の制限として受け入れてください。

キーセットページネーションは位置を追跡しないため、総数を自然にサポートしません。別のカウントクエリを実行できますが、これは大規模コレクションでオーバーヘッドを追加します。ユーザーが本当に正確なカウントを必要としているかどうかを検討してください。多くの場合、概算カウントまたは単により多くの結果が存在することを示すだけで、パフォーマンスコストなしに十分なコンテキストを提供します。

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay