一個 DynamoDB GSI 在內部是如何儲存的
一個 不是回指你表的一個指標。它是一張 單獨的、內部託管的表——有自己的分割區、自己的鍵模式、自己的容量——DynamoDB 透過把寫入非同步複製進它來保持同步。
從 SQL 過來,索引是一棵螺栓固定在同一張物理表上的 B 樹,在同一個事務內部更新。一個 GSI 打破了這兩個假設,而幾乎每一個 GSI 的意外都能追溯到那一個事實。
一個 DynamoDB GSI 是如何儲存的?
一個 DynamoDB GSI 作為一張單獨的、內部託管的表來儲存——有它自己的分割區、鍵模式和容量——而不是作為一個指向基表的指標。DynamoDB 把每次寫入非同步複製進索引,只儲存 GSI 的鍵、基表的鍵,以及任何被 的屬性。
- 一個 GSI 是它自己的表。 它有一個以 GSI 分割區索引鍵為鍵的完全獨立的分割區空間,而不是以基表的。
- 寫入非同步複製。 你的寫入先提交到基表,然後 DynamoDB 在一條後臺路徑上把它扇出到每一個 GSI。
- 只儲存被投影的屬性。 索引持有 GSI 的鍵、基表的鍵,加上你所投影的任何屬性——別的都沒有。
- GSI 鍵不必唯一。 多個基項可以共享同一個 GSI 分割區/排序索引鍵;基表的 是讓它們保持區分的決勝手。
從一個基項開始
拿一個 SaaS 的 審計日誌 來說。一個工作區裡的每一個特權動作都成為一個不可變的事件。基表 WorkspaceEvents 這樣建鍵,讓一個工作區的所有事件都住在一個 裡,按時間排序:
| EventPK | EventSK | actorId | verb | targetRef |
|---|---|---|---|---|
| WS#orbit-9 | TS#2026-06-23T14:02:11Z | USR#kp | ROLE_GRANTED | USR#mara |
EventPK = "WS#orbit-9" 按工作區分割區;EventSK 是一個 ISO 時間戳,所以一次 Query 按時間順序返回一個工作區的事件。那完美地服務了“給我看這個工作區的時間線”。
它服務不了別的任何東西。你沒法問“USR#kp 在每個工作區都做了什麼?”——actorId 不是一個鍵,所以在基表上回答它的唯一方式是一次完整的 Scan。那正是一個 GSI 存在的目的所要新增的訪問模式。
新增一個 GSI,看一張第二張表冒出來
定義一個 GSI,ByActor,它把同樣的事件按誰執行了它們來重新分割區:
ByActor (GSI)
partition key = actorId ("USR#kp")
sort key = EventSK ("TS#2026-06-23T14:02:11Z")
DynamoDB 現在維護了一個第二個物理結構。同一個邏輯事件被儲存 兩次——一次在基表的 WS#orbit-9 分割區裡,再一次在 GSI 的 USR#kp 分割區裡:
| actorId | EventSK | EventPK | verb |
|---|---|---|---|
| USR#kp | TS#2026-06-23T14:02:11Z | WS#orbit-9 | ROLE_GRANTED |
注意隨之搭車過來的東西:基表的鍵(EventPK、EventSK)被自動存進每一個 GSI 項。這就是一次 GSI 命中如何能把你指回完整的項——也是為什麼一個 KEYS_ONLY 索引仍然要花儲存。
GSI 裡實際住著什麼
索引 不 複製整個項。每一個 GSI 條目恰好持有三樣東西,而你只控制第三樣:
| 儲存在 GSI 裡 | 它從哪裡來 | 可選? |
|---|---|---|
| GSI 分割區索引鍵 + 排序索引鍵 | 你命名為 GSI 鍵的那些屬性 | 否 |
| 基表鍵 | 從每一個基項複製而來 | 否 |
| 被投影的屬性 | 你的 Projection 選擇 | 是 |
Projection 是 KEYS_ONLY、INCLUDE(一個命名列表)或 ALL。一次對 GSI 的 Query 只能返回索引裡有的屬性。
要一個沒被投影的屬性,DynamoDB 就 不會 透明地去取它——你為那個欄位拿回的是一片空白。(AWS GSI 文件)
那是被反過來的關係型陷阱:SQL 會為缺失的列 join 回到堆裡。一個 GSI 從不這麼做。 就是整個契約。
一次寫入如何抵達索引
複製是最狠地打破 SQL 直覺的那部分。一次基寫入和它的索引更新 不是 一個原子操作。
當你 PutItem 時,DynamoDB 持久地提交到基表、確認你的寫入,然後 把這次改動傳播到一條更新每一個 GSI 的後臺路徑上。這個確認不等索引。
以下是我們那次審計寫入的事件順序,自上而下:
呼叫方在第三步就拿到它的 200 OK,在第四到第六步完成之前——所以在這個間隙裡對 ByActor 的一次 Query 可能錯過一個嶄新的事件。
那份非同步性是設計使然,不是缺陷:它是 2007 年 Amazon Dynamo 論文 的血脈,那篇論文選擇了可用性而非同步一致性。完整的後果見 為什麼 GSI 是最終一致的。
GSI 鍵不是一個唯一鍵
在 SQL 裡,一個非唯一的次要索引是預設,而一個唯一的是你選擇加入的約束。GSI 恰恰相反:它 永遠 沒有唯一性保證。
來自同一個執行者、時間戳恰好相撞的兩個審計事件,會共享同一個 GSI1PK 和 GSI1SK。DynamoDB 把兩個都存下來——它在內部用基表的主索引鍵來消歧,那個主索引鍵總是被一併攜帶著。
所以一次對一個執行者、在一個瞬間的 GSI Query 可以合法地返回若干個項。如果你像一個 SQL 唯一索引會給你的那樣假設了每鍵一行,那就是那個暗雷。
當你查詢索引時,DynamoDB 運算式構建器 會把 KeyConditionExpression 連同正確轉義的名稱和值一起寫出來——例如匹配某個截止點以來的一個執行者:
KeyConditionExpression: "#a = :actor AND #ts > :since"
ExpressionAttributeNames: { "#a": "actorId", "#ts": "EventSK" }
ExpressionAttributeValues: {
":actor": { "S": "USR#kp" },
":since": { "S": "TS#2026-06-01T00:00:00Z" }
}容量隨索引走,不隨表走
因為 GSI 是它自己的表,它有它 自己的 讀寫容量,與基表分開計費和限流。一次從 ByActor 的讀取消耗 GSI 的讀單位,從不消耗表的。
反向的耦合才是那個會咬人的:每一次基表寫入也寫入索引,而如果 GSI 吸收不了它,它就會給基寫入施加反壓。那個機制有它自己的一篇指南——當一個 GSI 限流基表寫入時。
這也是為什麼一個 GSI 的分割區索引鍵和基表的一樣要緊。一個低基數的 GSI 鍵會把寫入聚集到一個索引分割區上,即便基寫入分佈得完美無缺——一個你透過重新建鍵而親手造出的熱分割區。
GSI 寫入放大(計費)
每一次會投影進 GSI 的基表寫入,在 us-east-1 的按需模式下都要花
基表 WCU + 索引 WCU。一個帶 ALL 投影的 1 KB 項目,通常一共計
約 2 個 WCU——一個給表行,一個給索引副本。KEYS_ONLY 會縮小索引寫入;
ALL 則讓儲存和寫入放大都翻倍。在定價計算器
裡給項目大小和投影建個模。
陷阱與後續步驟
- 別指望拿回沒被投影的屬性。 一次 GSI
Query只返回索引儲存的東西。如果你需要完整的項,就投影它,或者用那被一併攜帶的鍵從基表取它。 - 別把一個 GSI 鍵當作唯一的。 為一次
Query每鍵返回不止一個項做好打算;基表主索引鍵是唯一真正的身份。 - 別在餵給一個 GSI 的寫入剛發生之後就讀它。 那條非同步路徑意味著索引可能還沒顯示你的寫入——當你需要讀你自己寫的時,就讀基表。
- 審慎地為 GSI 的容量定尺寸。 它在讀取上是獨立的,在寫入上是一個隱藏的依賴。
整個遊戲就是選擇能服務你那些模式的鍵形狀——單表設計 把一個 GSI 過載到它們中的許多個之上;GSI 對比 LSI 講的是什麼時候一個本地索引反而合適。
在 DynamoDB 運算式構建器 裡構建並預覽你的 GSI KeyConditionExpression,然後 試試 DynoTable,去檢視一個索引的被投影屬性,並在你自己的表上看著寫入複製進 GSI。