中級読了 4 分

DynamoDB の JOIN: テーブルを結合する方法(と、たいてい結合できない理由)

DynamoDB に JOIN はありません。API には結合演算子がなく、データモデルには 外部キーがなく、そして — ほとんどの人を驚かせる部分ですが — SQL 風のクエリ層 である PartiQL でも結合は追加されません。 PartiQL の SELECT は、 ちょうど 1 つのテーブルだけを読み取ります。

リレーショナルデータベースから来た人にとって、これは最初にぶつかる壁です。本 ガイドでは、なぜその壁が存在するのか、開発者が代わりに使う 4 つの手法、本当に 本物の結合が必要になる唯一のケース、そしてそれをどう実行するかを扱います。

DynamoDB で JOIN はできますか?

いいえ。DynamoDB はテーブルを結合できません — 低レベル API(GetItem / Query / Scan / BatchGetItem)でも、PartiQL でも、組み込みのクエリプランナーでもできません。クエリプランナーが存在しないからです。すべての読み取りは単一テーブルまたはそのインデックスの 1 つにマッピングされます。一致するキーで 2 つのテーブルを組み合わせるのは、DynamoDB がアイテムを返した にアプリ側で行うことであり、DynamoDB の内部で行うことは決してありません。

  • DynamoDB には JOIN 演算子がありません。これまで一度もありませんでした。
  • PartiQL の SELECT単一テーブル専用 です — 文法は文字どおり SELECT … FROM {{table}}[.{{index}}] であり、2 つのテーブルを指定すると ValidationException: Only Select from a Single Table or index supported が 返ります。
  • AWS が推奨する解決策は 結合を必要としないこと です。非正規化するか、 シングルテーブル設計を使って、関連するアイテムを 1 回のリクエストで取得できる 1 つのパーティションにまとめます。
  • 本物のクロステーブル / アドホックなケースでは、DynamoDB の 外側で 結合します — アプリ内で、あるいは代わりに結合してくれるツールで。

DynamoDB は結合できる?

いいえ。DynamoDB はテーブルを結合できません — 低レベル API(GetItem / Query / Scan / BatchGetItem)でも、PartiQL でも、組み込みのクエリプランナーでも できません。クエリプランナーが存在しないからです。すべての読み取りは単一テーブル またはそのインデックスの 1 つにマッピングされます。一致するキーで 2 つのテーブルを 組み合わせるのは、DynamoDB がアイテムを返した に行うことであり、その内部で 行うことは決してありません。

これは AWS が埋め忘れたギャップではありません。意図的な設計上の決定であり、回避策に 手を伸ばす前にその理由を理解しておく価値があります。

DynamoDB に結合がない理由

SQL の JOIN は、複数のテーブルを読み取り、クエリ実行時にそれらを組み立てるよう データベースに要求します。AWS 自身の リレーショナルデータのモデリングガイド は、そのコストを明確に述べています。次のようなクエリは

SELECT * FROM Orders
  INNER JOIN Order_Items ON Orders.Order_ID = Order_Items.Order_ID
  INNER JOIN Products    ON Products.Product_ID = Order_Items.Product_ID
  ORDER BY Quantity_on_Hand DESC

柔軟ですが、「各テーブルのデータをステージしてから組み立てなければならないため、 クエリ内の各結合はクエリの実行時複雑度を増大させる」とあります。その作業は無制限 です — そのコストはクエリではなくデータに依存します — これはまさに DynamoDB が 持つことを拒否する性質です。

そこで AWS はその制約を設計に組み込みました。DynamoDB は、彼らの言葉を借りれば、 「JOIN を排除し(そしてデータの非正規化を推奨し)、1 回のアイテムへのリクエストで アプリケーションのクエリに完全に応答するようデータベースアーキテクチャを最適化する ことで、[CPU とネットワークの両方の]制約を最小化するよう構築されている」のです。 これらは、どんな規模でも 1 桁ミリ秒のレイテンシをもたらす性質です。DynamoDB の 読み取りの実行時コストは、テーブルサイズに関わらず一定です。設計上、計画すべき 結合エンジンも外部キーの概念も存在しません。

「でも PartiQL は SQL でしょう、当然結合できるのでは?」

いいえ。PartiQL は DynamoDB に対して SELECT / INSERT / UPDATE / DELETE の 構文を提供しますが、SQL 互換 であって SQL ではありません。 公式の SELECT 文法 は次のとおりです。

