進階閱讀時間 3 分鐘

DynamoDB 中的鍵過載

從 SQL 過來的你,一個列永遠只表示一件事:orders.created_at 永遠是日期,users.email 永遠是郵箱。鍵過載把這套規則徹底拋棄。你給分割區索引鍵和起通用的名字——pksk——讓每種條目型別往裡灌注不同的含義。一張表,多種實體,一套結構。

DynamoDB 中的鍵過載是什麼?

鍵過載就是把多種實體型別用通用的鍵名(如 pk/sk)存進一張表,把型別編碼進值裡(USER#u_3001INVOICE#2026-0014)。屬性名保持中性,於是使用者、發票和事件共享同一個分割區;值攜帶型別資訊,而排序索引鍵字首讓一次 Query 就能透過 begins_with 切出每種實體。

  • 通用的鍵名,帶型別的值。把鍵命名為 pk/sk,把實體型別放進值裡:pk = "TENANT#acme"sk = "USER#u_3001"。名字是無意義的,值才攜帶型別。
  • 它是單表設計能跑起來的關鍵。沒有過載,共享的一張表不過是個雜物抽屜。有了它,每個實體都落在一個你能 Query 的分割區裡。
  • begins_with 就是回報。排序索引鍵上的型別字首讓一次 Query 就能拉取整個實體,或它的一個切片,不需要 Scan,也不需要過濾。
  • 代價是:可讀性。一份原始的 pk/sk 轉儲什麼都告訴不了你。你需要一個能解碼字首的檢視器,否則就只能眯著眼盯著一堆字串。

為什麼通用名字勝過真實名字

DynamoDB 每張表最多給你兩個鍵屬性,而一次 Query 只能針對單個分割區索引鍵。所以如果你把鍵命名為 userId,那只有使用者條目才能乾淨地存進這張表——其他一切都得偽造一個 userId,或者搬到自己單獨的表裡去。

過載規避了這個問題。像 pk 這樣中性的名字不繫結任何實體,於是一個使用者、一張發票、一條審計事件都能共享同一個鍵屬性和同一張表。是、而不是屬性名,說明了條目是什麼。

正是這一招,把單表設計從理論變成了你真正能查詢的東西。共享的表是容器;過載則是讓不同實體在其中共存的機制。

一個多租戶示例

假設你營運一個 SaaS 計費產品。每個租戶都有成員、發票和一份審計軌跡。與其用三張表,不如全部放進一張表並過載鍵:

pkskattributes
TENANT#acmeMETAname="Acme Inc", plan="team"
TENANT#acmeUSER#u_3001email, role="admin"
TENANT#acmeUSER#u_3002email, role="member"
TENANT#acmeINVOICE#2026-0014amount_cents, status="paid"
TENANT#acmeINVOICE#2026-0015amount_cents, status="open"
TENANT#acmeEVENT#2026-06-23T09:12Zactor="u_3001", action="invite"

每一行都共享 pk = "TENANT#acme",因此它們構成一個——全部聚在一起,全部可在一次分割區讀取中觸及。

分区:TENANT#acmesk: METAsk: USER#u_3001sk: INVOICE#2026-0015sk: EVENT#2026-06-23T09:12Z一次 Query

排序索引鍵字首在幹真正的活。它既給實體分組,給它們排序。

查詢過載的集合

因為型別就存在排序索引鍵字首裡,begins_with 無需掃描任何東西就能按實體切分分割區:

Query pk = "TENANT#acme"  -- the entire tenant, every type
Query pk = "TENANT#acme" AND begins_with(sk, "USER#")  -- just members
Query pk = "TENANT#acme" AND begins_with(sk, "INVOICE#")  -- just invoices

你只為條件匹配到的條目付費,而不是整個分割區——這與帶過濾的 Scan 恰好相反,後者你得為讀取那些隨後又被丟棄的行付費。AWS 把這叫作鍵條件;它在任何資料離開分割區之前先作用在鍵上。

如果你手工構造那個 begins_with 條件,一定要把型別標籤寫對——一個寫歪了的 USERS#(而非 USER#)會靜默地返回空結果。運算式構建器會生成 KeyConditionExpressionExpressionAttributeValues 對映,讓字首與你真正寫的內容一致。

索引也一起過載

同樣的技巧適用於 。給它起通用的鍵名——gsi1pkgsi1sk——讓每個實體寫入它需要的任何內容。這樣一個索引就能回答基表回答不了的模式。

pkskgsi1pkgsi1sk
TENANT#acmeINVOICE#2026-0015STATUS#open2026-06-30
TENANT#acmeINVOICE#2026-0014STATUS#paid2026-06-12
TENANT#betaINVOICE#2026-0099STATUS#open2026-06-25

現在 Query gsi1 WHERE gsi1pk = "STATUS#open" 會列出所有租戶中每一張待處理的發票,並按到期日排序——這是一個跨分割區檢視,基表那些以租戶為範圍的鍵永遠無法服務。另一個實體可以用它自己的含義複用 gsi1(比如 gsi1pk = "ROLE#admin"),所以一個索引就覆蓋了好幾種讀取。只是要記住,GSI 是最終一致的——它的寫入會滯後於基表。

在 DynoTable 中操作

原始的過載鍵讀起來很不友好:INVOICE#2026-0015EVENT#2026-06-23T09:12Z 在扁平列表裡混作一團。一個按分割區分組、把字首顯式呈現的檢視器,能把雜物抽屜重新變回一個個實體。

DynoTable 瀏覽一個租戶的條目集合——META、USER、INVOICE 和 EVENT 條目分組在單個過載的分割區索引鍵之下。
DynoTable 瀏覽一個租戶的條目集合——META、USER、INVOICE 和 EVENT 條目分組在單個過載的分割區索引鍵之下。

陷阱

  • 分隔符一次定好,永不更改。# 是約定俗成的選擇。在不同實體間混用 #: 會以某種沒有任何東西會警告你的方式破壞 begins_with
  • 別過載需要做範圍數學的值。排序索引鍵 INVOICE#2026-0015 是按字典序排序的,不是按數值——給 id 並使用 ISO-8601 日期,好讓字串順序與你想要的順序一致。
  • 給字首名稱空間留出預留位。兩個都以 USER 開頭的實體型別(比如 USER#USERGROUP#)會在 begins_with(sk, "USER") 下發生衝突。要讓字首從第一個字元起就沒有歧義。
  • 先規劃讀取,再定鍵。過載服務的是你已經列舉好的訪問模式。如果你還不知道自己的讀取需求,請先看單表設計——鍵是由查詢衍生出來的。

先規劃好一個分割區,然後下載 DynoTable 去瀏覽你自己的過載鍵,看著一次 Query 把整個租戶一次性拉回來。

Query 過載分割區的成本

列出 TENANT#acme 下的每個成員 begins_with(sk, "USER#") 僅讀取使用者行 — 不讀取發票或事件 — 因為關鍵條件在資料離開分割區之前進行過濾。在一個租客身上有 200 個使用者(每個 2 KB)和 5,000 個稽核事件(每個 1 KB),該查詢涉及約 400 KB(約 100 個最終一致的 RCU)。整個桌子上有Scan 要找到使用者,就要對每個租戶中的每個項目進行計量。將代表性的超載項目貼上到 item-size calculator,然後估算列表查詢pricing calculator

使用單表工具進行設計

輸入實體(租戶、使用者、發票、事件)和訪問模式(“列出使用者對於租戶”、“跨租戶開具發票”) single-table design tool。它建議與您將使用的過載字首匹配的 pk/sk 模板和 GSI 鍵在生產中 — 在您提交 CloudFormation 之前。

從模式發出查詢

固定字首後,在中構建關鍵條件 expression builder 並匯出分頁來自query builder的節目。字首拼寫錯誤 (USER# vs USERS#) 返回空集且沒有錯誤 — 生成的運算式減少無聲故障模式。

實體型別字首登入檔

維護一個簡短的內表開發者可以參考:

實體排序字首示例 SKQuery 切片
租戶元METAMETA單品獲取
使用者USER#USER#u_3001begins_with(sk, "USER#")
發票INVOICE#INVOICE#2026-0015begins_with(sk, "INVOICE#")
活動EVENT#EVENT#2026-06-23T09:12Z具有降序讀取的按時間順序的尾部

新實體型別必須選擇在 begins_with 下不發生衝突的字首現有字首 - USER#USERGROUP# 都匹配 begins_with(sk, "USER")除非你小心地延長或分隔。

已更新