中級読了 4 分

DynamoDB における 1 対多のリレーションシップ

SaaS のコントロールプレーンは、ほぼ必ず包含の階層構造を持ちます。1 つのワークスペースが多くの プロジェクトを所有する、といった具合です。SQL であれば projects テーブルに workspace_id の外部 キーを置いて JOIN するでしょう。

DynamoDB には結合も外部キーもないため、リレーションシップはキースキーマそのものの中に置かなければ なりません。正しく行えば、「あるワークスペースとその中のすべてのプロジェクトを読み込む」処理は、 1 回の読み取りに続けてスキャンを行うのではなく、単一の Query になります。

DynamoDB で 1 対多のリレーションシップはどうモデリングするのか?

親とそのすべての子に同じを与えて 1 つのを共有させ、次にソートキーで区別します。DynamoDB には結合も外部キーもないため、リレーションシップはキースキーマそのものの中に置かれます。すると、親とそのすべての子を読み込む処理は、結合ではなく単一の Query になります。

  • エンティティではなく、読み取りをモデリングする。 この 1 対多のリレーションシップは 「ワークスペースのプロジェクトを一覧表示する」という要件のためだけに存在します — そのクエリを 中心にキーを形づくりましょう。
  • 親を子のにエンコードする。 ワークスペースとそのすべての プロジェクトに同じパーティションキー値を与え、1 つのに 収まるようにします。
  • すると一覧の読み取りは 1 回の Query になる。 親とその子がまとめて返ってきます — 結合も 2 回目の往復もありません(Query は 1 ページあたり最大 1 MB を返し、それを超える分は LastEvaluatedKey でページングします)。
  • に注意する。 1 つの巨大なテナントはすべてのトラフィックを 1 つのパーティションに集中させます。巨大なワークスペースにはシャーディングされたキーと ファンアウトの読み取りが必要になることがあります。

まずはアクセスパターン

DynamoDB のモデリングはエンティティ優先ではなく、アクセスパターン優先です — これは シングルテーブル設計 の背後にあるのと同じ規律です。どんなキーを選ぶ 前にも、アプリが実際に発行する読み取りを書き出しましょう。

  • あるワークスペースの設定を取得する。
  • あるワークスペース内のすべてのプロジェクトを、新しい順に一覧表示する。
  • 特定のプロジェクトを id で取得する。

「1 つのワークスペース、多数のプロジェクト」というリレーションシップが重要なのは、読み取り #2 が あるからだけです。ワークスペースのプロジェクトをまとめて一覧表示する必要がまったくないのなら、 このリレーションシップをモデリングすることもなく — プロジェクトを独立して保存するでしょう。

ですから問いは、抽象的に「1 対多をどう表現するか?」では決してありません。「このリレーションシップ はどのクエリに応える必要があるのか?」です。それに答えてから、それを中心にキーを形づくりましょう。

なぜ外部キーはここでは役に立たないのか

DynamoDB ではすべての GetItemQueryパーティションキーをターゲットにし、サービスはその キーをハッシュ化してアイテムを保持するパーティションを特定します。

AWS は Core Components のドキュメントでそれを直接述べています。パーティションキー値は、データがどこに存在するかを決める 内部ハッシュ関数への入力である、と。

このハッシュベースの配置は、2007 年の元論文 Dynamo: Amazon's Highly Available Key-value Store から 受け継いだものです。この論文では、コンシステントハッシュがキーをノード間に分散させます。

プロジェクトアイテム上の裸の workspace_id 属性 は、その仕組みからは見えません — DynamoDB は それを「たどる」ことができないのです。

関連するアイテムを 1 回のリクエストで取得するには、親の識別子をプロジェクトのパーティションキーに エンコードしなければなりません。そうすれば、あるワークスペースのすべてのアイテムが同じパーティションに ハッシュされ、1 つの Query でそれらをまとめて取得できます。

実例: ワークスペースとプロジェクト

汎用的で、オーバーロードしたキースキーマを使います。パーティションキーを EntityRef、ソートキーを Detail と名付けましょう。ワークスペースの識別子は、ワークスペースアイテムとその配下のすべての プロジェクトの両方について EntityRef に入ります。

EntityRefDetailattributes
WS#acmeMETAdisplayName, region, seatLimit
WS#acmePROJ#2026-0007title, status, createdBy
WS#acmePROJ#2026-0042title, status, createdBy
WS#acmePROJ#2026-0118title, status, createdBy
WS#globexMETAdisplayName, region, seatLimit
WS#globexPROJ#2026-0009title, status, createdBy

ワークスペースとそのすべてのプロジェクトは EntityRef = "WS#acme" を共有するため、1 つのパーティション 上でともに存在する単一のアイテムコレクションを形成します。

Detail ソートキーがそれらを区別します。META はワークスペースのレコードで、各プロジェクトは PROJ# プレフィックスに、ゼロ埋めされた時間順の id を付けて持つため、プロジェクトは自然にソート されます。

