Amazon Web Services ブログ

DynamoDB のスケーリング: パーティション、ホットキー、Split for heat がパフォーマンスに与える影響(第 1 部: ローディング)

Amazon DynamoDB の一般的な原則は、高いカーディナリティのパーティションキーを選択することです。しかし、なぜそのようにすべきなのか、そしてそうしなかった場合の影響は何か?お客様のユースケースをもとに、この疑問に深く迫り、異なるパーティションキーの設計とテーブルの設定を使用して DynamoDB のロードおよびクエリのパフォーマンスを調査します。

各実験の後、生成されたパフォーマンスグラフを分析し、私たちが観察したパターンを説明し、繰り返しの改善イテレーションを通じて、DynamoDB の内部構造の基礎を紹介し、パフォーマンスの高いアプリケーションを構築するためのベストプラクティスを紹介します。テーブルのパーティション、パーティションキー値、ホットパーティション、Split for heat、バーストキャパシティ、およびテーブルレベルのスループット制限についても説明します。

この 3 部構成のシリーズでは、まず初めに探究する問題を紹介し、データロードの戦略と短時間の DynamoDB 実行時の挙動に焦点を当てます。第 2 部では、クエリのパフォーマンスと継続した負荷に対して DynamoDB はどのように対応するかについて説明します。このシリーズは、学びとベストプラクティスをまとめた第 3 部で終了します。

AWS の試算によれば、今回調査するソリューションは非常にスケーラブルであり、アイテムの読み取りにかかるコストは約 0.18 ドル、継続的なストレージコストは月額 0.05ドルであり、完全に柔軟なオンデマンドモードでは 1 ドルで 800 万回の検索が可能です。最初はゆっくりとした読み取りから始め、最終的には平均レイテンシが 2 ミリ秒以下で数百万回のリクエストを処理している状態になります。

お客様の使用例: IPアドレス検索

この投稿は、高いカーディナリティのパーティションキーの重要性に関するお客様の質問に触発されました。多くの DynamoDB のユースケースでは、自然で明白な高いカーディナリティのパーティションキー(例: 顧客 ID や資産 ID など)がありますが、今回のケースは異なります。Accenture Federal Services は私たちに連絡して、IP アドレスのメタデータ検索サービスを設計したいと伝えました。彼らのデータセットは何十万もの IP アドレス範囲から成り、各範囲には開始アドレス(例: 192.168.0.0)、終了アドレス(例: 192.168.10.255)、および関連するメタデータ(例: 所有者、国、適用するセキュリティルールなど)が含まれていました。彼らのクエリは IP アドレスを受け入れ、それを含む範囲を見つけ、メタデータを返す必要がありました。

Accenture Federal Services が知りたかった事:

  • このルックアップサービスにおいて DynamoDB がどの程度有効か。
  • どのようなテーブル設計が最適か。
  • 実行時間、コスト、スケーラビリティの観点から最も効率的な設計は何か。
  • DynamoDB が達成できる 1 秒あたりの理論的な最大ルックアップ数はどのくらいか。
  • 彼らはまた、自分たちのユースケースが古典的な DynamoDB のユースケースのように見えないことを懸念していました。彼らは、それがパフォーマンスを制限するかどうかを知りたかったのです。

この投稿では、これらの問題に取り組み、これらの質問に答えます。

ルックアップアルゴリズム

テーブルを設計する前に、基本的なアルゴリズムを用意することが重要です。このアルゴリズムは、IP アドレスを受け入れ、効率的にそのアドレスを含む範囲を特定する必要があります。

DynamoDB のテーブルスキーマには、パーティションキーとオプションのソートキーがあります。追加の属性が存在するかもしれませんが、それらは(セカンダリインデックスに配置されない限り)インデックスされません。

パーティションキーとソートキーに詳しくない場合は、まず DynamoDB の基礎を学ぶことをお勧めします。動画が好きな方は、「DynamoDB: Its purpose, main features, and key concepts」と「DynamoDB: Under the hood, managing throughput, advanced design patterns」をご覧いただくとよいでしょう。

