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 唯讀取它們 —— 無過濾器,無浪費的容量。
設想基表在給索引供料,只有攜帶該鍵的項才跨越過去:
只有帶鍵的(未關閉的)項才複製到索引;已關閉的項從不進入它。
這與單表設計是同一種塑鍵 心法:鍵是你為某個特定訪問模式打造的工具,而不是你資料的 忠實映象。
一個完整示例:"只看未關閉工單"
設想一張支援工單表。基表被建鍵用來按 id 取一張工單, 以及列出某個客戶的工單:
| PK | SK | attributes |
|---|---|---|
| TICKET#a91f | DETAIL | subject, body, priority, openState |
| CUSTOMER#88 | TICKET#a91f | subject, priority, openState |
在這張表的整個生命週期裡,大多數工單最終會關閉。但你的坐席 整天點選的那個儀表盤查詢是"給我看每一張未關閉工單,最早的優先" —— 藏在數百萬張裡的區區幾百行。
稀疏索引的做法:定義一個 ,分割區索引鍵為 openBucket,排序索引鍵為
openedAt,並只在未關閉工單上寫入 openBucket。在工單
建立時設定它;在工單解決時 REMOVE 它。
| PK | SK | openBucket | openedAt | |
|---|---|---|---|---|
| TICKET#a91f | DETAIL | OPEN | 2026-06-23T09:14:00Z | ← open: in the index |
| TICKET#b02c | DETAIL | OPEN | 2026-06-22T16:40:00Z | ← open: in the index |
| TICKET#77de | DETAIL | (absent) | 2026-05-30T11:02:00Z | ← closed: NOT in the index |
工單 a91f 和 b02c 攜帶 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,而不是帶著一個陳舊的鍵滯留其中。

陷阱與後續步驟
有幾件事要留意:
- 移除這個鍵,別清空它。 一個空字串不是有效的索引鍵 ——
寫入
openBucket = ""會以 ValidationException 失敗,所以這個項從不會 被它索引。要把一個項從索引中剔除,你必須REMOVE這個屬性。 - 索引是的。 GSI 是非同步更新的,所以一張 剛解決的工單可能短暫地仍然出現 —— GSI 讀取 只支援最終一致。 別用它來判斷"這張工單此刻是否未關閉"。
- 留意屬性。 對索引的一次
Query只返回被 投影進它的屬性。如果儀表盤需要主題和優先順序,就 投影它們 —— 否則就為完整的基項多付一次GetItem。 - GSI 和 LSI 都可以是稀疏的 —— 槓桿是一樣的:在你不想索引的 項上省略索引的排序索引鍵。不過 GSI 通常是更好的選擇: 你可以在建表之後再新增它,並給它自己的鍵 schema 和 容量。GSI 與 LSI 對比拆解了這個取捨。
稀疏索引是這套模型裡最古老的想法之一。最初的 2007 年 Amazon Dynamo 論文 就是圍繞著廉價地服務已知的、高流量的訪問模式來構建這個儲存的。
而稀疏索引恰恰就是這個:塑造你的鍵,讓常見查詢不讀取任何它 不需要的東西。
要真正構建並檢視一個,就下載 DynoTable,把它指向 你的表,把資料檢視切到你的稀疏 GSI —— 看著子集隨著 項獲得和失去索引鍵而更新。


