カーディナリティの低い属性にGSIを設定するとスロットリングすることを確認してみた

2024.04.08

CX事業本部@大阪の岩田です。

先日とあるDynamoDBのテーブル設計をレビューしていたのですが、GSIにホットパーティションが発生する設計となっていました。このブログではGSIにホットパーティションが発生した際の動作を確認しつつ、改善案について検討してみます。

テーブル設計とアクセスパターン

テーブル設計は以下のような設計でした。簡略化のため、今回の検証に無関係の項目については省略しています。

項目名 キー情報 備考
id S テーブルのパーティションキー UUID4で採番
status S GSIのパーティションキー USEDUN_USEDの2値をとる
新規登録時は常にUN_USEDとなる

想定しているアクセスパターンは以下の通りです

  • idを指定して1件のアイテムを取得
  • GSIを利用してstatusUN_USEDのアイテムをクエリし複数件取得
  • idを指定して1件のアイテムを条件付き書き込み
    • statusUN_USEDの場合にstatusUSEDに更新する

問題点

このテーブル設計ではstatusがGSIのパーティションキーとして指定されていますが、statusが取り得る値はUSEDUN_USEDの2値となっています。このためGSIのパーティションは2つだけになります。また、アイテムの新規登録時はstatusが常にUN_USEDとなるため、アイテム新規登録時の書き込みが全て単一のパーティションに集中することになります。

パーティションあたりのWCU上限は1,000なので、各アイテムのサイズが1K以内に収まる前提で考えると1,000アイテム/秒までしか書き込みできないことになります。

大量のレコードを含むCSVファイルを元にBatchWriteItemでアイテムを一括登録する例で考えてみます。バーストキャパシティとアダプティブキャパシティについては無視して考えると、理論値としては以下のようになります。

  • インデックス内の各アイテムサイズは1K以内 → 1アイテムの書き込みでインデックスのキャパシティを1WCU消費する
  • BatchWriteItemで25アイテムをまとめて書き込む → 1回のバッチ書き込みでインデックスのキャパシティを25WCUを消費する
  • 書き込み対象のパーティションはすべて同一パーティションになる → 最大1000WCPU/sが上限になる
  • 1000WCU / 25WCU → 40なので40回のBatchWriteItem/秒が上限となる

アプリケーションの処理でループしながら逐次BatchWriteItemを繰り返すとすると、1回のBatchWriteItemが1秒/40回 → 25ms以内に完了する程度のレイテンシであれば1,000WCUまで達することになります。ローカルPCからの書き込みであれば問題無さそうですが、LambdaやEC2などAWSのNW内から書き込む場合スロットリングエラーを誘発してしまいそうな数値です。

スロットリングエラーが発生しないか検証してみる

実際にスロットリングエラーが発生しないか確認してみましょう。

まずテスト用にテーブルを作成します。テーブル定義のJSONファイルは以下の通りです。

teble-def.json

{
    "AttributeDefinitions": [
        {
            "AttributeName": "id",
            "AttributeType": "S"
        },
        {
            "AttributeName": "status",
            "AttributeType": "S"
        }
    ],
    "TableName": "test",
    "KeySchema": [
        {
            "AttributeName": "id",
            "KeyType": "HASH"
        }
    ],
    "GlobalSecondaryIndexes": [
        {
            "IndexName": "gsi-status",
            "KeySchema": [
                {
                    "AttributeName": "status",
                    "KeyType": "HASH"
                }
            ],
            "Projection": {
                "ProjectionType": "ALL"
            }
        }
    ],
    "BillingMode": "PAY_PER_REQUEST"
}

上記テーブル定義を元にAWS CLIを使ってテーブルを作成します

aws dynamodb create-table --cli-input-json file://table-def.json

テーブルが作成できたらContributor Insightsも有効化しておきましょう

aws dynamodb update-contributor-insights --table-name test --contributor-insights-action=ENABLE
aws dynamodb update-contributor-insights --table-name test --index-name gsi-status --contributor-insights-action=ENABLE