DynamoDB からデータを取得する 1 つの方法は、Query 操作を実行することです。Query はさまざまなことができますが、その 1 つは、パーティションキーが正確に指定され、ソートキーが不等式で指定された(パーティションキーが X に等しく、ソートキーが Y に等しくない)アイテムを取得することです。IP を IP 範囲から検索するために Query を使用できます。ただし、データが次の 2 つの条件を満たしている場合です:

  • データセット内の IP 範囲は重なってはいけません。これは本質的な条件であり、そうでなければデータセットは曖昧になります(同じ IP が複数のメタデータエントリを持つ可能性があるため)。
  • IP 範囲は隙間なくすべての IP アドレスを完全に記述しています。これは常に成り立つわけではありません。なぜなら、一部の IP 範囲は特に予約されていてメタデータを持っていないためです。しかし、合成範囲を使用してこの仮定を真にすることができます。合成範囲はメタデータが利用できないことを示すペイロードでギャップを埋めることができます。

各 IP 範囲は DynamoDB 内のアイテム(行)として保存できます。今のところ、単一の固定パーティションキー値(すべてのアイテムで同じ値)と、範囲の開始 IP アドレスに等しいソートキー値を仮定しましょう。以下の図 1 は、このデータモデルを示しています(後でこのデータモデルを改善します)。

図 1:固定パーティションキーを使用したデータモデル

図 1:固定パーティションキーを使用したデータモデル

Query はパーティションキーが 0 に等しく、かつソートキーが検索対象の IP アドレス以下であるアイテムを後方にスキャンし、結果を 1 つに限定することで見つけることができます。最初に一致した項目が、返すべきメタデータです。重複やギャップがないことが分かっているので、終了範囲の値を考慮する必要はありません。以下は、AWS Lambda 関数のテスト実行を示す Python コードです:

import json
import boto3
from boto3.dynamodb.conditions import Key

client = boto3.resource('dynamodb')
table = client.Table('IPAddressRanges')

def lambda_handler(event, context):
    pk = '0'
    ip = event.get('ip')
    response = table.query(
        KeyConditionExpression=Key('PK').eq(pk) & Key('SK').lte(ip),
        ScanIndexForward = False,
        Limit = 1
    )
    print(response['Items'][0])

このアルゴリズムがどのように機能するのか理解するのに苦労している場合は、視覚的な表現が助けになるかもしれません。田舎にあるフェンスがあり、そこにはランダムな間隔でたくさんのフェンスの支柱が立っていると想像してください。フェンスに沿って歩く特定の距離に対応するフェンスの区間を見つけたいとします。この問題を解決するためには、まずその距離だけ歩き進み、それから後ろに向かって歩いてフェンスの支柱に遭遇します。そのフェンスの支柱には、その区間に関するメタデータがすべて含まれています。各フェンスの支柱は、それに続く区間を説明しています。反対側のフェンスの支柱には、それに続く区間のメタデータが含まれています。もしフェンスに隙間がある場合、その隙間の直前のフェンスの支柱には「ここに隙間がある」と記されています。

ただし、1 つのパーティションキー値だけを使用すると、クエリは簡単になりますが、これは DynamoDB のパーティションキー値間のカーディナリティを高くするベストプラクティスに直接反してしまいます。これに関する影響をテストしてみましょう。

データ形式

テストを実行するには、まず実際のデータ形式を考慮する必要があります。ソースデータは CSV ファイルにあり、1 行に 1 つの IP 範囲が記載されており、ギャップやオーバーラップはありません。各行には開始および終了の IP 値とメタデータが含まれています。以下は、可読性を高めるために空白を追加した模擬例です:

