中階閱讀時間 3 分鐘

如何在 DynamoDB 中建模資料

在 SQL 裡你先建模實體與關係,然後信任查詢規劃器日後把你要的任何東西組合出來。DynamoDB 把這件事反過來。你為你已經知道自己會做的那些讀取建模,而索引鍵的存在,就是為了服務它們。

沒有 join 引擎,也沒有規劃器在執行期挑策略。一個 Query 沿著一個索引鍵讀取一個分割區,那就是全部的效能合約。所以你為已知的存取模式設計索引鍵,而不是為一份整潔的結構描述。

AWS 在它的最佳實務指南裡把話講得很白:「在你知道結構描述需要回答哪些問題之前,你不該開始設計它。」

本指南在一個領域上走過整個流程:一個追蹤玩家、他們打的比賽,以及他們每個賽季排名的多人遊戲排行榜。我們從一份問題清單走到一套可用的索引鍵結構。

你要如何在 DynamoDB 中建模資料?

先為讀取建模,不是為資料表。列出應用會做的每一個查詢,然後設計一個,讓每個問題都解析成單一個 QueryGetItem。把一起讀取的項目放在一處、在排序索引鍵上對值做範圍查詢,並為任何基礎表無法服務的存取模式加一個 GSI。

  • 先列讀取,不是資料表。 那些問題才是規格;那些名詞是分散注意力的東西。
  • 每個問題都必須是一個 QueryGetItem 如果一個問題需要 Scan,那模型就錯了。
  • 一起放置的項目共用一個;你要對其做範圍查詢的任何東西都放進
  • 基礎表回答不了的問題就給它一個 ——絕不是一個帶篩選的 Scan

步驟 1——把問題框成問題,不是資料表

抗拒去畫 playersmatchesscores 資料表的衝動。那個直覺是 SQL 習慣,在這裡它是錯的。改成寫下應用實際執行的每一次讀取。對我們的排行榜:

  • 依 id 取得一名玩家的個人檔案。
  • 列出一名玩家最近的比賽,最新的在前。
  • 顯示某個賽季的前 N 名玩家,依評分排名。
  • 依玩家的公開 handle 查找玩家(例如用於個人檔案 URL)。

這四個問題——不是那些名詞——才是規格。每一個都必須解析成單一個 Query(或 GetItem),因為那是 DynamoDB 在規模下唯一能便宜服務的存取形狀。

如果一個問題只能靠掃描表來回答,那模型就錯了,而你會在延遲和成本上感受到——見 Query vs Scan了解為什麼 Scan 是要避開的地雷。

整套方法是一條你每個領域跑一次的簡短、有序的流水線:

列出實體列舉存取模式設計 PK / SK來滿足它們每次讀取都是一次 Query?為每個剩下的讀取加一個 GSI用真實資料驗證出現新的問題?交付這個模型

下面的每個步驟對映到一個方框:列出、列舉、設計索引鍵、為其餘的加索引,然後驗證。

步驟 2——理解你正在用來建模的基本元件

一張表有一個分割區索引鍵(PK)挑選一個項目住在哪個實體分割區,以及一個選用的排序索引鍵(SK)在那個分割區之內排序項目。

AWS 的核心元件文件把這一對稱為項目的主索引鍵。一個 Query 永遠鎖定剛好一個 PK 值,並可以對 SK 做範圍掃描或篩選——那就是全部的工具箱。

正是這種單一分割區設計,讓 DynamoDB 能交付 2007 年 Amazon Dynamo 論文最早描述的那種可預測、低延遲、水平分割的讀取。

有兩個後果驅動下面的每一個決定:

  1. 一起讀取的項目應該共用一個分割區索引鍵,這樣一個 Query 就在單一個計費請求裡把它們傳回。
  2. 你想對其做範圍查詢的任何東西(最近的比賽、最高的評分)必須住在排序索引鍵裡,因為那是 Query 唯一能排序並設界的屬性。

當一個問題需要一種和基礎表提供的不同的存取形狀時,你加一個全域次要索引——把表在一組不同的 PK/SK 下重新投影。

(關於 GSI 對比區域次要索引,見 GSI vs LSI。)

步驟 3——一次一個問題地設計索引鍵

我們用單一張表,搭配通用、過載的索引鍵屬性——單表做法——因為一名玩家和他的比賽是一起讀取的。

自己發明前綴;這裡 PLAYER#MATCH#SEASON# 在原本通用的索引鍵裡標記實體型別。

