中階閱讀時間 3 分鐘

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 中,每個 GetItemQuery 都以一個分割區索引鍵為目標,服務會對該鍵做雜湊,以定位存放該項的分割區。

AWS 在 核心元件 文件中直接這樣說:分割區索引鍵值是內部雜湊函式的輸入,由它決定資料存放在哪裡。

這種基於雜湊的放置方式,繼承自最初 2007 年那篇 Dynamo: Amazon's Highly Available Key-value Store 論文,其中用一致性雜湊把鍵分佈到各個節點上。

項目項上一個光禿禿的 workspace_id _屬性_對那套機制是不可見的——DynamoDB 無法"順著"它去查詢。

要在一次請求中取回相關的項,父項的身份必須被編碼進項目的分割區索引鍵裡,這樣一個工作區的全部項就雜湊到同一個分割區,一次 Query 就能把它們一掃而空。

完整示例:工作區與項目

使用一個通用的、過載(overloaded)的鍵 schema。把分割區索引鍵命名為 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",所以它們構成一個單一的項集合,共同存放在一個分割區上。

Detail 排序索引鍵把它們分開:META 是工作區記錄,而每個項目都帶一個 PROJ# 字首,加上一個補零、按時間排序的 id,這樣項目就能自然排序。

從視覺上看,父項和它的子項在一個分割區內堆疊起來,按排序索引鍵排序:

分割區:EntityRef = WS#acmeMETA —— 工作區設定PROJ#2026-0007PROJ#2026-0042PROJ#2026-0118

一次對 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 會把它們分組,讓你把這層一對多關係看作一個連續的整塊,而不必在分散的多張表之間猜測。

工作區的 META 項及其 PROJ# 子項在 DynoTable 表檢視中被分組為一個項集合。
工作區的 META 項及其 PROJ# 子項在 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

已更新

互動式地試用這個設計

在免費的 DynamoDB 單一資料表設計工具中勾勒你的實體與存取模式 — 它會建議 PK/SK 鍵範本、預覽項目集合,並顯示哪些模式需要 GSI。

開啟單一資料表設計工具