Netblock     , start     , end         , metadata
1.0.0.0/24   , 1.0.0.0   , 1.0.0.255   , Range 1.0.0.0/24 metadata
1.0.1.0/24   , 1.0.1.0   , 1.0.1.255   , Range 1.0.1.0/24 metadata
1.0.2.0/23   , 1.0.2.0   , 1.0.3.255   , Range 1.0.2.0/23 metadata
1.0.4.0/22   , 1.0.4.0   , 1.0.7.255   , Range 1.0.4.0/22 metadata
1.0.8.0/21   , 1.0.8.0   , 1.0.15.255  , Range 1.0.8.0/21 metadata
1.0.16.0/20  , 1.0.16.0  , 1.0.31.255  , Range 1.0.16.0/20 metadata
1.0.32.0/19  , 1.0.32.0  , 1.0.63.255  , Range 1.0.32.0/19 metadata
1.0.64.0/18  , 1.0.64.0  , 1.0.127.255 , Range 1.0.64.0/18 metadata
1.0.128.0/17 , 1.0.128.0 , 1.0.255.255 , Range 1.0.128.0/17 metadata

開始 IP 文字列をソートキーとして使いたくなるかもしれませんが、文字列を数字のように扱うのは危険です。.100 が .1 と .2 の間にくることがあります。1.0.32.0 が 001.000.032.000 になるように、値にゼロパッドするのが 1 つの解決策です。数字が常に3 桁である場合、文字列と数字は同様にソートされます。

もっと良い方法があります: 各 IP アドレスをその自然な数値に変換することです。IP アドレスはほとんどの場合、1.0.32.0 のようなドット付き 4 進数形式で書かれていますが、これはあくまで人間にとって分かりやすい表現です。基本的に、IP アドレス(IPv4 の場合)は単一の 32 ビットの値です。

IP アドレス 1.0.32.0 を 2 進数に変換すると(ただし、可読性のためにピリオドは保持されます)、00000001.00000000.00100000.00000000 となり、これを 10 進数に変換すると 16,785,408 になります。これがソートキーとして使用される番号モデルです。不等式演算がシンプルで効率的であり、かつストレージがコンパクトです。

次に続く図 2 は、IP アドレスを数値に変換した後のテーブルがどのように見えるかのスニペットです。

図 2: IP アドレスを数値に変換したテーブル

図 2: IP アドレスを数値に変換したテーブル

ローディング

さて、テストを開始する準備が整いました。まずは、ロード性能から始めます。CSV ファイルは、シンプルなループを使用したシングルスレッドの Python スクリプトでロードします。すべてのテスト結果は us-east-1 リージョンから収集されました。

ロードテスト: オンデマンドテーブル、シーケンシャルな CSV、単一のパーティションキー

最初のテストとして、新しく作成されたオンデマンドテーブルに対して一括ロードを実行します。CSV ファイルからロードし、Python コードによってすべてのアイテムに同じパーティションキーを割り当て、IP 範囲の文字列を数値に変換します。CSV ファイルのデータは IP 範囲でソートされており、低い IP が先頭にあることに留意してください。このような順序は CSV ファイルで一般的であり、後の実験で重要となります。次に示す図 3 は、その結果です。

図 3: 最初のロードテストの結果:オンデマンドテーブル、シーケンシャルな CSV、単一のパーティションキー

図 3: 最初のロードテストの結果:オンデマンドテーブル、シーケンシャルな CSV、単一のパーティションキー

図 3 に示されているように、最初のロードテストは毎秒 1,000 書き込みユニットの安定したレートで実行されます。残りの書き込みリクエストは制限されています。

裏側で何が起こっているかを以下に説明します: すべての DynamoDB テーブルは複数の物理パーティションに分散されています。各物理パーティションは毎秒 3,000 の読み取りユニットと毎秒 1,000 の書き込みユニットをサポートできます。1 つのパーティションキー値を使用することで、すべての書き込みが 1 つのパーティションに送信され、それがボトルネックを引き起こしています。

新しいオンデマンドテーブルが 1 つのパーティションしか割り当てないことを意味するわけではありません。オンデマンドテーブルは常にライブトラフィックに適応し、新しく作成されたオンデマンドテーブルは最大で 4,000 の書き込みリクエストユニットおよび 12,000 の読み取りリクエストユニットを提供できると記載されています。詳細については、「読み取り/書き込みキャパシティーモード」を参照してください。

