晚上好,我是 miruky。
先說個突然的,我最喜歡的 AWS 服務是 Amazon DynamoDB(常常把 Dynamo 打成 Dyamo)。原因很簡單,就是「非常好用」。在不需要像 RDBMS 那麼嚴格資料一致性的場景下,這是一個相當實用的服務;雖然還是需要設計,但因為基本上是無結構(schema-less),只要先決定幾個主要鍵值,之後欄位就能由應用程式端自由擴充,而且又是無伺服器(serverless),幾乎不需要去意識到基礎架構部分(個人感想)。
這次雖然說來有點老生常談,但我想把對 DynamoDB 設計時應該掌握的重點,整理成一篇相對完整的文章,主要從「分區鍵」、「GSI 與 LSI」、以及「單表設計」這三個主軸快速整理。
如果你想看 RDB 相關內容,可以參考這篇文章。
那麼,開始吧。
設計 RDB 時,視情況而定,基本上常會先把資料結構正規化,之後再思考要怎麼查詢。因為有 JOIN,所以就算把表拆開也不會太困擾。
但 DynamoDB 沒有這個後半段。因為不能 JOIN,所以不能採用「之後再想查詢方式」這個順序。這點有點痛,但也沒辦法。因此 DynamoDB 的設計,會變成先把應用程式需要的存取模式(查詢模式)全部列出來,再依照這些存取模式來組合表與索引,也就是一種反過來的流程。
只要記住這個前提,後面關於分區鍵和索引的討論,就能比較理解為什麼要這樣設計。
DynamoDB 的主索引鍵,可以是單獨的分區鍵,也可以是分區鍵加排序鍵的組合。若只用分區鍵作為主鍵,其角色就和 RDB 的主鍵幾乎一樣,值本身必須在整張表中唯一。若搭配排序鍵,保證唯一性的單位就不再是單獨的分區鍵,而是分區鍵與排序鍵的組合。
也就是說,可以在同一個分區鍵下放多個項目;相對地,在同一個分區鍵內,排序鍵的值不能重複。
剛剛提到,若搭配排序鍵,只要兩者組合起來唯一即可。不過,滿足唯一性和實際的存取分布是兩回事。就算組合上沒有重複,如果分區鍵本身偏斜,讀寫還是會集中到同一處。接下來就來談這種偏斜。
DynamoDB 會根據分區鍵的值,把資料分配到實體分區中。如果分區鍵的值種類很少,或某個值的存取特別集中,對應的分區就會變得很忙。這就是所謂的熱分區(hot partition)。
假設把訂單資料以「訂單日期」作為分區鍵來儲存。
PK(訂單日期)SK(訂單 ID)使用者 ID金額2024-01-20ORDER#00001USER#1013,200 元2024-01-20ORDER#00002USER#2041,800 元2024-01-20ORDER#00003USER#3095,000 元三筆資料的 PK 都是 2024-01-20。如果遇到促銷等情況,當天訂單暴增,那一天的分區就會集中大量存取,很容易碰到 2-2 會提到的每秒上限。
如果把同樣的資料改成以「訂單 ID」作為分區鍵來存。
PK(訂單 ID)SK使用者 ID訂單日期金額ORDER#00001METADATAUSER#1012024-01-203,200 元ORDER#00002METADATAUSER#2042024-01-201,800 元ORDER#00003METADATAUSER#3092024-01-205,000 元這次 PK 是依訂單分散的。就算某一天訂單數量激增,存取的分區依然是分散的。基本上應該選像使用者 ID、訂單 ID 這類值種類多、存取也會自然分散的欄位作為分區鍵。這點和 RDB 的思考方式也很接近。
DynamoDB 的每個實體分區,都有每秒 3,000 個讀取單位,以及每秒 1,000 個寫入單位的上限。1 個讀取單位代表可在 1 秒內,以強一致性讀取一筆最多 4KB 的項目。1 個寫入單位代表可在 1 秒內寫入一筆最多 1KB 的項目。
這裡所說的強一致性,是指在開始讀取之前已成功完成的寫入,讀出來一定會反映出來。也就是說,更新後立刻再讀,不會拿到舊值。
這個上限不一定只套用在某個單一分區鍵值上,而是以實體分區為單位。不過,如果是單一項目的存取集中,或像具有 LSI(後面會說)那種同一個分區鍵值的項目永遠會聚在同一個實體分區上的情況,這個上限就會實質上變成該分區鍵值的天花板。即使整張表的容量還很充裕,只要特定實體分區的存取過於集中,還是只會在那一處被節流。
分區設計通常會成為瓶頸,往往就是因為撞上這個界線。
即便如此,還是可能遇到某個值的存取量就是特別集中。很常見的例子,是像文章按讚數這種只保留最新總數的計數器。如果只用一筆項目存總數,那分區鍵就只有 POST#001 一種,不管多少人同時按,寫入都會集中到同一個地方。
PKSK讚數POST#001LIKES9,999這就會直接朝著 2-2 提到的每秒 1,000 WCU 上限衝去。這時候就會用到「寫入分流(write sharding)」這個概念。做法是在分區鍵尾端加上 0 到 3 的數字,把原本 1 種鍵變成 4 種。
PKSK讚數POST#001#0LIKES2,410POST#001#1LIKES2,533POST#001#2LIKES2,488POST#001#3LIKES2,568寫入時,應用程式端從 0 到 3 選一個來 UpdateItem。讀取時,則把 4 筆都取出來,再由應用程式端加總。可以把它理解成:原本 1 次讀取,換成 4 次讀取,以便把寫入分散到 4 個邏輯鍵上。
尾端數字的決定方式大致有兩種。
方式數字的決定方式讀取隨機後綴每次寫入時用亂數選擇讀取全部 shard 並加總計算後綴根據項目屬性計算可針對特定項目命中對應 shard如果只是想拿到總數,像計數器這種情境,用亂數就夠了。如果之後還想回頭查特定項目,則可以用像「使用者 ID 的雜湊值除以分割數的餘數」這種計算式方式,讓讀取可以回到 1 次。
排序鍵是用來在同一個分區鍵值下排列資料,並進行範圍查詢的鍵。它可以單獨使用,但真正厲害的是把它組成複合字串時。
以下是把分區鍵固定為 USER#123,並在排序鍵前面加上用途前綴後儲存的例子。因為同一天可能會有多筆訂單,所以排序鍵尾端也包含訂單 ID。
PKSKUSER#123PROFILEUSER#123ORDER#2024-01-20#00001USER#123ORDER#2024-02-14#00002只要指定像 PK = "USER#123" AND begins_with(SK, "ORDER#") 這樣的鍵條件,就可以把 PROFILE 以外的訂單一次取出來。排序鍵不只是單純的排序順序,而是把某個使用者底下的多種類型資料,在同一個分區鍵中組成階層結構的方式。這個觀念也會成為第 5 章單表設計的基礎。
這是 AWS 認證考試很常出現的題目。也就是有全球(G)與本地(L)兩種,第二個索引,也就是次要索引(secondary index,SI)。
之所以需要索引,是因為在 Query 中只能用主索引鍵的屬性作為條件。假設你原本做了一張以使用者 ID 為主軸查詢訂單的表,但後來又想用商品 ID 找訂單,不加索引的話就只能全表掃描。用來從不同角度重新查詢的替代鍵機制,就是次要索引。
兩者的差異,核心可以濃縮成:能不能替換分區鍵。LSI 是固定分區鍵,只把排序鍵改成其他屬性;GSI 則可以自由改成別的分區鍵。
這一點差異會延伸到建立時機與一致性限制。下表所列的差異,也是 AWS 認證很常考的內容。
觀點LSIGSI分區鍵與基礎表相同可自由設定建立時機只能在建立表時可隨時新增、刪除強一致性讀取可以不可以大小上限每個分區鍵值 10GB無上限每張表上限最多 5 個預設最多 20 個容量消耗消耗基礎表的容量模式與基礎表相同;配置型可個別設定 RCU/WCU
LSI(本地次要索引)是指分區鍵和基礎表相同,但可以把排序鍵替換成另一個屬性的索引。這點非常重要。
另外一個重點是,LSI 只能在建立表時新增,而且一旦建立就不能刪除。即使之後才想到「啊,這個排序鍵也想查」,LSI 也無法補救。因此在表設計的初期,就必須把真正需要的排序鍵候選項先整理出來。
LSI 有一個 GSI 沒有的優點:可以選擇強一致性讀取(ConsistentRead=true)。
原因在於它的放置方式。LSI 的分區鍵和基礎表相同,因此索引實體也和基礎表共存在同一個分區中。因為可以和寫入同步更新,所以剛寫完馬上讀也會拿到最新值。相對地,GSI 是非同步複寫到其他分區,因此多少會有延遲。
不過,LSI 有每個分區鍵值 10GB 的上限。這個限制是針對同一個分區鍵值下,基礎表與所有 LSI 的總大小。若你的設計預期某一個分區鍵值的資料量會無限成長,那麼與其用 LSI,不如考慮 GSI 會比較安心。
GSI(全域次要索引)可以把分區鍵本身替換成另一個屬性。它可以在建表之後再新增或刪除,而且沒有大小上限。一張表預設最多可以有 20 個 GSI。
目前的 GSI 中,分區鍵與排序鍵各自最多可由 4 個屬性構成。也就是說,不需要再由應用程式自行把多個屬性串成一個複合鍵,也能直接把既有屬性組合起來。基礎表與 LSI 的鍵,則仍然是一樣,分區鍵與排序鍵各指定一個屬性即可。
代價是,對 GSI 的查詢只能使用結果一致性讀取。結果一致性是指:在寫入成功後,立刻讀取可能還會看到舊值;但過一段時間後,各份複本的內容會逐漸一致。基礎表的寫入反映到 GSI 之間會有些微延遲。如果你的設計是要在寫入後立刻透過 GSI 讀回同一筆資料,建議先確認一下這件事。
GSI 和 LSI 都可以從三種方式中,選擇要在索引中複製哪些屬性(欄位)。
投影索引中包含的屬性儲存與寫入成本KEYS_ONLY只有基礎表與索引的鍵最少INCLUDEKEYS_ONLY 加上指定的屬性依指定內容增加ALL基礎表的所有屬性最多如果把需要的屬性縮小到必要範圍,就能降低索引的儲存與寫入成本。不過,當查詢後需要拿取未投影的屬性時,GSI 和 LSI 的行為會不同。
要求未投影的屬性時LSIGSI能不能取得可以不行內部動作 DynamoDB 會再去基礎表取資料 GSI 不會回傳,且不會自動從基礎表補取額外成本讀取容量與延遲需自行呼叫 GetItem 或 BatchGetItem對 GSI 而言,通常會需要先從索引拿到主鍵,再回頭讀一次基礎表。越常用的查詢,越應該事先把必要屬性納入投影,才能避免這種來回。
以 RDB 的直覺來看,使用者和訂單分成不同的表是理所當然的。你應該也對 ER 圖中那些彼此關聯複雜的多張表很熟悉。可是到了 DynamoDB 的世界,常見的做法反而是故意把使用者、訂單、訂單明細全部放到同一張表裡。這就是單表設計。
理由很單純:為了能一次查出相關資料。如果把使用者和該使用者的訂單放在相同的分區鍵下,就可以用一次查詢一起拿到。若拆成多張表,原本會變成先查使用者、再查訂單的來回流程。
為了把多種實體塞進同一張表,分區鍵與排序鍵通常會取一個通用名稱。常見的是 PK 與 SK,其實際意義會依用途而變。以下用第 3 章的 USER#123 例子,加入訂單明細項目後擴充看看。
PKSK內容範例USER#123PROFILE姓名、電子郵件USER#123ORDER#2024-01-20#00001訂單日期、總金額USER#123ORDER#2024-01-20#00001#ITEM#001商品名稱、數量對同一個 PK 值,只要改變 SK 的前綴,就能區分是個人資料、訂單,還是訂單明細。這種把鍵的語意依用途重複使用的想法,就稱為重疊使用(overload)。
像「一個使用者有多筆訂單」這種一對多關係,只要把父層當成分區鍵,子層用不同的排序鍵放在同一個分區鍵底下,就能表達。5-2 的例子正是這種形式。
比較複雜的是使用者與群組這種多對多關係。也就是一個人可以加入多個群組,而一個群組也可以有很多人。RDB 通常會透過關聯實體,硬做出多個一對多關係,再讓多的一側以外鍵參照另一側。
在 DynamoDB 裡,這裡常用的是鄰接清單模式(adjacency list pattern):把關係本身當成一筆項目(邊,edge),並把一端實體當作分區鍵,另一端當作排序鍵來儲存。
PKSK意義USER#123GROUP#A使用者 123 屬於群組 AUSER#123GROUP#B使用者 123 屬於群組 BUSER#456GROUP#A使用者 456 屬於群組 A這樣就可以透過 PK = "USER#123" 查出 123 的所屬群組。不過反過來,如果想知道群組 A 裡有哪些人,只靠使用者 ID 當分區鍵就無法直接查。這時就再加一個把排序鍵反轉成分區鍵的 GSI。
GSI 的 PKGSI 的 SKGROUP#AUSER#123GROUP#AUSER#456GROUP#BUSER#123這是同一批資料,只是把鍵的方向反過來,如此就能從 PK = "GROUP#A" 查出成員。DynamoDB 透過「邊項目 + 反轉 GSI」的組合,來扮演 RDB 裡多對多的中介表角色。
當然,也不是只有好處。因為一張表裡混了多種實體,光看表內容不容易直接理解結構。每當存取模式增加,就可能需要重新檢視鍵設計;新加入團隊的人也可能需要一段時間才能理解這張表的意圖。
觀點分表單表相關資料取得需要多次讀取同一個 PK 時可用一次 Query 一起取出結構理解可從表名與屬性名判斷需要確認鍵設計與項目類型新增存取模式需要重新檢視目標表的鍵與 GSI需要重新檢視共用的鍵與 GSI適合場景不會一起取出多種實體會一起取出多種實體
當然,不是所有應用程式都適合單表設計。判斷標準不是實體數量,而是不同種類的資料是否會以相同的存取模式一起被取出。如果不會一起取出,就分表;如果像使用者資訊和訂單這樣,經常需要一起查出來,那麼單表設計就值得列入候選。我認為這是比較務實的判斷方式。
Query 是先用分區鍵條件鎖定目標再讀取;Scan 則是先把整張表從頭到尾掃過,再做篩選。差別就在於篩選是在讀取前還是讀取後。
觀點QueryScan讀取範圍符合 PK 等值條件與任意 SK 條件的資料整張表消耗的讀取容量符合鍵條件的資料大小整體評估後的全部資料大小資料量增加時如果同一個 PK 下讀取量增加會變重會隨整張表的成長而變重
資料庫一定會談到的就是事務(transaction)。
事務是把多個操作當成一個整體處理的機制,用來避免只反映其中一部分結果。比方說,把「庫存減 1」和「建立一筆訂單」放進同一個事務裡,就會是兩者都成功,或兩者都不生效,二選一。
如果想把多筆項目合併成一次操作,可以使用 TransactWriteItems 與 TransactGetItems。這兩者內部都會經過準備與提交兩階段,因此容量也會以 2 倍計算。
觀點TransactWriteItemsTransactGetItems最大項目數100100總大小4MB4MB條件式可指定不可指定部分失敗時全部失敗只是不回傳不存在的項目消耗容量每 1KB 2 WCU每 4KB 2 RCU要特別注意的是,即使事務被取消,容量還是會被消耗。即使 ConditionCheck 為假導致整體失敗,也一樣要支付和成功時相同的讀寫容量。
讀取的一致性預設是結果一致性,只要指定 ConsistentRead 就能切換成強一致性。成本會隨一致性強度提高而增加。
讀取類型每 4KB 的消耗以結果一致性為 1 時的倍率結果一致性0.5 RCU1 倍強一致性1 RCU2 倍事務2 RCU4 倍事務讀取和強一致性相比是 2 倍,但和預設的結果一致性相比則是 4 倍。若不小心把操作改成 TransactGetItems,有可能會消耗到預期 4 倍的 RCU。
如同第 4 章所提到的,對 GSI 的查詢無法選擇強一致性。因此,如果某個讀取需要一致性,卻設計成走 GSI,就連這張表中的選項都用不上,所以在設計階段就應該分清楚。
容量模式有預先保留讀寫量的配置型(provisioned)模式,以及依使用量計費的隨需型(on-demand)模式。到了 2024 年,又新增了 Warm Throughput 這個功能。它可以在隨需型與配置型兩種模式下,確認表與 GSI 目前能立即處理的讀寫量;當預期會有大幅流量成長時,也可以事先把數值提高。查詢目前值是免費的,但若明確調高則會產生費用,而且一旦調高就不能再調低。
可將資料複製到多個區域的全域表(Global Tables),過去長期是以結果一致性複寫(MREC)為前提,但現在也可以選擇跨區域強一致性模式(MRSC)。在 MREC 中,事務只會在執行操作的區域內完成,不會以一個整體複製到其他區域。至於 MRSC,則無法使用事務操作。MRSC 也必須是剛好 3 個區域的配置,且不支援 LSI 與 TTL,整體一致性模式在建立後也不能更改,這些都值得注意。
此外,還有像是 TTL(超過到期時間的項目會非同步刪除,不會立刻消失,通常會在數天內刪除)、DynamoDB Streams(可偵測寫入變更並傳給 Lambda 等服務)、以及加速讀取密集型存取的 DAX 等周邊功能。這些都比較偏向如何把已設計好的表投入實際運用,而不是表設計本身;如果有興趣,可以看看官方文件。
感謝你看到這裡。
DynamoDB 的設計,是先決定存取模式,再依照該模式來組合分區鍵、排序鍵、GSI 與 LSI;這是一個和 RDB 相反的流程。特別是 LSI 不能事後重做,所以在表設計初期能把存取模式盤點得多完整,會大大影響之後的設計自由度。
那麼,之後再見。