準備ができたらDynamoDBと同一リージョンのCloudShellから検証用コードを実行してみます。

なお、検証に利用した各種のバージョンは以下のとおりです。

  • Node.js: v18.18.2
  • @aws-sdk/client-dynamodb: 3.549.0
  • @aws-sdk/lib-dynamodb: 3.549.0
  • uuid: 9.0.1

検証用のコードは以下です。

無限ループしながら25アイテムのBatchWriteItemを繰り返すという実装です。

index.js

const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { BatchWriteCommand } = require('@aws-sdk/lib-dynamodb');
const { v4 } = require('uuid');

const tableName = 'test';
const createReqItems = () => {
    const items = new Array(25).fill(null).map(() => ({
        PutRequest: {
            Item: {
                id: v4(),
                status: 'UN_USED'
            }
        }
    }))
    return {
        [tableName]: items
    }
}

(async () => {
    console.log(`start ${new Date()}`)
    const ddbClient = new DynamoDBClient();
    let items = 0;
    let errItems = 0;
    while (true) {
        const requestItems = createReqItems();
        items += requestItems[tableName].length;
        const batchWriteCmd = new BatchWriteCommand({
            RequestItems: requestItems
        })
        const res = await ddbClient.send(batchWriteCmd)
        const unprocessedItems = res.UnprocessedItems
        if(Object.keys(unprocessedItems).length !== 0) {
            errItems += unprocessedItems[tableName].length;
            console.error(`${new Date()} UnprocessedItems: ${errItems} / ${items}`)
        }
    }
})();

CloudShellから上記コードを実行します

node index.js

テスト結果

実行後しばらくするとエラーログが出力されるので適当なところでCtrl + Cで実行を停止しましょう。エラーログからBatchWriteItemが一部失敗していることが分かります。

...
Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 166 / 160350
Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 167 / 160375
Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 171 / 160400
Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 173 / 160425
Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 175 / 160450
Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 177 / 160475
Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 179 / 160500
Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 180 / 160525
Mon Apr 08 2024 02:42:49 GMT+0000 (Coordinated Universal Time) UnprocessedItems: 184 / 160550

テスト実施時間帯のCloudWatchメトリクスを確認してみましょう。

まずContributor Insightsのメトリクスです。テーブル自体のパーティションキーは「消費されたスループットユニット」が分散しており、値もせいぜい50程度です。それに対してGSIのパーティションキーは全て単一パーティションにアクセスが集中しており、「消費されたスループットユニット」 は約350kに達しています。GSIの最もスロットリングされたキー (パーティションキー)には特にデータが計上されていませんが、これはContributor Insightsの仕様によるものでGSIの書き込みスロットは計測されないためです。

グローバルセカンダリインデックスの書き込み容量が不十分なため発生した書き込みスロットルは測定されません。

CloudWatch DynamoDB のコントリビューターインサイト:仕組み - Amazon DynamoDB

対象GSIのメトリクスWriteThrottleEventsを確認するとスロットリングが発生していることが分かります

GSIの設計を改善してみる

GSIのスロットリングエラーが発生することが確認できたので、GSIの設計を改善してみましょう。改めてGSIに関するアクセスパターンを抽象化して考えると要件は以下の通りと考えられます。

  • 「未使用」状態のアイテムを複数件取得したい
  • 「未使用」状態のアイテムを「使用済み」に更新したい

この要件を満たすようにDynamoDBらしい設計に置き換えると以下のようになります。

項目名 キー情報 備考
id S テーブルのパーティションキー UUID4で採番
unUsedId S GSIのパーティションキー 「未使用」状態のアイテムの場合はidと同じ値を登録
新規登録時は常に「未使用」状態としてidを登録する
「使用済み」状態のアイテムの場合は属性ごと指定しない