これらのスループット性能は、まさに 4 つのパーティションを持つテーブルと同じです。つまり、4 つのパーティションが利用可能でも、単一のパーティションキー値を使用しているため、すべてのトラフィックはそのうちの 1 つのパーティションに割り当てられ、他の 3 つのパーティションは非アクティブなままです。

データがどのようにパーティションに割り当てられるかを簡単に復習しましょう: 各パーティションは、テーブルのキースペースのサブセットを担当します。パーティションキー値はハッシュ化され、数値に変換されます。そして、その数値を含む範囲のパーティションが、そのパーティションキー値の読み取りまたは書き込みを処理します。パーティションキー値が常に同じであれば、一般的に同じパーティションがすべての読み取りと書き込みを処理します。異なるパーティションキーが同じ数値範囲の一般領域にハッシュすることがあり、その場合、その範囲を処理するパーティションに配置されます。パーティションは分割でき、そのアイテムを 2 つの新しいパーティションに移動し、数値範囲に新しい分割ポイントを導入できます。パーティションはアイテムコレクション内で分割できる場合もあります(同じパーティションキー値を持つアイテム間で)、その場合、ソートキーは分割ポイントの計算に考慮されます。

しかし、1 つのパーティションキー値を使うと、最初はすべてのアイテムが同じパーティションに配置されるため、書き込みが大幅に制限されます。

ロードテスト: オンデマンドテーブル、シーケンシャルな CSV、複数のパーティションキー

データを複数のパーティションキー値に分散させれば、ロードを高速化できます。例えば、IP アドレスの最初の部分をパーティションキー値として選択することができます。例えば、192.168.0.0 はパーティションキーが 192 になります。これにより、書き込みが 200 以上のパーティションキー値に分散され、作業がパーティション間でより均等に分散されるはずです。

クエリを調整して IP 範囲に基づいて正しいパーティションキーを指定し、範囲がパーティションキーの境界を越えないようにローダーを調整する必要があります。そうでなければ、コアロジックが失敗します。新しいテーブルデザインは、次に続く図 4 のようになります。

図 4: 各 /8 アドレス範囲に対する異なるパーティションキーを示すテーブルデザイン

図 4: 各 /8 アドレス範囲に対する異なるパーティションキーを示すテーブルデザイン

次に続く図 5 は、ロードテスト中のパフォーマンスを示しています。

図 5: 2 回目のロードテストの結果: 今回は多くのパーティションキーを使用

図 5: 2 回目のロードテストの結果: 今回は多くのパーティションキーを使用

ロードは毎秒約 1,250 回の書き込みに改善されました。なぜそれ以上ではないのか、なぜ作業がパーティション間でうまく分散されないのか。それは CSV ファイルが順次処理されているからです。各パーティションキー値に対応する何千もの範囲が次から次へと続いています。あるパーティションが一時的にすべてのトラフィックを処理し、そして別のパーティションが、また別のパーティションが順にトラフィックを処理します。十分に均等に広がっていません。唯一の改善点は、パーティションキー値が切り替わり、新しいパーティションがスロットリングされるパーティションとして順番に割り当てられるときです。

このケースからの教訓は、順次処理された CSV ロードはトラフィックをうまく分散しないということです。

ロードテスト: オンデマンドテーブル、ランダムなCSV、複数のパーティションキー

CSV ファイルのエントリの順序をランダムにすると(ファイルを調整するか、Python ローダー内で内部的なシャッフルを行う)、ロードをより均等に分散させ、パフォーマンス向上が期待できます。次に示す図 6 は、我々のテスト結果を示しています。

図 6: 3 回目のロードテストの結果: 今回は行をランダム化した CSV ファイルを使用

図 6: 3 回目のロードテストの結果: 今回は行をランダム化した CSV ファイルを使用

このテストは 2 分未満で終了しました。最初と最後のタイミングは部分的なものであり、秒単位のレートを推測することはできません。完全に測定された中央の 1 分間では、毎秒約 3,600 回の書き込みに達しました。これは4つのパーティションが効果的に利用されていることとよく一致しています。データの書き込み順序をランダムにするだけで、書き込みスループットが大幅に向上しました。