SELECT  {{expression}}  [, ...]
FROM    {{table}}[.{{index}}]
[ WHERE {{condition}} ]
[ ORDER BY {{key}} [DESC|ASC], ... ]

FROM1 つの テーブル(任意でそのインデックスの 1 つ)を取ります。2 つ目の FROM テーブルも、JOIN も、サブクエリも、CTE もありません。PartiQL を 2 つの テーブルに向けると DynamoDB は拒否します (AWS re:Post で報告)。

ValidationException: Only Select from a Single Table or index supported

PartiQL が SQL のように見えてなぜ SQL のように振る舞えないのか、その完全な理由は PartiQL vs SQL を参照してください。

開発者が実際に使う 4 つの回避策

1. 非正規化する(データをコピーして入れる)

本来結合するフィールドを、アイテムに直接保存します。Order は、後で解決する customerId の代わりに、customerNameshippingAddress のスナップショットを 持ちます。1 回の読み取り、結合なし。

そのコストは書き込み時のファンアウトです。ソースが変わったらすべてのコピーを更新します (通常は DynamoDB Streams のハンドラ経由)。読み取りの複雑さを書き込みの複雑さと 引き換えにしているわけです — 読み取りの多いアプリでは、たいてい良い取引です。

2. シングルテーブル設計(パーティション内で事前結合する)

関連するエンティティを、共有のパーティションキーの下で 1 つのテーブル にまとめ、 アイテムコレクションが結合済みの結果 そのもの になるようにします。顧客とその すべての注文が PK = "CUSTOMER#42" を共有し、1 回の Query で顧客アイテムと すべての注文アイテムが返ります — 「結合」は書き込み時にすでに行われていたのです。

Query  PK = "CUSTOMER#42"
→ CUSTOMER#42 / PROFILE      (顧客)
→ CUSTOMER#42 / ORDER#1001   (注文)
→ CUSTOMER#42 / ORDER#1002   (注文)

これは一対多の関係に対する DynamoDB の定番の答えです。詳しい手順は シングルテーブル設計を参照してください。

3. アプリケーション側の結合(2 回読んでコードで繋ぐ)

テーブル A から読み取り、得られたキーを取り、テーブル B から読み取り、2 つの結果 セットをアプリケーション内でマージします。リレーショナルな結合ロジックそのものを、 データベースの代わりにあなたのコードで実行するだけです。

// 「各注文を顧客名つきで取得」— 手動の結合。
const {Items: orders} = await ddb.query({TableName: 'Orders' /* … */});

const customers = await Promise.all(
  orders.map((o) => ddb.getItem({TableName: 'Customers', Key: {id: o.customerId}}))
);

const joined = orders.map((o, i) => ({
  ...o,
  customerName: customers[i].Item?.name
}));

ファンアウトが小さければ問題ありません。注文が多いと N+1 問題 になります — 注文を一覧する 1 回の読み取り、その後に注文ごとに 1 回の読み取り — これは遅く、 読み取りキャパシティを消費します。BatchGetItem(次項)は、その第 2 波を 1 回の 往復にまとめます。

4. BatchGetItem(1 回の往復、複数のテーブル)

BatchGetItem は、API が「2 つのテーブルに同時に触れる」ことに最も近いものです。1 回のリクエストで 「1 つ以上のテーブルから 1 つ以上のアイテムの属性」を返し、1 回の呼び出しあたり 最大 100 アイテムまたは 16 MB(先に達したほう)まで対応します。アプリ側結合の 往復を削減しますが、結合では ありません。「リクエストするアイテムをプライマリキーで 識別する」のであり、ON 条件もリレーショナルなマッチングもありません。事前にキーを 知っておき、レスポンスを自分で繋ぎ合わせる必要があります。

本物の JOIN が避けられないとき

4 つの回避策は本番の読み取りパスをよくカバーします。それらが力尽きるのは、 アドホックで探索的、分析的な クエリ — モデリングしなかったクエリ — です。

  • Orders テーブルと Customers テーブルにまたがる 「先月 500 ドルを超える注文を した EU の顧客は誰か?」
  • 2 つのエンティティ型を結合する一度きりのデータ品質チェック。
  • レポーティングと集計(GROUP BYSUMCOUNT)— DynamoDB にはこれらの演算子が まったくありません。