この設計はスパースインデックスというテクニックを使っており、unUsedIdという属性が存在するアイテムにのみ対応するインデックスが作成されます。先ほど確認した要件は以下のようなオペレーションで満たすことができます。

  • 「未使用」状態のアイテムを複数件取得したい
    • → GSIをスキャンする
  • 「未使用」状態のアイテムを「使用済み」に更新したい
    • idを指定して1件のアイテムを条件付き書き込み
    • unUsedIdが存在する場合はunUsedIdを削除するように更新する

新しいテーブル定義は以下の通りです。

better-table-def.json

{
    "AttributeDefinitions": [
        {
            "AttributeName": "id",
            "AttributeType": "S"
        },
        {
            "AttributeName": "unUsedId",
            "AttributeType": "S"
        }
    ],
    "TableName": "better-table",
    "KeySchema": [
        {
            "AttributeName": "id",
            "KeyType": "HASH"
        }
    ],
    "GlobalSecondaryIndexes": [
        {
            "IndexName": "gsi-un-used-id",
            "KeySchema": [
                {
                    "AttributeName": "unUsedId",
                    "KeyType": "HASH"
                }
            ],
            "Projection": {
                "ProjectionType": "ALL"
            }
        }
    ],
    "BillingMode": "PAY_PER_REQUEST"
}

先ほどと同様テーブルの作成とContributor Insightsの有効化を行います。

aws dynamodb create-table --cli-input-json file://better-table-def.json
aws dynamodb update-contributor-insights --table-name better-table --contributor-insights-action=ENABLE
aws dynamodb update-contributor-insights --table-name better-table --index-name gsi-status --contributor-insights-action=ENABLE

先程利用した検証用コードを新しいテーブル定義に合わせて少し書き換えます

index2.js

const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
const { BatchWriteCommand } = require('@aws-sdk/lib-dynamodb');
const { v4 } = require('uuid');

const tableName = 'better-table';
const createReqItems = () => {

    const items = new Array(25).fill(null).map(() => {
        id = v4();
        return {
            PutRequest: {
                Item: {
                    id,
                    unUsedId: id
                }
            }
        }
    }
    )

    return {
        [tableName]: items
    }
}

(async () => {
    console.log(`start ${new Date()}`)
    const ddbClient = new DynamoDBClient();
    let items = 0;
    let errItems = 0;
    while (true) {
        const requestItems = createReqItems();
        items += requestItems[tableName].length;
        const batchWriteCmd = new BatchWriteCommand({
            RequestItems: requestItems
        })
        const res = await ddbClient.send(batchWriteCmd)
        const unprocessedItems = res.UnprocessedItems
        if(Object.keys(unprocessedItems).length !== 0) {
            errItems += unprocessedItems[tableName].length;
            console.error(`${new Date()} UnprocessedItems: ${errItems} / ${items}`)
        }
    }
})();

このコードをCloud Shellから実行します

node index2.js

しばらくしたら適当なところでCtrl + Cで実行を止めてメトリクスを確認しましょう。

テスト結果

まずはContributor Insightsです。改善前と異なりGSIの「消費されたスループットユニット」が複数のパーティションに分散しており、約60未満の低い水準で安定していることが分かります。

続いてGSIのWriteThrottleEventsを確認してみましたが、こちらはスロットリングが発生していないためメトリクス自体が存在しませんでした。ConsumedWriteCapacityUnits の1分当たりの合計値は156,300と改善前より若干上がっていました。これもスロットリングを回避できたことが影響してそうですね。

補足ですが、この設計のもう1つのメリットとして「使用済み」状態に更新されたアイテムについてはインデックスが削除されるため、ストレージ容量に対する従量課金が低く抑えられるというメリットもあります。

まとめ

カーディナリティの低い属性に対して素直にGSIを設定するとホットパーティションが生まれ、スロットリングを誘発するリスクがあることを確認しました。RDBに慣れていると気持ち悪く感じるかもしれませんが、DynamoDBのテーブル設計においてはスパースインデックス等のテクニックをうまく活用していくのが良いでしょう。

参考