為什麼 DynamoDB 的 GSI 是最終一致的
你寫入一個項,立刻在一個全域次要索引上查詢它,卻什麼也拿不到——儘管寫入成功了,並且
一次基礎表 GetItem 能正常返回該項。
沒有任何東西壞掉。你撞上了 GSI 最令人意外的屬性:每一次對 GSI 的讀取都是最終一致的。在一次寫入 之後有一個短暫的視窗,索引還沒趕上。
DynamoDB 的 GSI 是最終一致的嗎?
是的——每一次對全域次要索引的讀取都是最終一致的,沒有辦法退出。你的寫入先提交到基礎表,然後非同步傳播到索引,所以寫入之後立刻發起的查詢可能返回陳舊或缺失的行。DynamoDB 不為 GSI 提供 ConsistentRead 標誌。
- 一個 GSI 是一張獨立的、非同步複製的表——你的寫入先提交到基礎表,然後傳播到索引。
- GSI 不存在
ConsistentRead標誌。 與基礎表不同,你無法強制一次強一致讀取來彌合差距。 - 從基礎表讀你自己的寫入,而不是從 GSI。寫入之後你已經握著主索引鍵了。
- 用條件寫入而不是 GSI 查詢來強制唯一性。 傳播差距把一次"這個被佔了嗎?"的檢查變成一場競爭。
症狀:一個"找不到自己"的註冊
拿一個使用者帳戶服務的 Members 表。基礎表以一個內部 id 設鍵,但使用者用郵箱登入,所以有一個郵箱查詢
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 可能返回零個項。一秒後再做一次,那一行就在了。寫入
沒有失敗——只是索引還沒被更新。
為什麼會這樣:GSI 是非同步複製的
一個 GSI 是一張獨立的、內部管理的表,有它自己的分割區和它自己的鍵 schema。它不是在與你的基礎表 寫入相同的事務裡維護的。
當你 PutItem 時,DynamoDB 持久地提交到基礎表,向你確認寫入,然後非同步地把變更傳播到每個 GSI。
AWS GSI 文件
講得直白:GSI 只支援最終一致讀取。
基礎表寫入與索引更新之間的傳播延遲通常是幾分之一秒——但在負載下它不被保證、也不被設上界。把它 當作有上界來設計,就是那個坑。
這不是 bug;這是最初的 Dynamo 設計取捨。2007 年的 Amazon Dynamo 論文 選擇了可用性和分割區容忍,而非強一致。
GSI 繼承了那條血脈。松耦合正是讓索引能獨立於基礎表擴充套件並保持可寫的原因。
200 OK 與"複製變更"之間的縫隙,就是你的索引讀取陳舊的那個視窗。沒有一致讀取標誌能合上它。
與基礎表不同——那裡你傳 ConsistentRead = true 來強制一次強一致的 GetItem/Query——GSI 乾脆
拒絕那個選項。
一個 LSI 可以被強一致讀取,因為它共享基礎表的分割區;參見 GSI 與 LSI瞭解這個區分為什麼存在。
GSI 上的讀取成本
GSI 查詢按索引在 us-east-1 的按需 RCU 計費,和其他任何讀取一樣——
每 4 KB 區塊 0.5 個 RCU(最終一致),而且你沒法多花錢買強一致,
因為 ConsistentRead 會被拒絕。傳播延遲是免費的,索引讀取不是。
在定價計算器裡對比基礎表與 GSI 的讀取費率。
更隱蔽的坑:陳舊的舊值,而不只是缺失的新值
缺行的情況是顯而易見的那個。更安靜的 bug 是讀到一個陳舊的舊值。
假設 Ada 把她的郵箱從 ada@northwind.test 改成 ada.l@northwind.test。基礎表原子地更新,但有那麼一刻
GSI 仍然能返回舊的索引條目。
對新值的一次查詢落空,而那個被廢棄的值仍然解析得到。
更糟:如果你查詢 GSI 並基於你讀到的東西寫回,你可能基於一個已經不存在的值去行動。把任何 GSI 讀取都 當作一個可能落後於現實的快照。
繞開它來設計——別和它較勁
傳播視窗是真實的,所以解法是架構性的,而不是一個你撥弄的重試旋鈕。四種模式,大致按偏好順序排列:
從基礎表讀你自己的寫入。 寫入之後你已經握著主索引鍵(
ACC#a1f9c),所以對基礎表做一次強一致的GetItem,而不是查詢 GSI。GSI 是給另一個訪問模式用的——"我有一個郵箱,找出帳戶"——而不是用來確認你剛做的那次寫入。
用一個守衛項而不是 GSI 來強制唯一性。 永遠別信任一次 GSI 查詢去證明一個郵箱未被佔用——傳播 差距讓那變成一場兩個同時註冊都可能輸掉的競爭。
相反,在一個
TransactWriteItems裡寫一個以郵箱本身設鍵 (PK = "EMAIL#ada@northwind.test")的專用唯一性項,配一個attribute_not_exists(PK)的ConditionExpression。強一致的基礎表條件,原子地施加,才是真正強制唯一性的東西。
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 讀取確實是正確的工具時(一個已有使用者用郵箱登入),視窗是亞秒級且 無害的——一個已建立的帳戶早就傳播過去了。
把強一致的基礎表路徑只留給寫後立即讀的那一刻。
重新查詢,別假設。 如果一個工作流必須透過 GSI 觀察到一個全新的項,把空結果當作"尚不可見", 而不是"不存在",並在一個短暫的退避之後重新查詢。
但優先用模式 1 和 2,它們完全消除了猜測。
自己看那個傳播縫隙
建立直覺最快的辦法是親眼看它發生。在 DynoTable 中,你把一個項放入基礎表,並立刻在第二個標籤頁裡查詢 GSI。
在一張有負載的表上,你會偶爾逮到索引落後於基礎資料,然後看著它在下一次重新整理時收斂。
用你自己的資料看到那個延遲,會讓"從基礎表讀你自己的寫入"這條規則比任何圖都記得牢。
陷阱與後續步驟
- 別把邏輯閘控在一次 GSI 的寫後讀上。 唯一性檢查、"我的寫入落地了嗎"的確認,以及讀-改-寫迴圈, 都屬於強一致的基礎表。
- 別對 GSI 動用
ConsistentRead——它不被允許,會報錯。 - 當基礎鍵已經能回答時,別把一個訪問模式建模成 GSI。 從主索引鍵服務一次讀取,你就完全跳過了傳播視窗。
挑選正確的鍵形態是單表設計的全部遊戲;懂得何時 Query 勝過 Scan
能讓你一開始就遠離索引(Query 與 Scan)。
在 DynamoDB Expression Builder 中構建並測試你的唯一性
ConditionExpression。然後試試 DynoTable 實時觀看基礎表寫入傳播到一個 GSI,並設計
你的鍵,讓最終一致視窗永遠不咬你。