中階閱讀時間 3 分鐘

DynamoDB 稀疏索引

稀疏索引是一種只儲存那些攜帶其鍵屬性的項的二級 索引 —— 於是一張巨大表中一個又小又熱的子集,就變成了它自己的 預過濾、可直接查詢的集合。

你有數百萬行,但你整天執行的那個查詢只觸及很小的一片:那些 未關閉的支援工單、未付款的發票、被標記為待審查的帳戶。

過濾那一片仍然要掃描整張表,併為每一次讀取向你計費。而 稀疏索引則讓索引本身變小。

什麼是 DynamoDB 中的稀疏索引?

稀疏索引是一種只儲存那些攜帶其鍵屬性的項的次要索引。因為 DynamoDB 會跳過任何缺失該鍵的項,所以你發明一個只有你想要的項才會寫入的鍵 —— 未關閉的工單、未付款的發票 —— 於是索引就恰好成為那個子集。查詢隨後唯讀取它,無過濾器,無浪費的讀取容量。

  • 次要索引只索引那些擁有其鍵的項。 在某個項上省略這個鍵,它就 永不進入索引 —— 沒有預留位置,沒有 null 行。
  • 所以你發明一個只有你想要的項才攜帶的鍵。 在你要查詢的項上寫入它, 在其餘項上移除它。索引就恰好成為那個子集。
  • 查詢唯讀取那個子集,無過濾器。 它的大小追隨那個又小又熱的 集合,而不是表的總量。
  • REMOVE 才是槓桿,而不是清空。 一個空字串不是有效的索引 鍵 —— DynamoDB 會以 ValidationException 拒絕整個寫入 —— 所以你必須 刪除這個屬性。

問題:過濾並不省讀取

從 SQL 過來,你以為一個 WHERE 子句會收窄工作量。DynamoDB 的 FilterExpression 並不會。它在項被讀取之後才執行,而不是之前。

AWS 開發者指南 所述,"無論是否存在篩選運算式,一次 Query 都消耗相同數量的讀取容量" —— 你為檢查過的每一個項付費,然後把不匹配的 丟棄。

所以如果你 500 萬張工單裡有 50 張是未關閉的,一次帶過濾的 Query/Scan 會 翻遍數百萬張,才交給你那 50 張。

那正是每一個"為什麼我的 scan 這麼貴"帖子背後的陷阱; query 與 scan 對比有完整的成本全景。

稀疏索引透過讓索引本身變小來繞開它。

稀疏性是如何運作的

一個次要索引只索引那些實際擁有該索引鍵屬性的 項

AWS 關於稀疏索引的文件 把這一點講得很清楚:只有當一個項攜帶索引的鍵屬性時,DynamoDB 才會把它寫入次要索引,所以一個建立在很少被設定的屬性上的索引 自然會保持很小。

在某個項上缺失 GSI 的分割區索引鍵(或排序索引鍵),DynamoDB 就乾脆不 把它寫入索引。沒有預留位置,沒有 null 行 —— 這個項是缺席的。

那種"預設缺席"正是整個訣竅所在。別去索引一個_每個_項都 攜帶的 status 屬性。發明一個只有你想要查詢的項才根本 攜帶的屬性。

索引於是就變成一份恰好由那些項組成的乾淨列表,而針對它的一次 Query 唯讀取它們 —— 無過濾器,無浪費的容量。

設想基表在給索引供料,只有攜帶該鍵的項才跨越過去:

鍵已移除鍵已移除稀疏 GSI(僅未關閉)未關閉未關閉基表(全部項)未關閉:有該鍵未關閉:有該鍵已關閉:無該鍵已關閉:無該鍵

只有帶鍵的(未關閉的)項才複製到索引;已關閉的項從不進入它。

這與單表設計是同一種塑鍵 心法:鍵是你為某個特定訪問模式打造的工具,而不是你資料的 忠實映象。

一個完整示例:"只看未關閉工單"

設想一張支援工單表。基表被建鍵用來按 id 取一張工單, 以及列出某個客戶的工單:

PKSKattributes
TICKET#a91fDETAILsubject, body, priority, openState
CUSTOMER#88TICKET#a91fsubject, priority, openState