視覚的には、親とその子が 1 つのパーティション内でソートキー順に積み重なります。

パーティション: EntityRef = WS#acmeMETA ワークスペース設定PROJ#2026-0007PROJ#2026-0042PROJ#2026-0118

EntityRef = "WS#acme" に対する 1 つの Query が、そのスタック全体 — 親とすべての子 — を 1 回の 読み取りで取得します。

これで、3 つのアクセスパターンがそれぞれ 1 回の呼び出しに収まります。

  • ワークスペースの設定GetItem(EntityRef="WS#acme", Detail="META")
  • プロジェクトを新しい順に一覧表示Detail begins_with "PROJ#" を指定した Query(EntityRef="WS#acme") を、降順(ScanIndexForward = false)で実行。
  • 1 つのプロジェクトGetItem(EntityRef="WS#acme", Detail="PROJ#2026-0042")

2 つ目こそが肝心です。親とその子が1 つQuery から返ってきます。結合も 2 回目の往復も ありません — DynamoDB は 1 ページあたり最大 1 MB を返し、残りを取得するための LastEvaluatedKey を 渡します。これは、外部キー属性と Scan ではできない技です。

その begins_with 条件を手で書くのは面倒です — キー条件式と射影式の構文が厄介なのです。

DynamoDB Expression BuilderKeyConditionExpression#name/:value のプレースホルダーマップ、そしてすぐ実行できる SDK スニペットを生成するので、 文法と格闘せずに済みます。

KeyConditionExpression     "#er = :er AND begins_with(#d, :p)"
ExpressionAttributeNames   { "#er": "EntityRef", "#d": "Detail" }
ExpressionAttributeValues  { ":er": "WS#acme", ":p": "PROJ#" }

DynoTable でアイテムコレクションを確認する

このレイアウトの成果は視覚的です。EntityRef を共有するすべての行が、ワークスペースとその子であり、 隣り合って並びます。

DynoTable はそれらをグループ化するため、1 対多のリレーションシップを、別々のテーブルにまたがって 推測するのではなく、1 つの連続したブロックとして目にできます。

DynoTable のテーブルビューで、1 つのアイテムコレクションとしてグループ化されたワークスペースの META アイテムとその PROJ# の子。
DynoTable のテーブルビューで、1 つのアイテムコレクションとしてグループ化されたワークスペースの META アイテムとその PROJ# の子。

落とし穴と代替の形

注意すべき点がいくつかあります。

  • ホットパーティション。 1 つのワークスペースのすべてのアイテムが 1 つのパーティションに存在する ため、1 つの非常に大きいまたは非常に忙しいテナントがトラフィックを集中させます。AWS が説明する アダプティブキャパシティ の挙動は中程度の偏りを吸収しますが、数百万のプロジェクトを持つワークスペースには、シャーディング されたキー(例: WS#acme#01 … #10)とファンアウトの読み取りが必要になることがあります。
  • アイテムコレクションのサイズ。 ローカルセカンダリインデックスがある場合、単一パーティションの アイテムコレクションは 10 GB に制限されます。LSI がなければそのような制限はありません。ここで インデックスの種類を比較検討しているなら、GSI vs LSI を参照してください。
  • Scan ではなく必ず Query を選ぶ。 この設計全体は、1 つのパーティションを Query できるように するために存在します。「ワークスペースのプロジェクトを見つける」ためにフィルタ付きの Scan に 頼るのは、モデルを捨ててテーブル全体を読むことです — これは Query vs Scan で扱う罠です。

ワークスペースをまたいでプロジェクトを一覧表示する必要が本当にある場合(たとえば、グローバルに status = ACTIVE のすべてのプロジェクト)、ベーステーブルはそれに答えられません — そのパーティション キーはワークスペース単位にスコープされているからです。

それは、このリレーションシップを作り直すのではなく、別の属性でプロジェクトを再パーティションする セカンダリインデックスの仕事です。

次のステップ

アクセスパターンをモデリングし、親を子のパーティションキーにエンコードすれば、1 対多の読み取りは 単一の Query になります。DynamoDB Expression Builder で キー条件を構築して検証しましょう — アクセスパターンそのものから始めたいなら、無料の シングルテーブル設計ツールが、例のアイテム付きの PK/SK/GSI プランを下書きしてくれます。

その後、DynoTable をダウンロードしてこのスキーマを読み込み、ワークスペース→プロジェクトの アイテムコレクションをライブで閲覧し、各クエリが正確に 1 回の読み取りを行うことを確認してください。 ワークスペースとプロジェクトを結合されたリレーショナルビューとして見たいなら、 DynoTable の SQL Workbench がその JOIN も実行します。

更新日

この設計をインタラクティブに試す

無料の DynamoDB シングルテーブル設計ツールでエンティティとアクセスパターンをスケッチしてみてください — PK/SK キーテンプレートを提案し、アイテムコレクションをプレビューし、どのパターンに GSI が必要かを示します。

シングルテーブル設計ツールを開く