中階閱讀時間 3 分鐘

為什麼 DynamoDB 的 GSI 是最終一致的

你寫入一個項,立刻在一個全域次要索引上查詢它,卻什麼也拿不到——儘管寫入成功了,並且 一次基礎表 GetItem 能正常返回該項。

沒有任何東西壞掉。你撞上了 GSI 最令人意外的屬性:每一次對 GSI 的讀取都是最終一致的。在一次寫入 之後有一個短暫的視窗,索引還沒趕上。

DynamoDB 的 GSI 是最終一致的嗎?

是的——每一次對全域次要索引的讀取都是最終一致的,沒有辦法退出。你的寫入先提交到基礎表,然後非同步傳播到索引,所以寫入之後立刻發起的查詢可能返回陳舊或缺失的行。DynamoDB 不為 GSI 提供 ConsistentRead 標誌。

  • 一個 GSI 是一張獨立的、非同步複製的表——你的寫入先提交到基礎表,然後傳播到索引。
  • GSI 不存在 ConsistentRead 標誌。 與基礎表不同,你無法強制一次強一致讀取來彌合差距。
  • 從基礎表讀你自己的寫入,而不是從 GSI。寫入之後你已經握著主索引鍵了。
  • 用條件寫入而不是 GSI 查詢來強制唯一性。 傳播差距把一次"這個被佔了嗎?"的檢查變成一場競爭。

症狀:一個"找不到自己"的註冊

拿一個使用者帳戶服務的 Members 表。基礎表以一個內部 id 設鍵,但使用者用郵箱登入,所以有一個郵箱查詢 GSI:

Members (base table)
PKSKemaildisplayName
ACC#a1f9cPROFILEada@northwind.testAda L.
EmailIndex (GSI)
GSI1PKGSI1SK
ada@northwind.testACC#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 繼承了那條血脈。松耦合正是讓索引能獨立於基礎表擴充套件並保持可寫的原因。

EmailIndex基礎表AppEmailIndex基礎表App非同步傳播PutItem(新成員)200 OK按郵箱查詢0 個項(陳舊)複製變更按郵箱查詢1 個項(已趕上)

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 讀取都 當作一個可能落後於現實的快照。

繞開它來設計——別和它較勁

傳播視窗是真實的,所以解法是架構性的,而不是一個你撥弄的重試旋鈕。四種模式,大致按偏好順序排列:

  1. 從基礎表讀你自己的寫入。 寫入之後你已經握著主索引鍵(ACC#a1f9c),所以對基礎表做一次強一致的 GetItem,而不是查詢 GSI。

    GSI 是給另一個訪問模式用的——"我有一個郵箱,找出帳戶"——而不是用來確認你剛做的那次寫入。

  2. 用一個守衛項而不是 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 構建並預覽它。

  3. 在 UX 裡容忍延遲。 當 GSI 讀取確實是正確的工具時(一個已有使用者用郵箱登入),視窗是亞秒級且 無害的——一個已建立的帳戶早就傳播過去了。

    把強一致的基礎表路徑只留給寫後立即讀的那一刻。

  4. 重新查詢,別假設。 如果一個工作流必須透過 GSI 觀察到一個全新的項,把空結果當作"尚不可見", 而不是"不存在",並在一個短暫的退避之後重新查詢。

    但優先用模式 1 和 2,它們完全消除了猜測。

自己看那個傳播縫隙

建立直覺最快的辦法是親眼看它發生。在 DynoTable 中,你把一個項放入基礎表,並立刻在第二個標籤頁裡查詢 GSI。

在一張有負載的表上,你會偶爾逮到索引落後於基礎資料,然後看著它在下一次重新整理時收斂。

用你自己的資料看到那個延遲,會讓"從基礎表讀你自己的寫入"這條規則比任何圖都記得牢。

陷阱與後續步驟

  • 別把邏輯閘控在一次 GSI 的寫後讀上。 唯一性檢查、"我的寫入落地了嗎"的確認,以及讀-改-寫迴圈, 都屬於強一致的基礎表。
  • 別對 GSI 動用 ConsistentRead——它不被允許,會報錯。
  • 當基礎鍵已經能回答時,別把一個訪問模式建模成 GSI。 從主索引鍵服務一次讀取,你就完全跳過了傳播視窗。

挑選正確的鍵形態是單表設計的全部遊戲;懂得何時 Query 勝過 Scan 能讓你一開始就遠離索引(Query 與 Scan)。

DynamoDB Expression Builder 中構建並測試你的唯一性 ConditionExpression。然後試試 DynoTable 實時觀看基礎表寫入傳播到一個 GSI,並設計 你的鍵,讓最終一致視窗永遠不咬你。

已更新