echo("備忘録");

IT技術やプログラミング関連など、技術系の事を備忘録的にまとめています。

【DynamoDB】DynamoDBデータのページネーション処理について

はじめに

お久しぶりです。だいぶ間が開いてしまいました。

前回から色々ありまして、結果として8月から新しい就業先で働いてます。(この辺はまた別の機会に書きます)

で、新しい環境でもAWSに携わっていますが、 コードレビューなどを通して、(知ってたつもりでも)色々知らなかった事や勉強になる事が多いなあ...と感しています。

そこで今回は基本に立ち返るという意味で、ビギナー向けネタとして「DynamoDBデータのページネーション処理」について書こうと思います。

前提:ページネーション処理について

元々は「Webページに長い文章を掲載する際に、同じデザインの複数のページに分割し、各ページへのリンクを並べたもの」という意味です。

が、今回はDynamoDBのデータについて「該当データを一定個数ごとに区切る処理」という意味合いで使います。

例えばGoogle等が「検索結果を一定データ数ごとにページ区切りする」感じです。 f:id:Makky12:20210821082533p:plain

DynamoDBのデータ取得の制限

DynamoDBでデータ取得する場合、主にQueryやScanを使うと思いますが(※)、QueryやScanで取得できるデータには「最大1MBまで」という制限があります。

なので「該当データを(データ量や件数に関係なく)全件取得する」という事はできません。

※Getはページネーション自体がいらない(=1件しか取得できない)ので省略。

docs.aws.amazon.com

なので、データ量が多い場合、全データを取得する場合は「何回かに分けて取得する」処理(≒ページネーション処理)が必要になってきます。

基本的なページネーション方法(LastEvaluatedKey, ExclusiveStartKey)

先程のAWS公式サイトの説明にもある通り、DynamoDBのQueryやScanには、戻り値の中に「LastEvaluatedKey」というキーが定義されています。

QueryやScanの実行時に全件取得できなかった場合、この「LastEvaluatedKey」に最後のデータのパーティションキー情報が格納されます。(全件取得できた場合は、そもそも「LastEvaluatedKey」キーが存在しない(※))

そして「LastEvaluatedKey」から続きのデータを取得する方法ですが、QueryやScanには実行時のパラメータに「ExclusiveStartKey」というキーがあり、ここに先程の「LastEvaluatedKey」キーの値を指定することで、前回取得したデータの続きから取得することができます。

※ただし後述の「Limit」設定時に「ちょうど全データ取得できた場合」はLastEvaluatedKeyに値が入ってきます。

例えば、末尾に記載した(僕の記事ではお約束の)「ドルアーガの塔 宝物取得リスト」に対して下記ソースを実行すると...

const AWS = require('aws-sdk');
  
async function scanSample() {  
  
    const dc = new AWS.DynamoDB.DocumentClient();
  
    const data = await dc.scan({
        TableName: 'tower-of-druaga',
        Limit: 6
    }).promise();
  
    console.log(`[data] ${JSON.stringify(data)}`);
  
    const data2 = await dc.scan({
        TableName: 'tower-of-druaga',
        Limit: 6,
        ExclusiveStartKey: data.LastEvaluatedKey
    }).promise();
  
    console.log(`[data2] ${JSON.stringify(data2)}`);
  
    return;
}

結果として、下記ログが書き込まれます。

[data] {
    "Items": [
        // Floor1~6までの情報
    ],
    "Count":6,
    "LastEvaluatedKey":{
        "Floor":6,
        "Type":"treasure"
    }
}

[data2] {
    "Items": [
        // Floor7~10までの情報
    ],
    "Count":4,
}  
  
/*ちなみに、Limitを5にするとこうなる */  
[data] {
    "Items": [
        // Floor1~5までの情報
    ],
    "Count":5,
    "LastEvaluatedKey":{
        "Floor":5,
        "Type":"treasure"
    }
}

[data2] {
    "Items": [
        // Floor6~10までの情報
    ],
    "Count":4,
    "LastEvaluatedKey":{
        "Floor":10,
        "Type":"treasure"
    }
} 

問題点

ただし、先述の通りDynamoDBには「最大1MBまで」という制限があるので、各データの内容によっては件数にばらつきが生じる(=最大1MBを超えない最大件数になる)ことがあります。

例えば取得対象データが下表のような場合、QueryやScanを実行すると取得件数は1回目は5件、2回目は2件となります。(さすがにこれは極端ですが)

