DynamoDB 中的一對多關係
一個 SaaS 控制平面幾乎總有一層包含層級:一個工作區(workspace)擁有多個項目(project)。在 SQL 中,你會在項目表上放一個 workspace_id 外來鍵,然後用 JOIN。
DynamoDB 沒有連線(join),也沒有外來鍵,所以這層關係必須存在於鍵 schema本身之中。做對了,"載入一個工作區及其內部的每個項目"就變成一次 Query,而不是一次讀取外加一次後續掃描。
在 DynamoDB 中如何建模一對多關係?
讓父項和它的全部子項擁有相同的 ,從而共享同一個 ,然後用排序索引鍵把它們區分開。DynamoDB 沒有連線或外來鍵,所以這層關係存在於鍵 schema 本身之中。這樣,載入一個父項加上它的每個子項就變成了一次 Query,而不是一次連線。
- 對讀取建模,而非對實體建模。 這層一對多關係存在的唯一目的,就是服務於"列出某個工作區的項目"——圍繞那條查詢來塑造鍵。
- 把父項編碼進子項的 。 讓工作區及其全部項目擁有相同的分割區索引鍵值,這樣它們就落入同一個。
- 於是列表讀取就是一次
Query。 父項加上它的子項一併返回——沒有連線,沒有第二次往返(一次Query每頁最多返回 1 MB,超出部分透過LastEvaluatedKey分頁)。 - 留意。 單個龐大的租戶會把它的全部流量集中到一個分割區上;一個超大的工作區可能需要分片鍵(sharded key)和扇出讀取(fan-out read)。
先從訪問模式開始
DynamoDB 建模是訪問模式優先,而非實體優先——這與 單表設計 背後是同一套紀律。在選擇任何鍵之前,先寫下應用實際發出的那些讀取:
- 獲取某個工作區的設定。
- 列出某個工作區中的每個項目,最新的在前。
- 按 id 獲取某個特定項目。
"一個工作區、多個項目"這層關係之所以重要,僅僅是因為讀取 #2。如果你從不需要把一個工作區的項目一併列出,你根本就不會去建模這層關係——你會把項目獨立儲存。
所以問題從來不是抽象地"我該如何表示一對多?",而是"這層關係必須服務於哪些查詢?"回答了這個,再圍繞它來塑造鍵。
為什麼外來鍵在這裡幫不上忙
在 DynamoDB 中,每個 GetItem 和 Query 都以一個分割區索引鍵為目標,服務會對該鍵做雜湊,以定位存放該項的分割區。
AWS 在 核心元件 文件中直接這樣說:分割區索引鍵值是內部雜湊函式的輸入,由它決定資料存放在哪裡。
這種基於雜湊的放置方式,繼承自最初 2007 年那篇 Dynamo: Amazon's Highly Available Key-value Store 論文,其中用一致性雜湊把鍵分佈到各個節點上。
項目項上一個光禿禿的 workspace_id _屬性_對那套機制是不可見的——DynamoDB 無法"順著"它去查詢。
要在一次請求中取回相關的項,父項的身份必須被編碼進項目的分割區索引鍵裡,這樣一個工作區的全部項就雜湊到同一個分割區,一次 Query 就能把它們一掃而空。
完整示例:工作區與項目
使用一個通用的、過載(overloaded)的鍵 schema。把分割區索引鍵命名為 EntityRef,排序索引鍵命名為 Detail。工作區的身份被放進 EntityRef 裡——既用於工作區項,也用於它下面的每個項目:
| EntityRef | Detail | attributes |
|---|---|---|
| WS#acme | META | displayName, region, seatLimit |
| WS#acme | PROJ#2026-0007 | title, status, createdBy |
| WS#acme | PROJ#2026-0042 | title, status, createdBy |
| WS#acme | PROJ#2026-0118 | title, status, createdBy |
| WS#globex | META | displayName, region, seatLimit |
| WS#globex | PROJ#2026-0009 | title, status, createdBy |
工作區及其全部項目共享 EntityRef = "WS#acme",所以它們構成一個單一的項集合,共同存放在一個分割區上。
Detail 排序索引鍵把它們分開:META 是工作區記錄,而每個項目都帶一個 PROJ# 字首,加上一個補零、按時間排序的 id,這樣項目就能自然排序。
從視覺上看,父項和它的子項在一個分割區內堆疊起來,按排序索引鍵排序:
一次對 EntityRef = "WS#acme" 的 Query 就把整個堆疊——父項加上每個子項——在一次讀取中掃過。
現在三種訪問模式各自都收斂為一次呼叫:
- 工作區設定 ——
GetItem(EntityRef="WS#acme", Detail="META")。 - 列出項目,最新的在前 ——
Query(EntityRef="WS#acme")配合Detail begins_with "PROJ#",以降序執行(ScanIndexForward = false)。 - 單個項目 ——
GetItem(EntityRef="WS#acme", Detail="PROJ#2026-0042")。
第二條才是全部重點所在:父項和它的子項從一次 Query 中返回,沒有連線,也沒有第二次往返——DynamoDB 每頁最多返回 1 MB,並交給你一個 LastEvaluatedKey 去取回其餘部分。這正是你用外來鍵屬性加一次 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 會把它們分組,讓你把這層一對多關係看作一個連續的整塊,而不必在分散的多張表之間猜測。

陷阱與另一種佈局
有幾點需要留意:
- 熱分割區。 一個工作區的每個項都存放在一個分割區上,所以單個非常大或非常繁忙的租戶會集中流量。AWS 所描述的 自適應容量 行為能吸收適度的傾斜,但一個擁有數百萬項目的工作區可能需要分片鍵(例如
WS#acme#01 … #10)和扇出讀取。 - 項集合大小。 當存在本地次要索引時,單個分割區的項集合上限為 10 GB;沒有 LSI 則沒有這樣的限制。如果你在這裡權衡索引型別,參見 GSI 與 LSI。
- 動用
Query,絕不用Scan。 整個設計存在的意義就是讓你能Query一個分割區。退回到用一次帶篩選的Scan去"查詢某個工作區的項目",就把這套模型丟掉了,並會讀取整張表——這正是 Query 與 Scan 中講到的陷阱。
如果你確實需要跨工作區列出項目(比如全域性所有 status = ACTIVE 的項目),基礎表回答不了這個問題——它的分割區索引鍵是限定在工作區範圍內的。
這是一個次要索引的活兒:它按另一個屬性對項目重新分割區,而不是靠重塑這層關係來解決。
後續步驟
對訪問模式建模,把父項編碼進子項的分割區索引鍵,一對多讀取就是一次 Query。用 DynamoDB Expression Builder 構建並驗證鍵條件——而如果你更願意從訪問模式本身出發,免費的單表設計工具會起草帶示例項的 PK/SK/GSI 方案。
然後 下載 DynoTable 載入這個 schema,實時瀏覽工作區→項目的項集合,並確認每條查詢恰好只做一次讀取。如果你更想把工作區和項目看作一個連線起來的關係型檢視,DynoTable 的 SQL Workbench 也能執行那個 JOIN。


