為什麼 DynamoDB GSI 是最終一致
你寫入一個 item,立刻對一個 Global Secondary Index 查詢它,結果
什麼也沒回來 — 即使寫入成功,且對 base-table 的 GetItem
能正常回傳該 item。
沒有東西壞掉。你撞上了 GSI 最令人意外的特性:每次對 GSI 的 讀取都是最終一致。寫入之後有一段短暫窗口,期間 index 還沒 追上。
DynamoDB GSI 是最終一致的嗎?
是的 — 每次對 Global Secondary Index 的讀取都是最終一致,而且無法選擇退出。你的寫入會先提交到 base table,然後非同步傳播到 index,所以緊接著寫入發出的查詢可能回傳陳舊或缺少的列。DynamoDB 不為 GSI 提供任何 ConsistentRead 旗標。
- 一個 GSI 是一張獨立、非同步複製的表 — 你的寫入先提交 到 base table,然後傳播到 index。
- GSI 沒有
ConsistentRead旗標。 與 base table 不同,你無法 強制一次強一致讀取來縮短這段差距。 - 從 base table 讀你自己的寫入,而非從 GSI。寫入後你已經 握有主鍵。
- 用條件式寫入強制唯一性,而非用 GSI 查詢。 傳播 差距會把一個「這個被佔了嗎?」的檢查變成一場競爭。
症狀:一個「找不到自己」的註冊
拿一張使用者帳戶服務的 Members 表。base table 以一個
內部 id 為 key,但使用者用 email 登入,所以有一個 email 查詢 GSI:
| PK | SK | displayName | |
|---|---|---|---|
| ACC#a1f9c | PROFILE | ada@northwind.test | Ada L. |
| GSI1PK | GSI1SK |
|---|---|
| ada@northwind.test | ACC#a1f9c |
註冊流程接連做兩件事:PutItem 新成員,然後
Query EmailIndex WHERE GSI1PK = "ada@northwind.test" 來檢查沒有別人
佔了那個地址,並載入個人資料。
把那兩個呼叫間隔幾毫秒地跑,那個 Query 可能回傳 零個
item。一秒後再跑一次,列就在那了。寫入沒失敗
— 只是 index 還沒更新。
為什麼會這樣:GSI 是非同步複製的
一個 GSI 是一張 獨立、內部管理的表,有它自己的 partition 與 它自己的 key schema。它不是在跟你 base-table 寫入相同的交易內 維護的。
當你 PutItem,DynamoDB 持久地提交到 base table、確認你的
寫入,然後 非同步地把變更傳播到每個 GSI。AWS
GSI 文件
講得很白:GSI 只支援最終一致讀取。
base-table 寫入與 index 更新之間的傳播延遲,通常是 不到一秒 — 但它 不保證、也不設上限,在負載下尤其如此。 把它當成有上限來設計,就是那個陷阱。
這不是 bug;它是 Dynamo 原本的設計取捨。2007 年的 Amazon Dynamo 論文 選擇了可用性與分割容忍,而非強一致。
GSI 承襲那個血統。鬆散耦合正是讓 index 能獨立於 base table 擴展並保持可寫入的關鍵。
200 OK 與「複製變更」之間的差距,就是你 index
讀取陳舊的窗口。沒有任何 一致讀取旗標 能縮短它。
與 base table 不同 — 在那裡你傳 ConsistentRead = true 去強制一次強
一致的 GetItem/Query — 一個 GSI 直接拒絕那個選項。
一個 LSI 能 被強一致地讀取,因為它共用 base table 的 partition;請見 GSI vs LSI 了解為什麼存在這個區別。
一個更隱微的陷阱:陳舊的 舊 值,不只是缺少的新值
缺列的情況是明顯的那個。更安靜的 bug 是 讀到一個陳舊的 先前值。
假設 Ada 把她的 email 從 ada@northwind.test 改成 ada.l@northwind.test。
base table 原子地更新,但有那麼一刻,GSI 仍可能回傳
舊的 index 條目。
對新值的查詢落空,而被丟棄的值卻仍能解析。
更糟:如果你查詢 GSI 並依你所讀到的去寫回,你可能會對一個 不再存在的值動作。把任何 GSI 讀取都當成可能落後現實的快照。
設計繞過它 — 別跟它對抗
傳播窗口是真的,所以解法是架構性的,而非你撥動的重試 旋鈕。四種模式,大致依偏好順序:
從 base table 讀你自己的寫入。 寫入後你已經 握有主鍵(
ACC#a1f9c),所以對 base table 做一次強一致的GetItem, 而非查詢 GSI。GSI 是給 另一個 存取模式的 — 「我有一個 email,找帳戶」 — 而非用來確認你剛做的那次寫入。
用一個守衛 item 強制唯一性,而非用 GSI。 絕不信任一個 GSI 查詢 去證明一個 email 未被佔用 — 傳播差距讓那變成一場 兩個同時註冊都可能輸的競爭。
改成在一個
TransactWriteItems內,寫一個以 email 本身為 key (PK = "EMAIL#ada@northwind.test")的專屬唯一性 item, 搭配一個attribute_not_exists(PK)的ConditionExpression。原子地套用的、強一致的 base-table 條件,才是真正 強制唯一性的東西。
TransactWriteItems: - Put member item (PK = ACC#a1f9c, SK = PROFILE) - Put uniqueness item (PK = EMAIL#ada@northwind.test) ConditionExpression: attribute_not_exists(PK)如果第二個註冊為同一個地址競爭,它的條件失敗,整個 交易被拒絕 — 沒有 GSI、沒有傳播延遲、沒有重複佔用。
在你把那個
attribute_not_exists條件接進程式碼之前,用 DynamoDB Expression Builder 建立並預覽它。在 UX 中容忍延遲。 當 GSI 讀取確實是對的工具 (以 email 登入一個 既有 使用者)時,窗口是不到一秒且無害的 — 一個既有帳戶早就傳播完了。
把強一致的 base-table 路徑只保留給寫入後立即讀取的那一刻。
重新查詢,別假設。 如果某個工作流程必須透過 GSI 觀察一個全新的 item,把空結果當成「尚未可見」,而非「不存在」,並在 一段短暫退避後重新查詢。
但偏好模式 1 和 2,它們徹底移除了猜測。
自己看那個傳播差距
建立直覺最快的方式,就是看它發生。在 DynoTable 中,你把一個 item 放進 base table,並立刻在第二個分頁查詢 GSI。
在一張有負載的表上,你偶爾會抓到 index 落後 base 資料,然後 看著它在下一次重新整理時收斂。
用你自己的資料看到那個延遲,會讓「從 base table 讀你自己的寫入」這條 規則記得遠比任何圖表都牢。
陷阱與下一步
- 別把邏輯閘控在一次 GSI 寫後讀上。 唯一性檢查、「我的寫入 落地了嗎」確認,以及讀-改-寫迴圈,都屬於強 一致的 base table。
- 別對 GSI 伸手去拿
ConsistentRead— 它不被允許,而且會出錯。 - 別在 base key 已能回答某個存取模式時,把它建模成 GSI。 從主鍵服務一個讀取,你就完全跳過了傳播窗口。
挑對 key 形狀就是
single-table design 的整場遊戲;知道何時 Query 勝過
Scan,能讓你一開始就遠離 index(Query vs Scan)。
在 DynamoDB Expression Builder 中建立並測試你的唯一性 ConditionExpression。然後
試試 DynoTable 即時觀察 base-table 寫入傳播到一個 GSI,
並設計你的 key,讓最終一致窗口永遠咬不到你。