進階閱讀時間 4 分鐘

一個 DynamoDB GSI 在內部如何被儲存

一個 Global Secondary Index 不是回指你資料表的指標。它是一張獨立、 由內部管理的資料表——它自己的分區、它自己的鍵 schema、它自己的 容量——DynamoDB 透過把寫入非同步複製進去,讓它保持同步。

從 SQL 過來,索引是一棵螺栓在同一張實體資料表上的 B-tree,在 同一筆交易內更新。一個 GSI 打破了這兩個假設,而幾乎 每一個 GSI 的意外都能追溯回那一個事實。

一個 DynamoDB GSI 如何被儲存?

一個 DynamoDB GSI 是以一張獨立、由內部管理的資料表來儲存——它有自己的分區、鍵 schema 和容量——而不是回指基底資料表的指標。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)
GSI1PK = actorId   ("USR#kp")
GSI1SK = EventSK   ("TS#2026-06-23T14:02:11Z")

DynamoDB 現在維護著第二個實體結構。同一個邏輯事件被 儲存了兩次——一次在基底資料表的 WS#orbit-9 分區裡,再一次在 GSI 的 USR#kp 分區裡:

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

注意搭便車跟過來的東西:基底資料表的鍵EventPKEventSK)會被 自動儲存在每個 GSI 項目裡。那就是為什麼一個 GSI 命中能把你指回 完整的項目——也是為什麼一個 KEYS_ONLY 索引仍然會耗用儲存。

GSI 裡實際住著什麼

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

儲存在 GSI 裡它從哪裡來可選的?
GSI 分區 + 排序鍵你指名為 GSI 鍵的那些屬性
基底資料表鍵從每個基底項目複製過來
被投影的屬性你的 Projection 選擇

ProjectionKEYS_ONLYINCLUDE(一份指名清單)或 ALL。對 GSI 的一次 Query 只能回傳索引裡有的屬性。

要一個沒被投影的屬性,DynamoDB 不會透明地去取它 ——那個欄位你會什麼都拿不回來。 (AWS GSI 文件

那是關聯式陷阱的反向:SQL 會回到 heap 去 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 Query 只回傳 索引所儲存的東西。如果你需要完整的項目,就投影它、或用那些被 攜帶過來的鍵從基底資料表取它。
  • 別把一個 GSI 鍵當成唯一的。 為一次 Query 回傳每鍵超過一個 項目做好準備;基底主鍵才是唯一真正的身分。
  • 別在餵養 GSI 的那次寫入之後立刻去讀它。 非同步路徑意味著 索引可能還沒顯示你的寫入——當你需要讀到自己剛寫的,就讀 基底資料表。
  • 刻意地為 GSI 的容量定大小。 它在讀取上是獨立的,在寫入上是 一個隱藏的相依。

整場遊戲就是選擇能服務你模式的鍵形狀—— 單一資料表設計把一個 GSI 多載到許多 模式上;GSI 與 LSI涵蓋何時改用一個本地索引才合適。

DynamoDB 運算式建構器裡建立並預覽你的 GSI KeyConditionExpression,然後試用 DynoTable來檢查 一個索引被投影的屬性,並在你自己的資料表上看著寫入複製進 GSI。

已更新