問題 1 和 2(個人檔案 + 最近的比賽)共用一個分割區,所以兩者都掛在同一個 PK 下:

partitionIdrangeIdattributes
PLAYER#u8231PROFILEhandle, region, createdAt
PLAYER#u8231MATCH#2026-06-23T14result=win, ratingDelta=+18, mapId
PLAYER#u8231MATCH#2026-06-23T11result=loss, ratingDelta=-15, mapId

Query partitionId = "PLAYER#u8231" 在一次讀取裡傳回個人檔案和每一場比賽。若只要個人檔案,用 GetItem

對於最近的比賽,rangeId begins_with "MATCH#" 搭配 ScanIndexForward = false 會由新到舊走訪它們——排序索引鍵裡的時間戳免費完成了排序。

問題 3 和 4 無法從那個分割區回答——它們以賽季排名和 handle 為樞紐,兩者都不是基礎 PK。每一個各得一個 GSI。

我們加兩對通用的索引屬性——排名索引用的 seasonPartition / seasonSort,以及 handle 索引用的 handlePartition / handleSort——填在同一個個人檔案項目上(步驟 3 寫入的那一個,現在連同它填好的索引屬性一起顯示):

partitionIdrangeIdseasonPartitionseasonSorthandlePartitionhandleSort
PLAYER#u8231PROFILESEASON#2026-Q2RATING#1842HANDLE#nighthawkPLAYER#u8231

現在 Query 賽季索引 WHERE seasonPartition = "SEASON#2026-Q2" 搭配 ScanIndexForward = false 傳回依評分排名的玩家——那就是排行榜。

第二個以 handlePartition = "HANDLE#…" 為索引鍵的索引,在一次讀取裡把一個公開 handle 解析成一個玩家 id。一張實體表,四種單一 Query 的存取模式。

關於 RATING#1842 的一個注意:DynamoDB 依字典順序而非數值順序排序排序索引鍵,所以一個評分必須補零到固定寬度(RATING#01842),否則 9 會排在 1000 之後。這是一個經典的建模陷阱,值得事先做對。

步驟 4——在 DynoTable 中驗證模型

一套索引鍵結構只有在你看著一個真實的 Query 傳回你所預期、且不多不少的項目時,才贏得信任。

在 DynoTable 中打開表,對賽季索引執行排行榜查詢,並確認分割區回來時是排名且設界的——沒有 Scan,沒有用戶端排序。

在 DynoTable 中對 GSI 執行賽季排行榜 Query,並檢視排名後的結果。
在 DynoTable 中對 GSI 執行賽季排行榜 Query,並檢視排名後的結果。

當你為這些查詢建構條件運算式時——那個 begins_with、那個 seasonPartition = :p、那個佔位符 :p 綁定——讓 DynamoDB 運算式建構器來做。

它產生 KeyConditionExpressionExpressionAttributeNamesExpressionAttributeValues,所以像 result 這樣的保留字或一個打錯的佔位符,永遠不會悄悄弄壞一次讀取。

步驟 5——陷阱與下一步

在你交付模型之前有幾個陷阱要檢查:

  • 別為你從不一起讀取的關係建模。 每個問題一個 GSI 很便宜;一個浪費掉的 GSI 是持續的成本。從問題清單加索引,別憑臆測加。
  • 留意分割區熱度。 如果一個 PK(一名名人玩家、單一個熱賽季)吸收了大多數流量,那個分割區可能被節流。當一個索引鍵被證實很熱時,用一個後綴分片來分散寫入——AWS 在分割區索引鍵設計裡談了這個。
  • 把排序索引鍵裡所有數值或時間的東西補零並用 ISO-8601,好讓字典順序符合你要的順序。
  • 一個新問題 = 一個新索引鍵或索引,絕不是一個 Scan 當日後真的出現一種全新的存取模式時,擴充索引鍵;別用一個篩選來敷衍它。

先為問題建模,設計索引鍵讓每一個都是一個 Query,然後證明它。

想在中間那一步搶先起跑,免費的單一資料表設計工具能把一份像這樣的存取模式清單變成 PK/SK/GSI 規劃,附上範例項目與成本提示。

試用 DynoTable瀏覽你的表、把這些查詢並排對基礎表和 GSI 執行,並看著你所設計的存取模式傳回你所規劃的、剛剛好的東西。而對於那個你沒有建模到的問題,它的 SQL Workbench 能在用戶端執行真正的 JOINGROUP BY 與彙總。

已更新

互動式地試用這個設計

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

開啟單一資料表設計工具