中階閱讀時間 4 分鐘

DynamoDB 的多對多關聯

一個學生選修多門課;一門課收容多名學生。在 SQL 裡你會伸手去拿 一張 join 表,加上四路的 JOIN

DynamoDB 沒有 join,所以關聯必須存在於 key 中 — 而 訣竅是把每條註冊邊以一種讓兩邊都能直接 Query 的形狀儲存。

本指南從頭到尾走過 students ↔ courses 問題:存取模式、解決它們的 鄰接清單模式、一份你可以照抄的原始 key schema,以及如何讀回雙向, 而完全不掃描整張表。

如何在 DynamoDB 中建立多對多關聯的資料模型?

DynamoDB 沒有 join,所以你要用模式來建立多對多關聯的資料模型:把每條連結儲存為以一端為 key 的獨立 edge item,再加上一個交換 key 的反轉 GSI。一條邊只寫一次,就能從兩個方向廉價地回應查詢。

  • 把每筆註冊當成它自己的 edge item 儲存,而非任一邊上的清單屬性。
  • 以學生為 key 來建這條邊PK = STU#…SK = ENROLL#CRS#…),讓一個 Query 回傳某學生的整份課程清單。
  • 加上一個反轉的 GSI,把角色對調(GSI1PK = CRS#…),讓同一條邊 也能回答「誰在這門課裡?」。
  • 一條邊,寫一次,雙向都讀得便宜 — 這就是整場遊戲。

先框定存取模式

DynamoDB 建模是存取模式優先:你在挑選任何單一屬性名稱之前,先決定 讀取。一個多對多關聯幾乎都有 兩個 對稱的 讀取,加上實體查詢:

  • 取得某學生的個人資料,並 列出該學生選修的每一門課
  • 取得某門課的中繼資料,並 列出選修該課的每一名學生
  • 查詢單一註冊邊 — 用來更新成績或退選。

痛點:兩個列表讀取在同一組邊上指向相反方向。一個天真的 設計把其中一邊服務得很便宜,卻逼另一邊用 Scan — 正是 Query vs Scan 涵蓋的那個地雷。

任務是讓 兩個 方向都成為單一 Query

使用鄰接清單模式

DynamoDB 自己針對關聯的指引就是 鄰接清單:把每個 關聯建模成一個 item,其 partition key 是一個端點、sort key 是 另一個端點。

AWS 在 DynamoDB 開發人員指南的 Best Practices for Managing Many-to-Many Relationships 頁面記載了這一點。

為什麼用 key 而不是第二張表?因為 DynamoDB 給你的原語是對單一 partition 的 Query

一個 Query 在一個 partition key 底下讀取 sort-key 值的連續範圍,於一個 計費操作中完成 — 那是引擎提供的唯一「join」。

要得到一個能從 兩邊 都讀得便宜的關聯,你把這條邊複製一份: 以學生為 key 寫一次,然後用次要 index 把同一條邊以課程為 key 投影出來。

這就是 Single-Table Design 那套 overloaded-key 思維,套用到一個關聯而非父子階層上。

形狀就是同一條邊的兩個堆疊檢視 — base table 以學生為 key、 反轉的 GSI 以課程為 key:

反轉的 GSI1 以課程為 keyBase table 以學生為 key同一條邊,key 對調同一條邊,key 對調PK STU#a91SK ENROLL#CRS#math204PK STU#a91SK ENROLL#CRS#cs101GSI1PK CRS#math204GSI1SK STU#a91GSI1PK CRS#cs101GSI1SK STU#a91

每條邊在 base table 上寫一次,並以 key 對調的方式投影進 GSI, 這樣對任一 partition 的 Query 都能便宜地讀取該關聯。

這脈絡可追溯到 2007 年 Amazon 的 Dynamo 論文: partition key 是分散的單位,而單一 key 存取是快速路徑。

DynamoDB 中的關聯,就是一場把多對多讀取折進那條快速 路徑的練習。

演練範例:students ↔ courses

使用一張表搭配通用 key PKSK,並把實體類型編碼進 值。註冊邊是核心所在:

PKSKattributes
STU#a91PROFILEname, year, major
STU#a91ENROLL#CRS#math204 enrolledOn, grade
STU#a91ENROLL#CRS#cs101enrolledOn, grade
CRS#math204METADATAtitle, credits, term
CRS#cs101METADATAtitle, credits, term

單一 Query PK = "STU#a91" 會在一次讀取中回傳該學生的個人資料 以及 每筆註冊。用 SK begins_with "ENROLL#" 縮窄,就只取得課程邊。 這解決了「列出某學生的課程」。

但「列出某門課的學生」指向另一邊 — 而 base table 無法回答 它,因為學生 id 在 partition key 裡,不在 sort key 裡。

加上一個反轉的 global secondary index,把角色對調。給 edge item 一組 通用的 GSI1PK/GSI1SK,partition 邊放課程、sort 邊放 學生:

PKSKGSI1PKGSI1SK
STU#a91ENROLL#CRS#math204CRS#math204STU#a91
STU#b30ENROLL#CRS#math204CRS#math204STU#b30
STU#a91ENROLL#CRS#cs101CRS#cs101STU#a91

現在 Query GSI1 WHERE GSI1PK = "CRS#math204" 會列出那門課的每一名學生 — base table 無法服務的讀取。一個 edge item,寫一次,回答了 雙向。

它必須是 GSI,而非 LSI:課程 partition 與學生 partition 完全 不同,而 LSI 共用 base table 的 partition key。

這個 index 橫跨多個 partition,所以必須是 global — 請見 GSI vs LSI

一個要注意的點:DynamoDB 中的 GSI 是非同步填入的。一筆全新的註冊 可能需要片刻才會在 CRS#… 方向出現。

把課程名單讀取當成最終一致 — 開發人員指南針對 global secondary index 明確點出這一點。

在 DynoTable 中寫入並讀取

寫入註冊意味著設定四個 key 屬性,加上邊本身的資料。阻止 學生在同一門課重複註冊的條件,是對複合 key 的一個 attribute_not_exists(PK) 守衛。

這正是你可以用 DynamoDB Expression Builder 視覺化組裝的那種條件,而不必手寫 ExpressionAttributeNames 與佔位符值。

在 DynoTable 裡,你把一個 Query 指向 GSI1、設定 GSI1PK = "CRS#math204", 名單就以一張你能讀、能排序、能就地編輯的表回傳 — 關聯的雙向 都能從一個 schema 瀏覽。

在 DynoTable 中查詢反轉的 GSI,以列出選修某門課的每一名學生。
在 DynoTable 中查詢反轉的 GSI,以列出選修某門課的每一名學生。

陷阱與下一步

  • 別把其中一邊存成清單屬性。 學生 item 上的 courseIds 陣列 感覺很整齊,直到某門課需要它的名單、陣列撞上 400 KB item 上限,或 兩筆註冊競爭並互相覆蓋。離散的 edge item 能各自獨立地 擴展與更新。
  • 把邊的資料留在邊上。 註冊的 gradeenrolledOn 屬於 edge item,而非重複複製到學生或課程上 — 每個 (學生, 課程) 配對剛好有一列要更新。
  • 留意 GSI 傳播。 反轉 index 的方向是最終一致,所以一筆在 註冊後立刻進行的讀取,可能會落後幾分之一秒。
  • 只投影名單需要的東西。 當名單檢視只需要 id 時,一個 KEYS_ONLY 或 狹窄的投影能讓 GSI 保持小巧。

要深入周邊模式,請讀 Single-Table Design 了解 overloaded key,以及 GSI vs LSI 了解何時反轉 index 必須是 global。

然後 下載 DynoTable 真正地把 students ↔ courses schema 建模 — 寫下這些邊、用 Expression Builder 建立條件,並查詢關聯的 雙向,連一次掃描都不用。

已更新