中階閱讀時間 4 分鐘

DynamoDB JOIN:如何 join 資料表(以及為什麼你通常不能)

DynamoDB 中沒有 JOIN。API 沒有 join 運算子、資料模型 沒有外鍵,而且 — 最讓多數人意外的部分 — PartiQL,這個 SQL 風味的查詢層,也沒有加上一個。 一個 PartiQL SELECT 只讀 恰好一張資料表。

如果你來自關聯式資料庫,這是你撞上的第一道牆。本 指南涵蓋這道牆為什麼在那裡、開發者改做的四件事、你 確實需要真正 join 的那一個情況 — 以及如何執行一個。

DynamoDB 能做 join 嗎?

不能。DynamoDB 無法 join 資料表——不透過低階 API(GetItem / Query / Scan / BatchGetItem)、不透過 PartiQL,也不透過任何內建的查詢規劃器,因為根本沒有。每次讀取都對應到單一資料表或它的某個索引;把兩張資料表依相符的鍵組合,是你在 DynamoDB 把項目交還給你之後才在應用程式中做的事,從不在它內部。

  • DynamoDB 沒有 JOIN 運算子。從來沒有。
  • PartiQL 的 SELECT僅單表 — 文法字面上就是 SELECT … FROM {{table}}[.{{index}}],把它指向兩張資料表會回傳 ValidationException: Only Select from a Single Table or index supported
  • AWS 推薦的修正是讓你不需要 join:去正規化,或使用 單表設計,讓相關項目住在 你能用單一請求取回的同一個分割區。
  • 對於真正的跨表/臨時情況,你在 DynamoDB 之外 join — 在 你的應用程式中,或用一個替你做的工具。

DynamoDB 能做 join 嗎?

不能。DynamoDB 無法 join 資料表 — 不透過低階 API(GetItem / Query / Scan / BatchGetItem)、不透過 PartiQL,也不透過任何 內建的查詢規劃器,因為根本沒有查詢規劃器。每次讀取都對應到 單一資料表或它的其中一個索引。把兩張資料表依相符的鍵組合,是你在 DynamoDB 把項目交回給你之後才做的事,從不在它內部。

那不是 AWS 忘了補的缺口。它是刻意的設計決策,而其 理由值得在你伸手找變通做法之前先理解。

為什麼 DynamoDB 沒有 join

一個 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

很靈活,但「查詢中的每個 join 都會增加查詢的執行階段 複雜度,因為每張資料表的資料都必須暫存,然後組裝。」那 工作是無上界的 — 它的成本取決於資料,而非查詢 — 這 正是 DynamoDB 拒絕擁有的特性。

所以 AWS 把這個限制設計進去了。用他們的話來說,DynamoDB「藉由 消除 JOIN(並鼓勵資料去正規化)並優化資料庫架構,使其能 以對某個項目的單一請求完整回答一個應用程式查詢,來最小化 [CPU 與網路] 這兩種限制。」這些就是讓它在任何規模下都有 單位數毫秒延遲的特性:DynamoDB 一次讀取的執行階段成本不論 資料表大小都是常數。沒有 join 引擎、也沒有外鍵概念可供 規劃 — 出於設計。

「但 PartiQL 就是 SQL,它肯定能 join 吧?」

不能。PartiQL 給你 SELECT / INSERT / UPDATE / DELETE 語法以操作 DynamoDB,但它是 SQL相容,不是 SQL。 官方 SELECT 文法 是:

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

FROM 接受一張資料表(可選它的其中一個索引)。沒有第二張 FROM 資料表、沒有 JOIN、沒有子查詢、沒有 CTE。把 PartiQL 指向兩張資料表, DynamoDB 就會拒絕它 (在 AWS re:Post 回報):

ValidationException: Only Select from a Single Table or index supported

如果你想看 PartiQL 為什麼看起來像 SQL 卻無法表現得像 SQL 的 完整理由,請見 PartiQL vs SQL

開發者實際採用的 4 種變通做法

1. 去正規化(把資料複製進來)

把你原本要 join 進來的欄位直接存到項目上。一個 Order 帶著 customerNameshippingAddress 的快照,而不是一個你之後要 解析的 customerId。一次讀取,沒有 join。

代價是寫入時的扇出:當來源變動時,你要更新每一份副本 (通常透過一個 DynamoDB Streams 處理器)。你在用讀取複雜度 換寫入複雜度 — 對讀取繁重的應用程式而言通常划算。

2. 單表設計(在分割區內預先 join)

把相關實體放進一張資料表、共用一個分割鍵,讓一個 項目集合就是 join 後的結果。一個客戶與他所有的訂單共用 PK = "CUSTOMER#42";一次 Query 回傳客戶項目外加每一個訂單 項目 — 那個「join」已經在寫入時發生了。

Query  PK = "CUSTOMER#42"
→ CUSTOMER#42 / PROFILE      (客戶)
→ CUSTOMER#42 / ORDER#1001   (一筆訂單)
→ CUSTOMER#42 / ORDER#1002   (一筆訂單)

這是 DynamoDB 對一對多關係的標準答案。完整 逐步說明請見單表設計

3. 應用程式端 join(兩次讀取,在程式碼中縫合)

