中階閱讀時間 3 分鐘

DynamoDB 裡的反正規化

從 SQL 過來,反正規化聽起來像一樁罪——重複的資料、沒有單一事實來源。在 DynamoDB 裡它正是重點所在。沒有 join,所以你把相關資料複製到需要它的那個項目上,一次就讀回來。

什麼是 DynamoDB 中的反正規化?

DynamoDB 中的反正規化,就是把相關資料複製到會讀它的那個項目上,這樣一次查詢就能一口氣把所有東西取回來。因為 DynamoDB 沒有 join,你在寫入時就先把資料 join 好,而不是在讀取時把多張表縫在一起。取捨在於資料會過時——所以只複製那些很少改變的值。

  • 沒有 join 意味著你在寫入時就先 join。 把相關的值存在會讀它的那個項目上,這樣查詢就永遠不需要第二次查找。
  • 兩種風味。 把巢狀資料內嵌到一個項目上的一個複雜屬性裡,或把一個值複製到多個項目上。
  • 地雷是資料過時。 當來源變動時,每一份複本都是錯的,直到你把更新扇出去。只複製那些很少改變的值。
  • 它買到的是讀取,不是寫入。 你用更多(也更需小心)的寫入,換來便宜的單請求讀取。

為什麼沒有 join 可以退回去用

關聯式的 JOIN 在讀取時把正規化過的列重新組裝起來。DynamoDB 沒有 join——一次 Query 讀一個項目集合,把那裡存著的東西原封不動交回給你。沒有任何東西替你把兩張表縫在一起。

所以資料必須事先就為那次讀取塑好形。如果一個畫面需要一篇貼文和它作者的名稱,那個名稱就得住在那次貼文讀取本來就會碰到的某個地方。2007 年的 Amazon Dynamo 論文把這個取捨講得很明白:捨棄關聯式特性,換來在規模下可預測的、個位數毫秒的讀取。

模式 1——用複雜屬性內嵌

DynamoDB 的屬性能裝巢狀的映射(map)列表(list),不只是純量。所以一種常見的反正規化形式,就是把一個子物件直接塞進它的父項目裡,而不是給它自己一個項目。

一篇貼文連同它的標籤和一份小小的作者快照,全在一個項目上:

PKSKauthortags
POST#9f3META{id: U#12, name: "Mara Vance"}["dynamodb","aws"]

一次 GetItem 把貼文、標籤和作者區塊一起回傳。沒有第二次讀取。這對那些被父項目擁有且大小有界的資料很棒——幾個標籤、一份作者快照。

要尊重的限制:單一 DynamoDB 項目最大就是 400 KB,屬性名稱與值都算在內(服務配額)。內嵌一個無上限的列表(一篇爆紅貼文上的每一則留言),你就會超過它。

模式 2——把一個值複製到多個項目

部落格這個案例是教科書範例。你列出貼文,並希望每一列都顯示作者的顯示名稱——但你不想為了取它而每篇貼文都做第二次讀取。

所以你在貼文建立時,就把作者的名稱寫到每一個貼文項目上

PKSKauthorIdauthorNametitle
POST#9f3METAU#12"Mara Vance""Modeling 1:N"
POST#a71METAU#12"Mara Vance""Sparse GSIs"
POST#b04METAU#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,並把生成的 UpdateExpressionConditionExpression 直接複製進你的程式碼。

扇出本身是每個項目一次寫入:查出作者的貼文,再發出那些更新。順序如下:

"DynamoDB"App"DynamoDB"App"更新 USER"查詢作者的貼文""POST"更新每個 authorName"

複製資料的成本:對來源的每一次變動,都是一次查詢加上每份複本一次寫入。

這就是為什麼規則是只複製那些很少改變的值。一個顯示名稱、一個方案層級、一個分類標籤——可以。一個即時計數器或一個頻繁編輯的欄位——別這麼做;扇出會把你活活吃掉。

什麼時候正規化仍然勝出

如果一個值經常改變,或某個項目被真正無法預測的模式讀取,那就讓它保持正規化,並接受那次額外的讀取。反正規化是針對已知的、讀取繁重的存取模式的一種最佳化——不是到處套用的預設。把你實際會跑的那些讀取先 join 起來,其餘的別動。

要決定這些被複製的屬性住在哪裡,先把存取模式建模出來——見單表設計,至於取捨的讀取面,見 Query 與 Scan

下載 DynoTable,去檢視一張反正規化過的表,找出哪些複本已經漂移,並對你自己的資料執行扇出更新。

已更新