在這張表的整個生命週期裡,大多數工單最終會關閉。但你的坐席 整天點選的那個儀表盤查詢是"給我看每一張未關閉工單,最早的優先" —— 藏在數百萬張裡的區區幾百行。

稀疏索引的做法:定義一個 ,分割區索引鍵為 openBucket,排序索引鍵為 openedAt,並只在未關閉工單上寫入 openBucket。在工單 建立時設定它;在工單解決時 REMOVE 它。

PKSKopenBucketopenedAt
TICKET#a91fDETAILOPEN2026-06-23T09:14:00Z← open: in the index
TICKET#b02cDETAILOPEN2026-06-22T16:40:00Z← open: in the index
TICKET#77deDETAIL(absent)2026-05-30T11:02:00Z← closed: NOT in the index

工單 a91fb02c 攜帶 openBucket,所以它們存在於 GSI 中。工單 77de 已被解決且其 openBucket 已被移除,所以它悄然出局了。儀表盤 現在就是一次廉價的查詢:

Query  IndexName = "open-tickets-index"
KeyConditionExpression: openBucket = "OPEN"
ScanIndexForward: true        # oldest first

這隻讀取未關閉工單。隨著工單關閉,索引會自行收縮 —— 它的 大小追隨_未關閉_工單的數量,絕不追隨總量。

一個靜態的分割區值("OPEN")在這裡沒問題,恰恰因為這個集合 保持很小。一個龐大的未關閉集合會需要一個分片的分割區索引鍵,但"小子集" 索引正是一個單一值恰當的用武之地。

讓它成立的那次轉變,就是一次單獨的 —— 在工單解決時 移除那個屬性。

DynamoDB 運算式構建器裡為讀取側 原型化那個 REMOVE 子句和帶型別的鍵條件,而不是自己手工 拼裝 ExpressionAttributeNames:val 預留位置。

在 DynoTable 中操作

稀疏索引最難的部分不是讀取 —— 而是_看清_哪些項進了 索引,哪些又悄然出局了。

DynoTable 讓你把一個表檢視切換到一個次要索引,看清那個 已填充的子集。這樣你就能確認一張已解決的工單真的離開了 open-tickets-index,而不是帶著一個陳舊的鍵滯留其中。

在 DynoTable 中透過其未關閉工單稀疏 GSI 檢視支援工單表,只顯示攜帶 openBucket 鍵的項。
在 DynoTable 中透過其未關閉工單稀疏 GSI 檢視支援工單表,只顯示攜帶 openBucket 鍵的項。

陷阱與後續步驟

有幾件事要留意:

  • 移除這個鍵,別清空它。 一個空字串不是有效的索引鍵 —— 寫入 openBucket = "" 會以 ValidationException 失敗,所以這個項從不會 被它索引。要把一個項從索引中剔除,你必須 REMOVE 這個屬性。
  • 索引是的。 GSI 是非同步更新的,所以一張 剛解決的工單可能短暫地仍然出現 —— GSI 讀取 只支援最終一致。 別用它來判斷"這張工單此刻是否未關閉"。
  • 留意屬性。 對索引的一次 Query 只返回被 投影進它的屬性。如果儀表盤需要主題和優先順序,就 投影它們 —— 否則就為完整的基項多付一次 GetItem
  • GSI 和 LSI 都可以是稀疏的 —— 槓桿是一樣的:在你不想索引的 項上省略索引的排序索引鍵。不過 GSI 通常是更好的選擇: 你可以在建表之後再新增它,並給它自己的鍵 schema 和 容量。GSI 與 LSI 對比拆解了這個取捨。

稀疏索引是這套模型裡最古老的想法之一。最初的 2007 年 Amazon Dynamo 論文 就是圍繞著廉價地服務已知的、高流量的訪問模式來構建這個儲存的。

而稀疏索引恰恰就是這個:塑造你的鍵,讓常見查詢不讀取任何它 不需要的東西。

要真正構建並檢視一個,就下載 DynoTable,把它指向 你的表,把資料檢視切到你的稀疏 GSI —— 看著子集隨著 項獲得和失去索引鍵而更新。

已更新