中階閱讀時間 3 分鐘

DynamoDB JOIN:如何連線表

DynamoDB 裡沒有 JOIN。API 沒有連線運算子,資料模型沒有外來鍵,而且——最讓人意外的那部分—— 這個帶 SQL 味道的查詢層也沒有補上它。一個 PartiQL SELECT 恰好唯讀一張表。

如果你從關係型資料庫過來,這是你撞上的第一堵牆。這份指南講這堵牆為什麼在那裡、開發者轉而去做的四件事、你確實需要一個真正連線的那唯一一種情形——以及如何跑一個。

DynamoDB 能做連線嗎?

不能。DynamoDB 無法連線表——不能透過底層 API(GetItem / Query / Scan / BatchGetItem),不能透過 ,也不能透過任何內建的查詢規劃器,因為壓根就沒有。每次讀取都對映到一張表或它的某個索引;把兩張表按匹配的鍵組合起來,是你在 DynamoDB 返回項_之後_、在你的應用裡做的事,絕不在它內部。

  • DynamoDB 沒有 JOIN 運算子。從來沒有。
  • PartiQL 的 SELECT只支援單表的——語法就是字面的 SELECT … FROM @@P0@@[.@@P1@@],把它指向兩張表會返回 ValidationException: Only select from a single table or index is supported.
  • AWS 推薦的修復辦法是讓你不需要連線,或者用單表設計,讓相關的項住在一個你用單次請求就能取回的分割區裡。
  • 對於真正的跨表/臨時查詢情形,你在 DynamoDB 之外做連線——在你的應用裡,或者用一個替你做連線的工具。

為什麼 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
  INNER JOIN Inventories ON Products.Product_ID = Inventories.Product_ID
  ORDER BY Quantity_on_Hand DESC

很靈活,但“查詢裡的每一次連線都會增加查詢的執行時複雜度,因為每張表的資料都必須先暫存、然後再拼裝。”那份工作是無界的——它的成本取決於資料,而不是查詢——而這恰恰是 DynamoDB 拒絕擁有的性質。

所以 AWS 把這個約束設計了進去。用他們的話說,DynamoDB 是“為了把 [CPU 和網路] 這兩種約束都降到最低而構建的,辦法是消除 JOIN(並鼓勵資料非正規化),並最佳化資料庫架構,使其用對一個項的單次請求就能完整回答一個應用查詢。”正是這些性質買來了任意規模下個位數毫秒的延遲:一次 DynamoDB 讀取的執行時成本與表大小無關、恆定不變。沒有連線引擎,也沒有外來鍵概念可供規劃——這是設計使然。

“可 PartiQL 就是 SQL,它肯定能連線吧?”

不能。PartiQL 給你在 DynamoDB 上的 SELECT / INSERT / UPDATE / DELETE 語法,但它是 SQL-相容,而非 SQL。官方的 SELECT 語法是:

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

FROM一張表(可選地接它的某個索引)。沒有第二個 FROM 表,沒有 JOIN,沒有子查詢,沒有 CTE。我們把這三種寫法都對著 DynamoDB 跑了一遍,看看每一種究竟是怎麼失敗的。

一個顯式的 JOIN

SELECT o.pk FROM "Orders" o JOIN "Customers" c ON o.customerId = c.pk
ValidationException: Only select from a single table or index is supported.

FROM 裡放兩張表——同樣的拒絕,所以引擎拒的不是 JOIN 關鍵字,而是第二張表:

SELECT * FROM "Orders", "Customers"
ValidationException: Only select from a single table or index is supported.

子查詢的失敗方式不同,如果你正在除錯它,這一點值得知道。PartiQL 根本走不到多表檢查那一步——它在此之前就拒絕了 IN 的運算元,所以你拿到的訊息壓根沒提到表:

SELECT * FROM "Orders" WHERE customerId IN (SELECT pk FROM "Customers")
ValidationException: IN operator must have a left hand argument of type Variable
Reference and right hand argument of type Seq with at least one member

實際後果是:沒有任何寫法能給你一個連線。前兩種死在第二張表上,第三種死得更早,死在運算元上。

如果你想要關於 PartiQL 為什麼看起來像 SQL 卻不能像它那樣表現的完整推理,參見 PartiQL 對比 SQL

開發者實際使用的 4 種變通辦法

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

把你原本要連線的欄位直接存到項上。一個 Order 攜帶 customerNameshippingAddress 的快照,而不是一個你稍後要解析的 customerId。一次讀取,沒有連線。

代價是寫入時的扇出:當源變化時,你要更新每一份副本(通常透過一個 處理器)。你是在用讀取複雜度換寫入複雜度——對一個讀密集的應用來說,這通常是筆劃算的交易。

2. 單表設計(在分割區裡預先連線)

把相關的實體放進一張表、置於一個共享的分割區索引鍵下,讓一個_就是_那個連線後的結果。一個客戶和他們所有的訂單共享 PK = "CUSTOMER#42";一次 Query 就返回客戶項加上每一個訂單項——那個“連線”在寫入時就已經發生了。

Query  PK = "CUSTOMER#42"
→ CUSTOMER#42 / PROFILE      (the customer)
→ CUSTOMER#42 / ORDER#1001   (an order)
→ CUSTOMER#42 / ORDER#1002   (an order)

