中階閱讀時間 3 分鐘

DynamoDB 中的非正規化

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

DynamoDB 中的非正規化是什麼?

DynamoDB 中的非正規化意味著把相關資料複製到讀取它的那個項上,這樣一次查詢就能一次性返回所有東西。因為 DynamoDB 沒有 join,你在 寫入時預先 join,而不是在讀取時把表拼接起來。代價是陳舊——只複製那些極少變化的值。

  • 沒有 join 意味著你在寫入時預先 join。 把相關的值存到讀取它的那個項上,這樣一次查詢就絕不需要第二次查詢。
  • 兩種形態。 在一個項上的一個 複雜屬性 裡嵌入巢狀資料,或者把一個值 複製 到許多項上。
  • 暗雷是陳舊。 當源變化時,每一份副本都是錯的,直到你把更新扇出。只複製那些極少變化的值。
  • 它換來的是讀取,不是寫入。 你用更多(且更小心)的寫入去換廉價的、單次請求的讀取。

為什麼沒有 join 可以退守

一個關係型 JOIN 在讀取時把規範化的行重新組裝起來。DynamoDB 沒有 join——一次 Query 讀取一個 ,把恰好存在那裡的東西交回來。沒有東西替你把兩張表拼接起來。(至少在生產讀取路徑上是這樣——對於臨時的審計或漂移檢查,DynoTable 的 SQL Workbench 能在用戶端對 DynamoDB 執行真正的 JOIN。)

所以資料必須已經被塑造成讀取所需的形狀。如果一個螢幕需要一篇帖子和它作者的名字,那個名字就必須存在於帖子讀取本就會觸及的某個地方。2007 年的 Amazon Dynamo 論文把這個權衡說明白了:捨棄關係型特性,去換取規模下可預測的讀取——這個權衡如今 DynamoDB 以個位數毫秒的讀取交付出來。

模式 1——用一個複雜屬性來嵌入

DynamoDB 屬性可以持有巢狀的 對映列表,而不只是標量。所以非正規化的一種常見形式,是把一個子物件直接塞進它的父項裡,而不是給它自己的項。

一篇帖子連同它的標籤和一小段作者快照,全在一個項上:

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

一次 GetItem 就把帖子、標籤和作者塊一起返回。沒有第二次讀取。這對於那種被父項 擁有 且大小有界的資料——寥寥幾個標籤、一段作者快照——很棒。

要遵守的上限:單個 DynamoDB 項的上限是 400 KB,屬性名和值都算在內(Service Quotas)。嵌入一個無界的列表(一篇爆款帖子的每一條評論),你就會衝破它。

模式 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"

一個覆蓋帖子的 (比如說 GSI1PK = "POST",或一個以作者為鍵的)就能渲染出整個列表——標題和作者——不用每行查詢。在分割區索引鍵上做 begins_with 是不存在的事;一次 Query 需要分割區索引鍵等值,所以在每帖各自分割區索引鍵的情況下,列表來自 GSI,而不是對 POST# 的一次 Query。作者名是被 非正規化 的:規範副本存在於 USER#12 上,而每一篇帖子都帶著它自己的一份副本。

這個權衡就擺在那裡。你把一次 N+1 讀取變成了一次讀取,代價是把 "Mara Vance" 儲存在 N+1 個地方。

嵌入 對比 複製——選哪個

嵌入(複雜屬性)複製(跨項複製)
形狀子項巢狀在父項內部同一個值在許多項上
最適合有界的、父項擁有的資料許多項要展示的一個共享值
讀取一次 GetItem一次 Query
更新成本重寫那一個父項扇出到每一份副本
大小風險400 KB 項上限每項無風險

當子項只會與它的父項一起出現時,夠用 嵌入。當許多獨立的項都需要展示同一個共享值時,夠用 複製

那個暗雷:陳舊的副本

這就是會咬人的部分。Mara 把自己改名成 “Mara V.”。你更新了 USER#12。每一個帖子項仍然寫著 "Mara Vance",直到你去把它們改過來。

所以更新一個被複制的值是一次 扇出寫入,而不是一行程式碼的事。你查詢每一個受影響的項並重寫每一個——理想情況下加上守衛,讓你只碰那些仍持有舊值的行:

UPDATE POST#9f3
SET authorName = "Mara V."
WHERE authorName = "Mara Vance"

你可以在 運算式構建器 裡針對 authorName 組合那個條件 SET,並把生成的 UpdateExpressionConditionExpression 直接複製進你的程式碼。

扇出本身是每項一次寫入:對以作者為鍵的 GSI 查詢那個作者的帖子,然後發起更新。這個序列:

"DynamoDB"App"DynamoDB"App"更新 USER"查詢該作者的帖子""POST"逐個更新 authorName"

複製資料的成本:對源的每一次改動都是一次查詢加上每份副本一次寫入。在 DynoTable 中,這次扇出會先落進暫存區,成為每個項一份可審閱的逐屬性差異——在任何東西發出之前,你都能看到每一份即將改變的副本。

這就是為什麼規則是 只複製那些極少變化的值。一個顯示名、一個套餐層級、一個分類標籤——可以。一個實時計數器或一個頻繁編輯的欄位——別;扇出會把你生吞活剝。

扇出寫入成本

你更新的每一份副本都是一次單獨計費的寫入。在 us-east-1 的按需模式下, 作者改名之後更新 50 個帖子項目要花 50 × WCU——每個帖子行 ≤ 1 KB 時通常是每個項目每 KB 1 個 WCU。讀取一側始終只是一次 Query;寫入一側 則隨你維護的副本數量放大。兩條路徑都可以在定價計算器 裡估算。

什麼時候規範化仍然勝出

如果一個值經常變化,或者一個項被真正無法預測的模式讀取,就讓它保持規範化,接受那次額外的讀取。非正規化是對 已知的、讀密集的 訪問模式的一種最佳化——而不是一個到處套用的預設值。為你真正會跑的那些讀取預先 join,其餘的別去動。

要決定這些被複制的屬性 住在哪裡,先對訪問模式建模——參見 單表設計,以及權衡的讀取一側,Query 對比 Scan

下載 DynoTable,去檢視一張非正規化的表、找出哪些副本已經漂移,並對你自己的資料執行那次扇出更新。它的 SQL Workbench 甚至能把事實源與各份副本 JOIN 起來,在一次查詢裡找出漂移。

已更新