中階閱讀時間 3 分鐘

DynamoDB 中的多對多關係

一個學生選修許多課程;一門課程容納許多學生。在 SQL 裡你會去找一張連線表和一個三表 JOIN

DynamoDB 沒有連線,所以關係得住在裡——訣竅是把每條選課邊存成一種讓雙方都能直接 Query 的形態。

本指南把「學生 ↔ 課程」這個問題從頭到尾走一遍:訪問模式、解決它們的模式、一套你可以照抄的原創鍵 schema,以及如何在從不掃描表的前提下把兩個方向都讀回來。

你該如何在 DynamoDB 中建模一個多對多關係?

DynamoDB 沒有連線,所以你用模式來建模多對多關係:把每條連結存成它自己的、以一方為鍵的邊項目,然後加一個把鍵對調的反轉 GSI。一條邊,只寫一次,就能廉價地從兩個方向回答查詢。

  • 把每次選課存成它自己的邊項目,而不是任一方上的一個列表屬性。
  • 以學生為邊的鍵PK = STU#…SK = ENROLL#CRS#…),這樣單次 Query 就返回一位學生的整份課程清單。
  • 加一個反轉的 對調角色(GSI1PK = CRS#…),這樣同一條邊也能回答「誰在這門課裡?」。
  • 一條邊,只寫一次,雙向都讀得便宜——這就是全部的門道。

先框定訪問模式

DynamoDB 建模是訪問模式優先的:在你敲定任何一個屬性名之前,就先決定讀取。一個多對多關係幾乎總有兩個對稱的讀取,外加實體查詢:

  • 取一位學生的 profile,並列出該學生所選的每一門課程
  • 取一門課程的後設資料,並列出選修該課程的每一位學生
  • 查一條單獨的選課邊——用來改成績或退課。

痛點:那兩個列表讀取在同一組邊上指向相反的方向。一個樸素的設計會廉價地服務其中一個,卻逼另一個走 Scan——正是 Query vs Scan 裡講到的那個坑。

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

使用鄰接表模式

DynamoDB 自己對關係的指導就是鄰接表:把每條關係建模成一個項目,它的分割區索引鍵是一個端點,排序索引鍵是另一個端點。

AWS 在 DynamoDB 開發者指南的管理多對多關係的最佳實踐頁面上記錄了這一點。

為什麼用鍵而不是第二張表?因為 DynamoDB 給你的原語是對單個分割區的一次 Query

一次 Query 在一個分割區索引鍵下、在一次計費操作裡讀取一段連續的排序索引鍵值區間——那是引擎所提供的唯一「連線」。

要得到一個能從_兩側_都讀得便宜的關係,你就把邊複製一份:以學生為鍵寫一次,然後用一個次要索引把同一條邊以課程為鍵再投影一份。

這就是單表設計裡那種過載鍵的思路,只不過應用到一個關係上,而非一個父子層級。

其形態是同一條邊的兩層堆疊檢視——基表以學生為鍵,反轉 GSI 以課程為鍵:

同一條邊,鍵對調同一條邊,鍵對調反轉 GSI1 —— 以課程為鍵GSI1PK CRS#math204GSI1SK STU#a91GSI1PK CRS#cs101GSI1SK STU#a91基表 —— 以學生為鍵PK STU#a91SK ENROLL#CRS#math204PK STU#a91SK ENROLL#CRS#cs101

每條邊在基表上只寫一次,並把鍵對調後投影進 GSI,於是對任一分割區的一次 Query 都能廉價地讀取這段關係。

這個淵源可以追溯到 2007 年 Amazon 的 Dynamo 論文:分割區索引鍵是分佈的單元,而單鍵訪問是那條快速路徑。

DynamoDB 裡的關係,就是一門把多對多讀取彎折進那條快速路徑的功夫。

走一遍示例:學生 ↔ 課程

用一張帶通用鍵 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" 在一次讀取裡返回學生的 profile 以及每一次選課。用 SK begins_with "ENROLL#" 收窄,就只拿到課程邊。那就解決了「列出一位學生的課程」。

但「列出一門課程的學生」指向的是另一個方向——而基表答不了它,因為學生 id 在分割區索引鍵裡,不在排序索引鍵裡。

加一個反轉的全域次要索引來對調角色。給邊項目一對通用的 GSI1PK/GSI1SK,把課程放在分割區一側、學生放在排序一側:

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" 列出那門課裡的每一位學生——正是基表服務不了的那個讀取。一條邊項目,只寫一次,回答兩個方向。

它必須是一個 GSI,不能是 LSI:課程分割區與學生分割區截然不同,而 LSI 共享基表的分割區索引鍵。

這個索引橫跨多個分割區,所以它必須是全域性的——見 GSI vs LSI

一個坑:DynamoDB 裡的 GSI 是非同步填充的。一次全新的選課可能要過一會兒才在 CRS#… 方向出現。

把課程名單讀取當作來對待——開發者指南對全域次要索引明確點出了這一點。

在 DynoTable 中寫入並讀取它

寫入一次選課意味著設定四個鍵屬性外加邊自己的資料。阻止一個學生重複選同一門課的條件,是對複合鍵的一個 attribute_not_exists(PK) 守衛。

那正是你可以用 DynamoDB 運算式構建器視覺化地組裝出來的那種條件,而不必手寫 ExpressionAttributeNames 和預留位置值。

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

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

陷阱與後續步驟

  • 別把一方存成一個列表屬性。 學生項目上的一個 courseIds 陣列看著整潔,直到某門課程需要它的名單、陣列撞上 400 KB 項目上限,或兩次選課競爭並互相覆蓋。離散的邊項目能獨立地伸縮和更新。
  • 把邊的資料留在邊上。 選課的 gradeenrolledOn 屬於邊項目,而不是複製到學生或課程上——每一對(學生,課程)恰好只有一行要更新。
  • 留意 GSI 傳播。 反轉索引這個方向是最終一致的,所以選課之後立刻讀取,可能會滯後零點幾秒。
  • 只投影名單需要的東西。 當名單檢視只需要 id 時,一個 KEYS_ONLY 或窄投影能讓 GSI 保持小巧。

要在周邊模式上深入,讀一讀單表設計瞭解過載鍵,以及 GSI vs LSI 瞭解反轉索引何時必須是全域性的。而要從你自己的關係出發,免費的單表設計工具能把「列出某個學生的課程 / 列出某門課程的學生」這樣的訪問模式清單,變成一份帶示例項的 PK/SK/GSI 方案。

然後下載 DynoTable 去真刀真槍地建模「學生 ↔ 課程」schema——寫入那些邊、用運算式構建器構建條件,並在不做一次掃描的前提下查詢關係的兩個方向。而當你終究還是想要那個經典的三表 JOIN 檢視時,DynoTable 的 SQL Workbench 能對你的實時表執行它。

已更新

互動式地試用這個設計

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

開啟單一資料表設計工具