中級読了 3 分

DynamoDB の一対多リレーションシップ

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

DynamoDB には結合も外部キーもないため、リレーションシップは キースキーマ そのものに 住まわせる必要があります。正しく設計すれば、「ワークスペースとその中のすべての プロジェクトを読み込む」が、1回の読み取りに後追いのスキャンが続く形ではなく、単一の Query になります。

DynamoDB で一対多リレーションシップをモデリングするには?

親とすべての子に同じパーティションキーを与えて同じアイテムコレクションに収め、ソートキーで区別します。DynamoDB には結合も外部キーもないため、リレーションシップはキースキーマ自体に住まわせます。こうすることで、親と全ての子の読み込みが結合の代わりに単一の Query になります。

  • エンティティではなく読み取りをモデリングする。 一対多リレーションシップは 「ワークスペースのプロジェクトを一覧する」に応えるためだけに存在します — そのクエリを 中心にキーを形作りましょう。
  • 親を子のパーティションキーにエンコードする。 ワークスペースとそのすべての プロジェクトに同じパーティションキー値を与え、1つの アイテムコレクション に まとまるようにします。
  • そうすれば一覧の読み取りは1回の Query 親と任意の数の子が、結合も2回目の ラウンドトリップもなしに、単一の課金対象呼び出しで返ってきます。
  • ホットパーティションに注意。 巨大なテナント1つがすべてのトラフィックを1つの パーティションに集中させます。巨大なワークスペースには、シャーディングしたキーと ファンアウトの読み取りが必要になることがあります。

まずアクセスパターンから

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

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

「1つのワークスペース、多くのプロジェクト」というリレーションシップが意味を持つのは、 読み取り #2 があるからこそです。ワークスペースのプロジェクトをまとめて一覧する必要が 一度もないなら、リレーションシップをモデリングすること自体しません — プロジェクトを 独立して保存するでしょう。

つまり問いは抽象的な「一対多をどう表現するか」では決してありません。「このリレーション シップはどのクエリに応えなければならないか」です。それに答えてから、キーをそのまわりに 形作りましょう。

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

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つのパーティション内でソートキー順に積み重なります。

Partition: EntityRef = WS#acmeMETA ワークスペース設定PROJ#2026-0007PROJ#2026-0042PROJ#2026-0118

EntityRef = "WS#acme" に対する1回の Query が、その積み重ね全体 — 親と すべての子 — を単一の読み取りでさらいます。

これで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つ目こそが肝心です。親と任意の数の子が、結合も2回目のラウンドトリップもなしに、 1回 の課金対象 Query で返ってきます。外部キー属性と Scan ではできない動きです。

その begins_with 条件を手で書くのは厄介です — キー条件式とプロジェクション式の 構文に噛まれます。

DynamoDB Expression Builder は、文法と戦わなくて 済むよう、KeyConditionExpression#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つの連続したブロックとして見られます。

DynoTable のテーブルビューで、ワークスペースの META アイテムとその PROJ# 子を1つのアイテムコレクションとしてまとめて表示。
DynoTable のテーブルビューで、ワークスペースの META アイテムとその PROJ# 子を1つのアイテムコレクションとしてまとめて表示。

落とし穴と別の形

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

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

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

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

次のステップ

アクセスパターンをモデリングし、親を子のパーティションキーにエンコードすれば、 一対多の読み取りは単一の Query になります。 DynamoDB Expression Builder でキー条件を組み立てて 検証しましょう。

そして DynoTable をダウンロード して、このスキーマを読み込み、 ワークスペース→プロジェクトのアイテムコレクションをライブで閲覧し、各クエリが きっかり1回の読み取りで済むことを確認してください。

更新日