これらはまさにパーティションに事前焼き込みできないクエリです。定義上、自分がそれを 尋ねると知らなかったからです。リレーショナルな本能 — JOIN を書く — はここでは 正しいのです。DynamoDB はそれをネイティブには提供できず、PartiQL も同様です。

通常の重量級の答えは、 S3 にエクスポートして Athena でクエリするか、 データウェアハウスに流し込むことです。大規模な真の分析には正しいですが、ライブテーブルに 対して 答えが欲しい質問には、かなりの配管作業です。

DynoTable の SQL Workbench で本物の JOIN を実行する

DynoTable は、SQL Workbench が実際の SQL — JOINGROUP BY、 集計関数を含む — を DynamoDB テーブルに対して実行するデスクトップ DynamoDB クライアントです。通常の DynamoDB API を通してアイテムを読み取り、クエリの リレーショナルな部分をクライアント内で実行します。つまり、次のように書けます。

SELECT  c.name, SUM(o.total) AS spend
FROM    Customers c
JOIN    Orders o ON o.customerId = c.id
WHERE   c.region = 'EU'
GROUP BY c.name
HAVING  SUM(o.total) > 500

— そして、関係が定義されていないテーブルと JOIN キーワードを持たないクエリ エンジンに対して、結果セットが返ります。

正直な注意点 — 「DynamoDB のアクセスパターンのルール内で」: Workbench は依然として DynamoDB を通して読み取るため、無制限の結合は無制限の読み取りです。最速のクエリは、 WHERE 句(または結合の ON 属性)が少なくとも片側でパーティションキーや GSI にヒットするものです。そうすると DynamoDB は、結合の実行前に フルテーブルスキャンではなく Query を実行します。Workbench は 本ガイドの制約を撤廃するわけではありません — ただ、繋ぎ合わせを手で書く代わりに SQL の質問を尋ねられる ようにし、内部で何をしているかを教えてくれるだけです。

これが唯一、本当に「はい、結合できます」と言える答えです。PartiQL も AWS 自身の NoSQL Workbench — その操作ビルダーは単一テーブルのデータプレーン操作(Query / Scan / GetItem)に 限定されています — も、他のほとんどの GUI クライアントと同様、単一テーブルの壁で 止まります。DynoTable が DynamoDB GUI としてどう比較されるかをご覧ください。

FAQ

PartiQL は JOIN をサポートしている? いいえ。PartiQL の SELECT は単一テーブル(またはそのインデックスの 1 つ)を読み取り ます。複数テーブルのクエリは ValidationException: Only Select from a Single Table or index supported を返します。API の他の部分と同じ壁です。

1 つのクエリで 2 つの DynamoDB テーブルを結合できる? ネイティブにはできません。DynamoDB API には、2 つのテーブルを読み取りキーでマッチング する文がありません。BatchGetItem は 1 回のリクエストで複数のテーブルからアイテムを 読み取れますが、ON 条件はありません — プライマリキーで指定したアイテムを返し、 マッチングはあなたに委ねます。本物の JOIN … ON … は DynamoDB の外側でのみ起こります。 あなたのアプリ内か、DynoTable の SQL Workbench で。

テーブルをその GSI に結合できる? いいえ — グローバルセカンダリインデックスは、結合する別個の テーブルではありません。同じアイテムの代替キービューです。ある SELECT の中で、 テーブル または インデックスのどちらかを Query するのであって、両方を結合する ことはありません。GSI は別のキーでアイテムに 到達する ことを可能にし、それが そもそも結合の必要性をなくすことがよくあります。

2 つの AWS アカウントにまたがって(または異なるアカウントの 2 つのテーブルを)結合できる? ネイティブにはできず、BatchGetItem でもできません — 1 回のリクエストは認証情報を またげず、クロスアカウントの結合プリミティブもありません。各テーブルをそれぞれの アカウントの認証情報で読み取り、結果をアプリケーションや DynoTable の Workbench の ようなツールで結合することになります。

非正規化は本当に結合より良い? DynamoDB のターゲットワークロード — 予測可能で大量の読み取り — にとっては、はい。 コストを書き込み時に移し(そしてある程度のデータ重複を受け入れ)、代わりに平坦に スケールする単一リクエストの読み取りを得ます。 シングルテーブル設計ガイドがトレードオフを扱います。


これらの読み取り用のキーと条件を手で組み立てるのは面倒です — 式ビルダーがあなたの代わりに KeyConditionExpression / FilterExpression の構文を生成し、回避策では足りないときは DynoTable が本物の SQL を実行します。

更新日