No サイズ(KB)
1 200
2 200
3 200
4 200
5 200
6 500
7 500
8 700

もちろんそれが問題にならない場合は良いんですが、例えば「画面表示の関係で、毎回決まった件数を取得したい」というような場合が問題です。

対策

上記問題への対策ですが、QueryやScanの実行時パラメータには他にも「Limit」というものがあり、「取得するデータ件数」を指定することができます。

なのでこれを指定することで、取得件数を固定することができます。(例えば上表の場合、Limit=1にすれば毎回1件のみの取得に固定することができます)

ただしLimitを付けたとしても、「最大1MBまで」の制限はあるので注意です。(例えば上表の場合に「Limit=3」にしても、3ページ目はNo.7の1件のみになる)

これに対しては、下記のような対策があります(設計段階での対策)。

  • 「Limit(=1ページに表示する件数) × 1データのデータサイズ」が、1MBよりも余裕をもって下回るようにLimitの値を設定する
  • 各データのデータサイズにあまり大きな差が出ないようにしておく

あえて全件取得するには?

ここまでは「何回かに分けて全データを取得する方法」について書きました。

ただし実際にDynamoDBからデータを取得するケースとして「クライアントからAPI Gateway等にリクエストして、そこからLambdaで取得処理を実行して...」というケースも多いと思います。

そしてその場合、何回もリクエストが発生するのを防ぐために「可能であれば1回のリクエスト(=Lambda実行)で全データを取得したい」というケースもあると思います。

その場合は、1回のLambdaの処理で何度もQueryやScanを実行して全データを取得するような処理を(ループ処理や再起処理で)書けばOKです。(サンプルコードを下記に記します)

なおAPI Gateway(AppSyncも)には30秒という時間制限があるので、あまりにも大量のデータを1度に取得しようとすると、タイムアウトが発生してしまうので注意です。(これはRDSでも同じです)

/*
* ループで書く方法
*/   
async function scanSampleLoop() {

    const dc = new AWS.DynamoDB.DocumentClient();

    let items = [];
    let exclusiveStartKey = null;  
  
    while(true) {
  
        const param = {
            TableName: 'tower-of-druaga',
            Limit: 5
        };
  
        if (exclusiveStartKey) param.ExclusiveStartKey = exclusiveStartKey;
  
        const data = await dc.scan(param).promise();
  
        items = items.concat(data.Items);
        if (!data.LastEvaluatedKey) break;
  
        exclusiveStartKey = data.LastEvaluatedKey;
    }
  
    return;
}  
   
/*
 * 再起処理で書く方法
*/
async function scanSampleRecursive(exclusiveStartKey = null) {
  
   const dc = new AWS.DynamoDB.DocumentClient();
  
    const param = {
        TableName: 'tower-of-druaga',
        Limit: 5
    };
  
    if (exclusiveStartKey) param.ExclusiveStartKey = exclusiveStartKey;
  
    const data = await dc.scan(param).promise();
  
    let items = [];
    if (data.LastEvaluatedKey) {
        items = data.Items.concat(await scanSampleRecursive(data.LastEvaluatedKey));
    } else {
        items = items.concat(data.Items);
    };
  
    return items;
}

まとめ

というわけで、今回は基本に立ち返って、ビギナーの方向けに記事を書きました。

冒頭にも書いた通り「今まで知ってたつもりでも、意外と理解しきれていなかった」と感じるところが多かったので、自分の復習も兼ねて、今後もこういったビギナー向けの記事を書くのも良いなあと感じています。

もちろん、ビギナー向けではないような記事も書いていくつもりです。(バタバタも落ち着いて、新しい就業先で働いたのもありますし)

それでは、今回はこの辺で。

参考:「ドルアーガの塔 宝物取得リスト」について

ドルアーガの塔 宝物取得リスト」は「ドルアーガの塔」というゲームの宝物情報(全60階分)をKey-Value形式でまとめたものです。(ただし、今回は10階まで)

※「ドルアーガの塔」が分からない人はググってみてください。

構造は下記の通りとなっています。

フィールド名 キーの種類 説明
Type パーティションキー 格納情報の種類を表す。今回は「treasure」固定
Floor ソートキー 階数(1~60)が格納される。実質RDBでいう主キー。
Detail なし 実際の宝物情報(名前、効果、出現条件など)が格納されている。(今回の記事では未使用)