DynamoDB のページネーション:LastEvaluatedKey 解説
DynamoDB は「すべての」結果を1回の呼び出しで返すことはありません。Query や Scan は最大 1 MB のデータを返し、そこから再開するための LastEvaluatedKey を渡します。ページネーションを正しく行うとは、カウンターではなくそのキーでループすることです。
DynamoDB のページネーションはどう機能しますか?
Query や Scan は1回の呼び出しで最大 1 MB を返し、LastEvaluatedKey を返却します。ページをめくるには、そのキーを次の呼び出しの ExclusiveStartKey として渡し、DynamoDB がキーを返さなくなるまでループします。ページ番号も、総件数もありません。Limit は返される件数ではなく、評価される件数の上限です。
let key;
do {
const out = await client.send(new QueryCommand({...params, ExclusiveStartKey: key}));
process(out.Items);
key = out.LastEvaluatedKey;
} while (key);LastEvaluatedKey が undefined になったら、終端に達しています。次のスライスを取得するには、それを ExclusiveStartKey として渡し返します。
各ページは2つの独立した上限に縛られます。設定した Limit(あれば)と、ハードな 1 MB のレスポンス上限です。大きなアイテムが並ぶパーティションでは、Limit が 100 でも3行でページが埋まることがあります。DynamoDB はどちらかの上限に当たった時点で止め、データが残っていれば LastEvaluatedKey を返します。UI の文言は「N 件中 25 件を表示」ではなく「もっと読み込む」を前提に計画しましょう。全ページを歩き終えるまで N は分からないからです。
制御フローは、キーが無いときだけ抜ける単一のループです。
各パスは、返されたキーから再開するか、停止するかのいずれかです。カウンターは存在しません。
Limit はページサイズではない
Limit は DynamoDB が評価するアイテム数の上限であって、FilterExpression 適用後に返す数ではありません。フィルタの背後にある Limit: 25 のクエリは、3件を返しつつ LastEvaluatedKey を渡してくることがあります。ページが短く見えても、キーが空になるまでページングを続けなければなりません。空でない LastEvaluatedKey も、より多くの一致するアイテムがあることを約束しません。終端に達したことを証明するのは、キーが無いことだけです。
| 期待してしまうこと | DynamoDB が実際にすること |
|---|---|
Limit: 25 → ページに25行 | 最大25件を評価する。フィルタが返る件数を減らすことがある |
| 短いページ → データの終わり | 短いページ + 空でないキー → ページングを続ける |
| 空のページ → 完了 | 空のページ + 空でないキー → フィルタの向こうにまだデータがある |
Limit がリクエストごとの請求を決める | 請求は読み取ったアイテムに従う。フィルタで落ちた行も含む |
具体的な読み取りで考えます。パーティション USER#42 に平均 2 KB の注文アイテムが200件あるとします。Limit: 50 と FilterExpression: status = 'OPEN' の Query は、50件(約 100 KB を計測)を評価して4件が一致し、キーを返すかもしれません。あなたはもう一度ページングします。フィルタがなければ、同じ Limit: 50 は50件を評価し、オンデマンドで約12.5読み取りキャパシティユニットを課金します(50 × 2 KB → 100 KB。結果整合性の読み取りでは 4 KB ブロックごとに切り上げ、各 0.5 RCU)。当て推量の代わりに、毎回の呼び出しで ReturnConsumedCapacity: TOTAL を渡して、ページごとの計測ユニットを見ましょう。
SDK にページングさせる
どちらの SDK も上記のループをラップしているので、ページを直接反復できます。
// AWS SDK for JavaScript v3
import {paginateQuery} from '@aws-sdk/lib-dynamodb';
for await (const page of paginateQuery({client}, params)) {
process(page.Items);
}# boto3
paginator = client.get_paginator('query')
for page in paginator.paginate(**params):
process(page['Items'])ページ番号はない
DynamoDB には総件数もランダムなページアクセスもありません。カーソルを再生し直さずに「7ページ目」に飛んだり前のページに戻ったりはできません。UI は無限スクロール/「もっと読み込む」を中心に設計し、番号付きページにはしないでください。(Select: 'COUNT' のクエリでも、件数を数えるために一致した全アイテムを読み取り、課金されます。)
API 向けのステートレスなカーソル
LastEvaluatedKey は最後のアイテムのキー属性にすぎません。それを Base64 エンコードし、不透明な nextToken としてクライアントに渡します。次のリクエストでそれを ExclusiveStartKey にデコードして戻します。サーバー側のカーソル状態は不要です。
そのトークンは DynamoDB-JSON です。DynamoDB-JSON コンバーターで目視したり手で組み立てたりできます。そしてScanを回避するためにページングしているなら、それはたいていインデックスを追加すべきシグナルです。
トークンは不透明かつ不変として扱いましょう。クライアントは発行したものをそのまま送り返さなければなりません。デコードしてソートキーの一部を書き換えて再エンコードすると、再開点が壊れ、行を飛ばしたり重複させたりしかねません。エンコードを差し替えても進行中のセッションを壊さずに済むよう、封筒にバージョンを付けましょう({"v":1,"lek":…})。Scan のページでは、並列セグメントを使っているとキーにセグメント ID が含まれます。セグメント 2 のトークンはセグメント 0 ではなくセグメント 2 を再開しなければなりません。
PartiQL の ExecuteStatement も、名前は違いますが同じ再開モデルを使います。レスポンスの NextToken が次のリクエストの NextToken になります。トークンが無くなるまでループするという考え方は、Query/Scan とまったく同じです。
ページネーション戦略を選ぶ
| アプローチ | 向いている用途 | トレードオフ |
|---|---|---|
キーに対する手書きの do/while | 完全な制御、独自のバックオフ、混在した操作 | エラー処理やキャパシティ上限を忘れやすい |
SDK のページネーター(paginateQuery) | バッチジョブ、エクスポート、CLI ツール | ページごとの副作用に対する制御が弱い |
自分の API での Base64 nextToken | モバイル/Web の「もっと読み込む」 | 検証が必須で、生のテーブルキーを決して露出しない |
| DynoTable の結果グリッド | 探索的な読み取り、キー順序の確認 | クライアント側であり、サーバーのページング手法ではない |
どの道を選ぶにせよ、進捗をページ番号から推測することは決してしないでください。成長中のテーブルに対する Scan の14ページ目は「14 × Limit 件まで進んだ」という意味ではありません。呼び出しの合間に追加・削除されたアイテムが境界をずらします。冪等な下流の書き込み(自然キー、条件付き put)にしておけば、クライアントがタイムアウト後に同じトークンで再試行しても安全です。
キャパシティはページをまたいで積み上がる
ページネーションは読み取りを割り引きません。それぞれ 1 MB のアイテムデータに触れる10ページは、1ページ分のコストのおよそ10倍を計測します。GSI 上の Query でテーブル全体を歩くバックグラウンドジョブは、スケジュールする前に「ページあたりのコスト」×「キーが無くなるまでのページ数」を掛けておきましょう。料金計算ツールは、そのページあたりのユニット数をそのまま受け付けます。
大きなレスポンスは、キャパシティの上限より先にワイヤの上限に当たります。単一のアイテムが 400 KB に近づくと、Limit にかかわらず1ページ1アイテムになることがあります。アイテムが 4 KB の読み取り切り上げ境界を越えるタイミングは、アイテムサイズ計算機で分かります(強い整合性の読み取りでは 4 KB あたり 1 RCU、結果整合性ではその半分)。
ループを組み立てて確認する
ループを書くこと自体を飛ばすには、クエリビルダーが完全な Query/Scan リクエストを組み立て、実行可能な SDK v3、CLI、boto3 のプログラムを — ページネーションループ込みで — 出力します。パーティションキー、任意のソート条件、射影、フィルタを設定すれば、出力されたプログラムが paginateQuery か同等の手書きループを、ExclusiveStartKey の配線込みでラップしてくれます。
基礎となる API のフィールドと整合性のオプションについては、DynoTable でのクエリを参照してください。SDK を呼ぶのでも、PartiQL でも、デスクトップアプリの PartiQL タブでも、同じページネーションのルールが当てはまります。
DynoTable を試すと、カーソルを追跡した状態でクエリ結果をビジュアルにページ送りでき、ReturnConsumedCapacity がリクエストごとに表示されるので、毎回の呼び出しを自分でラップしなくてもページごとの読み取りユニットが見えます。