進階閱讀時間 3 分鐘

一個 DynamoDB GSI 在內部是如何儲存的

一個 不是回指你表的一個指標。它是一張 單獨的、內部託管的表——有自己的分割區、自己的鍵模式、自己的容量——DynamoDB 透過把寫入非同步複製進它來保持同步。

從 SQL 過來,索引是一棵螺栓固定在同一張物理表上的 B 樹,在同一個事務內部更新。一個 GSI 打破了這兩個假設,而幾乎每一個 GSI 的意外都能追溯到那一個事實。

一個 DynamoDB GSI 是如何儲存的?

一個 DynamoDB GSI 作為一張單獨的、內部託管的表來儲存——有它自己的分割區、鍵模式和容量——而不是作為一個指向基表的指標。DynamoDB 把每次寫入非同步複製進索引,只儲存 GSI 的鍵、基表的鍵,以及任何被 的屬性。

  • 一個 GSI 是它自己的表。 它有一個以 GSI 分割區索引鍵為鍵的完全獨立的分割區空間,而不是以基表的。
  • 寫入非同步複製。 你的寫入先提交到基表,然後 DynamoDB 在一條後臺路徑上把它扇出到每一個 GSI。
  • 只儲存被投影的屬性。 索引持有 GSI 的鍵、基表的鍵,加上你所投影的任何屬性——別的都沒有。
  • GSI 鍵不必唯一。 多個基項可以共享同一個 GSI 分割區/排序索引鍵;基表的 是讓它們保持區分的決勝手。

從一個基項開始

拿一個 SaaS 的 審計日誌 來說。一個工作區裡的每一個特權動作都成為一個不可變的事件。基表 WorkspaceEvents 這樣建鍵,讓一個工作區的所有事件都住在一個 裡,按時間排序:

WorkspaceEvents (base table)
EventPKEventSKactorIdverbtargetRef
WS#orbit-9TS#2026-06-23T14:02:11ZUSR#kpROLE_GRANTEDUSR#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 分割區裡:

ByActor (GSI) — its own partition space
actorIdEventSKEventPKverb
USR#kpTS#2026-06-23T14:02:11ZWS#orbit-9ROLE_GRANTED

注意隨之搭車過來的東西:基表的鍵EventPKEventSK)被自動存進每一個 GSI 項。這就是一次 GSI 命中如何能把你指回完整的項——也是為什麼一個 KEYS_ONLY 索引仍然要花儲存。

GSI 裡實際住著什麼

索引 複製整個項。每一個 GSI 條目恰好持有三樣東西,而你只控制第三樣:

儲存在 GSI 裡它從哪裡來可選?
GSI 分割區索引鍵 + 排序索引鍵你命名為 GSI 鍵的那些屬性
基表鍵從每一個基項複製而來
被投影的屬性你的 Projection 選擇

ProjectionKEYS_ONLYINCLUDE(一個命名列表)或 ALL。一次對 GSI 的 Query 只能返回索引裡有的屬性。

要一個沒被投影的屬性,DynamoDB 就 不會 透明地去取它——你為那個欄位拿回的是一片空白。(AWS GSI 文件

那是被反過來的關係型陷阱:SQL 會為缺失的列 join 回到堆裡。一個 GSI 從不這麼做。 就是整個契約。

一次寫入如何抵達索引

複製是最狠地打破 SQL 直覺的那部分。一次基寫入和它的索引更新 不是 一個原子操作。

當你 PutItem 時,DynamoDB 持久地提交到基表、確認你的寫入,然後 把這次改動傳播到一條更新每一個 GSI 的後臺路徑上。這個確認不等索引。

以下是我們那次審計寫入的事件順序,自上而下:

PutItemWS#orbit-9 事件提交到基表分割區200 OK返回呼叫方非同步路徑:提取 GSI路由到 ByActor分割區 USR#kp寫入投影的屬性

呼叫方在第三步就拿到它的 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。

已更新