從資料表 A 讀取、拿你取回的鍵、從資料表 B 讀取,再在你的 應用程式中合併這兩個結果集。這就是關聯式 join 邏輯 — 只是 跑在你的程式碼中而非資料庫裡:

// 「為每筆訂單取得它的客戶名稱」 — 手動 join。
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 問題 — 一次讀取 列出訂單,然後每筆訂單一次讀取 — 既慢又燒讀取容量。 BatchGetItem(下一個)把第二波讀取縮成一次往返。

4. BatchGetItem(一次往返,多張資料表)

BatchGetItem 是 API 最接近「一次碰兩張資料表」的東西:一次 請求回傳「一個以上項目來自一張以上資料表」的屬性,每次 呼叫最多 100 個項目或 16 MB,以先達到者為準。它 削減了應用程式端 join 的往返 — 但它不是 join。你 「以主鍵識別所請求的項目」;沒有 ON 條件、也沒有 關聯式比對。你仍得事先知道鍵,並自己把回應 縫合起來。

當真正的 JOIN 無可避免時

這四種變通做法把生產讀取路徑涵蓋得不錯。它們失靈的地方是 臨時、探索性、分析性的查詢 — 那種你沒有為它建模的:

  • 跨一張 Orders 資料表與一張 Customers 資料表的 「上個月在歐盟下單超過 $500 的客戶有哪些?」
  • 一次性的、join 兩種實體型別的資料品質檢查。
  • 報表與彙總(GROUP BYSUMCOUNT) — 這些 DynamoDB 根本 沒有運算子能做。

這些正是你無法預先烘進分割區的查詢,因為根據 定義你不知道你會問它們。那個關聯式直覺 — 寫一個 JOIN — 在這裡是對的。DynamoDB 就是無法原生服務它, PartiQL 也不行。

通常的重型答案是 匯出到 S3 並用 Athena 查詢, 或導入資料倉儲。對於真正的大規模分析那是對的,但對一個你想 現在、對你的即時資料表回答的問題而言,那是大量的 管線工程。

用 DynoTable 的 SQL Workbench 執行真正的 JOIN

DynoTable 是一款桌面版 DynamoDB 用戶端,其 SQL Workbench 能對你的 DynamoDB 資料表執行真正的 SQL — 包含 JOINGROUP BY 與彙總 函式。它透過正常的 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 讀取,所以一個無上界的 join 就是無上界的讀取。 最快的查詢是那些 WHERE 子句(或 join 的 ON 屬性)在至少一側命中分割鍵或 GSI 的,這樣 DynamoDB 就會在 join 執行前跑一個 Query 而非整表 掃描。Workbench 並未廢除 本指南中的限制 — 它只是讓你能問出那個 SQL 問題, 而非自己手寫縫合,並告訴你它在底層 做了什麼。

它是唯一一個真正成立的「可以,你能 join」:PartiQL 與 AWS 自家的 NoSQL Workbench — 其操作建構器僅限於單表的資料平面操作 (Query / Scan / GetItem) — 兩者都止步於單表牆,多數 其他 GUI 用戶端也是。看看 DynoTable 作為 DynamoDB GUI 的比較。

常見問題

PartiQL 支援 JOIN 嗎? 不支援。PartiQL 的 SELECT 讀取單一資料表(或它的其中一個索引)。一個 多表查詢會回傳 ValidationException: Only Select from a Single Table or index supported。與 API 其餘部分是同一道牆。

你能在一個查詢中 join 兩張 DynamoDB 資料表嗎? 原生不行。DynamoDB API 沒有任何敘述能讀兩張資料表並依某個鍵 比對它們。BatchGetItem 能在一個請求中從多張資料表讀取項目, 但它沒有 ON 條件 — 它回傳你以主鍵指名的項目, 並把比對留給你。一個真正的 JOIN … ON … 只發生在 DynamoDB 之外: 在你的應用程式中,或在 DynoTable 的 SQL Workbench 中。

你能把一張資料表 join 到它的 GSI 嗎? 不能 — 全域次要索引不是一張你 join 過去的 獨立資料表;它是同一批項目的另一種鍵視圖。在某個 SELECT 中你 Query資料表索引,不是把兩者 join 在一起。一個 GSI 讓你能用不同的鍵觸及項目,這往往一開始就消除了 join 的 需要。

你能跨兩個 AWS 帳號(或不同帳號的兩張資料表)join 嗎? 原生不行,用 BatchGetItem 也不行 — 單一請求無法橫跨 憑證,也沒有跨帳號 join 原語。你得用各自帳號的憑證讀取 每張資料表,再在你的應用程式或像 DynoTable 的 Workbench 這類工具中 join 結果。

去正規化真的比 join 好嗎? 對 DynamoDB 的目標工作負載 — 可預測、高量的讀取 — 是的。你把 成本移到寫入時(並接受一些資料重複),以換取 能平坦地擴展的單一請求讀取。 單表設計指南涵蓋了這些取捨。


手動為這些讀取建立鍵與條件很瑣碎 — expression builder 替你產生 KeyConditionExpression / FilterExpression 語法,而 DynoTable 在變通做法行不通時執行真正的 SQL。

已更新