這是 DynamoDB 對一對多關係的經典答案。完整走查見單表設計

3. 應用側連線(兩次讀取,在程式碼裡縫合)

從表 A 讀取,拿你取回的鍵,去表 B 讀取,再在你的應用裡合併這兩個結果集。這就是關係型連線的邏輯——只是跑在你的程式碼裡而不是資料庫裡:

// "Get each order with its customer name" — the manual join.
const {Items: orders} = await ddb.query({TableName: 'Orders' /* … */});

const customers = await Promise.all(
  orders.map((o) => ddb.get({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,先到者為準。它削減了應用側連線的往返次數——但它不是一個連線。你“按主索引鍵標識所請求的項”;沒有 ON 條件,也沒有關係型匹配。你仍然得事先知道鍵,並自己把響應縫合到一起。

什麼時候一個真正的 JOIN 無法迴避

那四種變通辦法很好地覆蓋了生產讀取路徑。它們撐不住的地方是臨時的、探索性的、分析性的查詢——你沒為它建過模的那種:

  • “上個月哪些歐盟客戶下過一筆超過 500 美元的訂單?”——橫跨一張 Orders 表和一張 Customers 表。
  • 一次性的、連線兩種實體型別的資料質量檢查。
  • 報表與聚合(GROUP BYSUMCOUNT)——這些 DynamoDB 壓根沒有對應的運算子。

這些恰恰是你沒法預先烘焙進一個分割區的查詢,因為按定義你當初不知道自己會問它們。那種關係型直覺——寫一個 JOIN——在這裡是對的。只是 DynamoDB 原生服務不了它,PartiQL 也不行。

通常那個重量級的答案是匯出到 S3 並用 Athena 查詢(或用 Athena 的聯邦查詢聯結器去 JOIN 一張實時表),或者管道灌進一個資料倉儲。對於真正的規模化分析,這是對的,但對一個你想_現在_就針對實時表得到答案的問題來說,這是一大堆管道活。

用 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 讀取的,所以一個無界的連線就是一次無界的讀取。最快的查詢是那些 WHERE 子句(或連線的 ON 屬性)至少在一側命中一個分割區索引鍵或一個 GSI 的查詢,這樣 DynamoDB 會在連線執行之前跑一次 Query 而不是一次全表掃描。Workbench 並不廢除這份指南里的那些約束——它只是讓你能_提出那個 SQL 問題_,而不必自己手寫縫合,並告訴你它底下在做什麼。

在各種 GUI 用戶端裡,它是唯一一個真正為真的“是的,你能連線”:PartiQL 和 AWS 自家的 NoSQL Workbench——它的操作構建器跑單表操作和 PartiQL 語句(沒有 JOIN,沒有多表 SELECT)——都止步於單表這堵牆,大多數其他 GUI 用戶端也是。看看 DynoTable 作為一個 DynamoDB GUI 如何比較。

常見問題

PartiQL 支援 JOIN 嗎? 不支援。PartiQL 的 SELECT 讀單張表(或它的某個索引)。多表查詢會返回 ValidationException: Only select from a single table or index is supported. 和 API 其餘部分是同一堵牆。

你能在一次查詢裡連線兩張 DynamoDB 表嗎? 原生不能。DynamoDB API 沒有任何語句能讀兩張表並按鍵匹配它們。BatchGetItem 能在一次請求裡從多張表讀取項,但它沒有 ON 條件——它返回你按主索引鍵點名的那些項,把匹配留給你。一個真正的 JOIN … ON … 只發生在 DynamoDB 之外:在你的應用裡,或在 DynoTable 的 SQL Workbench 裡。

你能把一張表連線到它的 GSI 嗎? 不能——一個全域次要索引不是你可以連線過去的一張獨立的表;它是同一批項的一個替代鍵檢視。在某個給定的 SELECT 裡,你 Query 表_或者_索引二選一,而不是把兩者連線到一起。GSI 讓你能用一個不同的鍵_觸及_項,這往往一開始就消除了連線的需要。

你能跨兩個 AWS 帳戶(或不同帳戶裡的兩張表)連線嗎? 原生不能——沒有跨帳戶連線的原語。如果另一個帳戶那張表的基於資源的策略授予了你的呼叫方訪問權(自 2024 年 3 月起),BatchGetItem 能觸及那張表,但它仍然沒有 ON 條件,所以那是一次多表讀取,不是連線。你得讀取每一側,再在你的應用裡或在像 DynoTable 的 Workbench 這樣的工具裡連線結果。

非正規化真的比連線更好嗎? 對於 DynamoDB 的目標工作負載——可預測的、高流量的讀取——是的。你把成本挪到寫入時(並接受一些資料重複),換來能平坦擴充套件的單請求讀取。單表設計指南講了這些取捨。


手工為這些讀取構建鍵和條件很瑣碎——運算式構建器替你生成 KeyConditionExpression / FilterExpression 語法,而當一個變通辦法不夠用時,DynoTable 跑真正的 SQL。

PartiQL 的拒絕訊息於 2026-08-11 針對 DynamoDB Local(us-east-1,@aws-sdk/client-dynamodb v3.1096.0)復現,逐字引用自 ValidationException.message

已更新