DynamoDB 裡的反正規化
從 SQL 過來,反正規化聽起來像一樁罪——重複的資料、沒有單一事實來源。在 DynamoDB 裡它正是重點所在。沒有 join,所以你把相關資料複製到需要它的那個項目上,一次就讀回來。
什麼是 DynamoDB 中的反正規化?
DynamoDB 中的反正規化,就是把相關資料複製到會讀它的那個項目上,這樣一次查詢就能一口氣把所有東西取回來。因為 DynamoDB 沒有 join,你在寫入時就先把資料 join 好,而不是在讀取時把多張表縫在一起。取捨在於資料會過時——所以只複製那些很少改變的值。
- 沒有 join 意味著你在寫入時就先 join。 把相關的值存在會讀它的那個項目上,這樣查詢就永遠不需要第二次查找。
- 兩種風味。 把巢狀資料內嵌到一個項目上的一個複雜屬性裡,或把一個值複製到多個項目上。
- 地雷是資料過時。 當來源變動時,每一份複本都是錯的,直到你把更新扇出去。只複製那些很少改變的值。
- 它買到的是讀取,不是寫入。 你用更多(也更需小心)的寫入,換來便宜的單請求讀取。
為什麼沒有 join 可以退回去用
關聯式的 JOIN 在讀取時把正規化過的列重新組裝起來。DynamoDB 沒有 join——一次 Query 讀一個項目集合,把那裡存著的東西原封不動交回給你。沒有任何東西替你把兩張表縫在一起。
所以資料必須事先就為那次讀取塑好形。如果一個畫面需要一篇貼文和它作者的名稱,那個名稱就得住在那次貼文讀取本來就會碰到的某個地方。2007 年的 Amazon Dynamo 論文把這個取捨講得很明白:捨棄關聯式特性,換來在規模下可預測的、個位數毫秒的讀取。
模式 1——用複雜屬性內嵌
DynamoDB 的屬性能裝巢狀的映射(map)和列表(list),不只是純量。所以一種常見的反正規化形式,就是把一個子物件直接塞進它的父項目裡,而不是給它自己一個項目。
一篇貼文連同它的標籤和一份小小的作者快照,全在一個項目上:
| PK | SK | author | tags |
|---|---|---|---|
| POST#9f3 | META | {id: U#12, name: "Mara Vance"} | ["dynamodb","aws"] |
一次 GetItem 把貼文、標籤和作者區塊一起回傳。沒有第二次讀取。這對那些被父項目擁有且大小有界的資料很棒——幾個標籤、一份作者快照。
要尊重的限制:單一 DynamoDB 項目最大就是 400 KB,屬性名稱與值都算在內(服務配額)。內嵌一個無上限的列表(一篇爆紅貼文上的每一則留言),你就會超過它。
模式 2——把一個值複製到多個項目
部落格這個案例是教科書範例。你列出貼文,並希望每一列都顯示作者的顯示名稱——但你不想為了取它而每篇貼文都做第二次讀取。
所以你在貼文建立時,就把作者的名稱寫到每一個貼文項目上:
| PK | SK | authorId | authorName | title |
|---|---|---|---|---|
| POST#9f3 | META | U#12 | "Mara Vance" | "Modeling 1:N" |
| POST#a71 | META | U#12 | "Mara Vance" | "Sparse GSIs" |
| POST#b04 | META | U#88 | "Lio Tan" | "Query vs Scan" |
現在 Query PK begins_with "POST#"(或一個跨貼文的 GSI)就把整份列表渲染出來——標題和作者——不必每列再查找。作者名稱被反正規化了:權威複本住在 USER#12 上,而每一篇貼文都帶著它自己的複本。
那個取捨就擺在那裡。你把一次 N+1 讀取變成了一次讀取,代價是把 "Mara Vance" 放在 N+1 個地方。
內嵌 vs. 複製——選哪一個
| 內嵌(複雜屬性) | 複製(跨項目複本) | |
|---|---|---|
| 形狀 | 子項目巢狀在父項目裡 | 同一個值落在多個項目上 |
| 最適合 | 有界、父項目擁有的資料 | 多個項目都要顯示的共享值 |
| 讀取 | 一次 GetItem | 一次 Query |
| 更新成本 | 改寫那一個父項目 | 扇出到每一份複本 |
| 大小風險 | 400 KB 項目上限 | 每項目皆無 |
當子項目永遠只跟它父項目一起出現時,伸手抓內嵌。當許多獨立的項目都需要顯示同一個共享值時,伸手抓複製。
地雷:過時的複本
這就是咬人的部分。Mara 把自己改名成 "Mara V."。你更新了 USER#12。每一個貼文項目仍然寫著 "Mara Vance",直到你去把它們修好。
所以更新一個被複製的值是一次扇出寫入,不是一行解決的事。你查出每一個受影響的項目並改寫每一個——理想上加上守衛,這樣你只動到仍持有舊值的那些列:
UPDATE POST#9f3
SET authorName = "Mara V."
WHERE authorName = "Mara Vance"
你可以在 Expression Builder 裡針對 authorName 組出那個條件式 SET,並把生成的 UpdateExpression 與 ConditionExpression 直接複製進你的程式碼。
扇出本身是每個項目一次寫入:查出作者的貼文,再發出那些更新。順序如下:
複製資料的成本:對來源的每一次變動,都是一次查詢加上每份複本一次寫入。
這就是為什麼規則是只複製那些很少改變的值。一個顯示名稱、一個方案層級、一個分類標籤——可以。一個即時計數器或一個頻繁編輯的欄位——別這麼做;扇出會把你活活吃掉。
什麼時候正規化仍然勝出
如果一個值經常改變,或某個項目被真正無法預測的模式讀取,那就讓它保持正規化,並接受那次額外的讀取。反正規化是針對已知的、讀取繁重的存取模式的一種最佳化——不是到處套用的預設。把你實際會跑的那些讀取先 join 起來,其餘的別動。
要決定這些被複製的屬性住在哪裡,先把存取模式建模出來——見單表設計,至於取捨的讀取面,見 Query 與 Scan。
下載 DynoTable,去檢視一張反正規化過的表,找出哪些複本已經漂移,並對你自己的資料執行扇出更新。