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}));
handle(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 も、一致するアイテムがさらにあることを約束するわけではありません。AWS のドキュメントにはこうあります: "If LastEvaluatedKey is not empty, it does not necessarily mean that there is more data in the result set." 終端に達したことを証明するのは、キーが無いことだけです。
| 期待してしまうこと | DynamoDB が実際にすること |
|---|---|
Limit: 25 → ページに25行 | 最大25件を評価する。フィルタが返る件数を減らすことがある |
| 短いページ → データの終わり | 短いページ + 空でないキー → ページングを続ける |
| 空のページ → 完了 | 空のページ + 空でないキー → ページングを続ける。一致するものが無いこともある |
Limit がリクエストごとの請求を決める | 請求は読み取ったアイテムに従う。フィルタで落ちた行も含む |
具体的な読み取りで考えます。パーティション USER#42 に平均 2 KB のアイテムが200件あるとします。Limit: 50 と FilterExpression: status = 'OPEN' の Query は、50件を評価して4件が一致し、キーを返すかもしれません。そこで、もう一度ページングします。
フィルタの有無にかかわらず、そのページは結果整合性の読み取りで約12.5読み取りユニットを課金します。50 × 2 KB は 100 KB で、これは 4 KB ブロック25個分、各 0.5 です。毎回の呼び出しで ReturnConsumedCapacity: TOTAL を渡せば、ページごとの計測ユニットが見えます。
空のページが積み重なるなら、フィルタがキーの役割を肩代わりしています。フィルタリング戦略では、条件をソートキーやスパースインデックスに移す方法を紹介しています。
SDK にページングさせる
どちらの SDK も上記のループをラップしているので、ページを直接反復できます。
// AWS SDK for JavaScript v3, low-level client
import {DynamoDBClient, paginateQuery} from '@aws-sdk/client-dynamodb';
const client = new DynamoDBClient({});
for await (const page of paginateQuery({client}, params)) {
handle(page.Items);
}@aws-sdk/lib-dynamodb の paginateQuery は、DynamoDBDocumentClient とプレーンな JavaScript の値を扱う同じループです。低レベルクライアントは受け付けません。
# boto3
paginator = client.get_paginator('query')
for page in paginator.paginate(**params):
handle(page['Items'])ページ番号はない
DynamoDB には総件数もランダムなページアクセスもありません。「7ページ目」に飛ぶことはできず、DynamoDB はページの履歴も保持しません。UI は無限スクロールや「もっと読み込む」を中心に設計し、残りは自分で追加します。
- 前のページ。 発行した
ExclusiveStartKeyのスタックを、クライアント側かトークンの中に保持します。n−1 ページ目は、現在のエントリの1つ前のスタックエントリです。 - 総件数。 カウンターアイテムを用意し、各挿入と同じ
TransactWriteItemsの中でUpdateItem ... ADD itemCount :oneによってインクリメントし、各削除と同じトランザクションの中でADD itemCount :minusOneを実行します。挿入にはattribute_not_exists(削除にはattribute_exists)の条件を付けてClientRequestTokenを渡し、再試行で二重にカウントされないようにします(書き込み時に集計する)。 - テーブル全体のおおよその件数。
DescribeTableはItemCountを返します。DynamoDB はこれを約6時間ごとに更新します(テーブルサイズとアイテム数)。
Select: 'COUNT' のクエリは近道ではありません。評価するすべてのアイテムを読み取って課金し、1ページずつ数えます。結局は LastEvaluatedKey でループして Count を合計することになります。
API 向けのステートレスなカーソル
LastEvaluatedKey は、最後に評価されたアイテム(フィルタで落とされたかもしれないもの)のキー属性と、インデックスをクエリした場合はそのインデックスのキーを保持します。それを不透明な nextToken としてクライアントに渡し、次のリクエストで ExclusiveStartKey に戻します。サーバー側のカーソル状態は不要です。
Base64 だけでは不透明になりません。誰でもデコードして、テーブルのキーを読めてしまいます。トークンを暗号化する(サーバーが保持する鍵による AES-GCM、または AWS KMS の Encrypt)か、少なくとも HMAC で署名し、署名が一致しないトークンは拒否しましょう。
トークンは不変として扱いましょう。クライアントは発行したものをそのまま送り返さなければなりません。ソートキーの一部を書き換えると再開点が壊れ、行を飛ばしたり重複させたりしかねません。
エンコードを差し替えても進行中のセッションを壊さずに済むよう、封筒にバージョンを付けましょう({"v":1,"lek":…})。
並列 Scan のキーは、自分のセグメントを記録しません。封筒の中で、キーの隣に Segment と TotalSegments を保存しましょう。セグメント 2 のキーをセグメント 0 で再開すると、ValidationException で拒否されます。exclusive start key はスキャン中のセグメントの範囲内になければなりません(並列スキャン)。
デバッグ中に生の LastEvaluatedKey を確認するには、DynamoDB-JSON コンバーターでプレーンな JSON に変換できます。そしてScanを回避するためにページングしているなら、それはたいていインデックスを追加すべきシグナルです。
PartiQL の ExecuteStatement も、名前は違いますが同じ再開モデルを使います。レスポンスの NextToken が次のリクエストの NextToken になります。Query/Scan と同じく、トークンが無くなるまでループします。
ページネーション戦略を選ぶ
| アプローチ | 向いている用途 | トレードオフ |
|---|---|---|
キーに対する手書きの do/while | 完全な制御、独自のバックオフ、混在した操作 | エラー処理やキャパシティ上限を忘れやすい |
SDK のページネーター(paginateQuery) | バッチジョブ、エクスポート、CLI ツール | ページごとの副作用に対する制御が弱い |
署名または暗号化した nextToken | モバイル/Web の「もっと読み込む」 | 署名鍵と検証を自分で管理する |
どの道を選ぶにせよ、進捗をページ番号から推測することは決してしないでください。
成長中のテーブルに対する Scan の14ページ目は「14 × Limit 件まで進んだ」という意味ではありません。呼び出しの合間に追加・削除されたアイテムが、境界をずらすことがあります。
冪等な下流の書き込み(自然キー、条件付き put)にしておけば、クライアントがタイムアウト後に同じトークンで再試行しても安全です。
キャパシティはページをまたいで積み上がる
ページネーションは読み取りを割り引きません。それぞれ 1 MB のアイテムデータを読む10ページは、1ページ分のコストのおよそ10倍を計測します。
テーブルやインデックス全体を歩くジョブをスケジュールする前に、「ページあたりのコスト」×「キーが無くなるまでのページ数」を計算し、それを毎秒の読み取り数に換算して料金計算ツールに入力しましょう。
大きなアイテムは Limit より先に 1 MB の上限に当たります。アイテムが 400 KB に近づくと、Limit にかかわらず1ページに2つか3つしか入りません。
Query と Scan はページ上のすべてのアイテムのサイズを合計し、その合計を 4 KB 単位に切り上げます。アイテムごとの切り上げは GetItem/BatchGetItem のルールです。1つのアイテムの重さはアイテムサイズ計算機で分かります。
ループを組み立てて確認する
ループを書くこと自体を飛ばすには、クエリビルダーが完全な Query/Scan リクエストを組み立て、ページネーションループ込みで、実行可能な SDK v3、CLI、boto3 のプログラムを出力します。
パーティションキー、任意のソート条件、射影、フィルタを設定すれば、出力されたプログラムが paginateQuery か同等の手書きループを、ExclusiveStartKey の配線込みでラップしてくれます。
DynoTable がクエリをどうページングするか(さらに読み込む、最後まで実行)については、DynoTable でのクエリを参照してください。API のフィールドについては、AWS の Query リファレンスが一次情報です。
DynoTable を試すと、カーソルの追跡を任せたまま、クエリ結果をビジュアルにページ送りできます。さらに読み込む で次のページを取得し、最後まで実行 でキーが無くなるまで読み取ります。