これらの理由から、CSV ファイルの行が(つまり、パーティションキー値でグループ化されている)パーティションキー値でソートされている場合は、CSV のロードをランダムにすることが重要です。

また、もしソースデータが他の DynamoDB テーブルの Scan の結果である場合は、ロード前にソースデータをランダムにすることが役立ちます。なぜなら、Scan で返されるアイテムは自然にパーティションキーのハッシュ値でグループ化され、ロード中に安定したヒートマップが生成される可能性があるからです。

ロードテスト: プロビジョニングされたテーブル、ランダムなCSV、複数のパーティションキー

ここまでのテストはすべて、新しく作成されたオンデマンドテーブルを対象としていました。では、10,000 の書き込みキャパシティユニット(WCU)でプロビジョニングされたテーブルをテストしてみましょう(シンプルにするため、オートスケーリングは有効にしません)。引き続きランダムな CSV ファイルを使用します。次に示す図 7 は、私たちが観測した結果です。

図 7: 4 回目のロードテストの結果: 10,000 WCU でプロビジョニングされたテーブルを使用

図 7: 4 回目のロードテストの結果: 10,000 WCU でプロビジョニングされたテーブルを使用

ロードは1分未満で完了しました。すべてのアクティビティは、毎秒約 6,000 回の書き込みで、1分未満のデータポイントに集約され、その1分間のピークはこれを上回っていました。

10,000 WCU で新たにプロビジョニングされたテーブルは、新しいオンデマンドテーブルよりもパーティションが多く必要です(この書き込み負荷を処理するためには少なくとも10のパーティションが必要です)。そして、これらの追加のパーティションに多くのパーティションキー値を分散させることで、パフォーマンスを向上させることができました。

これはプロビジョニングがオンデマンドよりも優れているという意味ではありません。なぜなら、オンデマンドは素早くトラフィックに応じてキャパシティとパーティションを調整し、増やしていくからです。ただし、オンデマンドテーブルのデフォルトサイズは10,000 WCU 未満です。テーブルが最初から高いトラフィック負荷を受けると予想される場合は、新しいオンデマンドテーブルを特定の初期キャパシティでプロビジョニングし、それを後でオンデマンドに切り替えることでテーブルを事前にウォームアップすることをお勧めします。

ローディングテスト: まとめ

最大の負荷率は、物理パーティションの数、パーティションキー値の数、そして負荷がパーティション全体にわたって作業を並列化する能力に依存します。より多くのパーティションは負荷率を増加させる傾向がありますが、パーティションが最も有益なのは、十分なパーティションキー値が作業をパーティション全体に分散させ、かつロードのロジックが作業をパーティションキー値にわたって分散させる場合です。

第 2 部では、クエリのパフォーマンスと、継続した負荷に対して DynamoDB はどのように対応するかについて探求します。

まとめ

この 3 部構成のシリーズでは、異なるパーティションキー設計を使用してロードおよびクエリのパフォーマンスをテストすることで、DynamoDB の内部について学びました。テーブルのパーティション、パーティションキーの値、ホットパーティション、Split for heat、バーストキャパシティ、およびテーブル全体のスループット制限について説明しました。

いつものように、ご質問やフィードバックはコメントでお気軽にどうぞ。


作者情報

Jason HunterJason Hunter はカリフォルニアを拠点とする DynamoDB 専門のプリンシパルソリューションアーキテクト。2003 年より NoSQL データベースの開発に従事。Java、オープンソース、XML への貢献で知られる。

Vivek NatarajanVivek Natarajan は Purdue 大学で CS を専攻しており、AWS のソリューションアーキテクトインターン生である。

(本記事は 2023/01/30 に投稿された Scaling DynamoDB: How partitions, hot keys, and split for heat impact performance (Part 1: Loading) を翻訳した記事です。翻訳は SA 鈴木が担当しました。)