進階閱讀時間 3 分鐘

為什麼 DynamoDB 裡的 GSI 會節流基礎表寫入

你對表寫入。寫入因為一個輸送量例外而失敗——但例外指名的是一個全域次要索引(Global Secondary Index),不是表。表還有空閒容量。

從 SQL 過來,這沒道理:次要索引不可能擋住一個 INSERT。在 DynamoDB 裡它可以,而這個機制叫做 GSI 反壓(back-pressure)

為什麼一個 DynamoDB GSI 會節流基礎表寫入?

DynamoDB 之所以節流基礎表的寫入,是因為每一次寫入也會複寫到每一個 GSI;如果某個 GSI 分割吸收不了它那一份,DynamoDB 就會施加反壓,以防索引永久落後。因此一個容量佈建不足或低基數的 GSI 鍵,就成了你基礎表寫入速率的一道硬上限。

  • 對基礎表的一次寫入,也會寫進每一個 GSI。 如果某個 GSI 吸收不了它那一份,DynamoDB 就會節流基礎表的寫入,以防索引永久落後。(AWS 文件
  • 基礎表均勻並救不了你。 GSI 依它自己的鍵分割。一個低基數的 GSI 鍵(像 status)就算基礎表寫入分佈得完美無瑕,仍會造出一個熱索引分割。
  • 例外在受害者是誰這件事上撒謊。 ResourceArn 指向 GSI;實際被節流的操作,是你對表的寫入。
  • 解法是容量或鍵設計,不是重試迴圈——調高 GSI 的輸送量,或挑一個能分散的 GSI 分割鍵。

一次寫入如何觸及索引

對基礎表的一次 PutItem,不只是一次寫入。DynamoDB 會以最終一致的模型,把該項目的投影屬性非同步複寫進每一個 GSI。一次邏輯寫入扇出成 N 次實體寫入——表加上每一個索引。

那份複寫既不免費,也不可選。GSI 必須跟得上,否則每一次操作都讓索引離表更遠一點。

為了阻止那種漂移,DynamoDB 施加反壓:它節流來源寫入,好讓索引永遠不會無上限地變舊。

所以 GSI 的寫入容量,是你基礎表寫入速率的一道硬上限——即使你從來不直接寫進 GSI。

一個實作範例:訂單表

假設你經營一張訂單表。基礎項目:

fieldvaluenote
PK"CUST#8841"partition key
SK"ORD#2026-06-23#A7"sort key
order_state"PROCESSING"
warehouse"EU-MAD-2"
total_cents4990

基礎表寫入很健康。CUST#... 基數高,所以訂單寫入均勻散布在各個基礎分割上。沒有熱鍵,容量充足。

現在你加一個 GSI 來回答「給我某個狀態下的每一筆訂單」:

GSI: orders-by-state
fieldvaluenote
GSI-PKorder_state"PENDING" | "PROCESSING" | "SHIPPED" | "CANCELED"
GSI-SKSK

四種可能的分割鍵值。在一場限時搶購中,幾乎每一筆新訂單都落到 order_state = "PENDING"。這些寫入每一筆都打到同一個 GSI 分割。

那個分割有每分割的輸送量上限,而你剛把整場寫入風暴對準了它。

基礎表沒事。PENDING 這個 GSI 分割著火了。DynamoDB 節流基礎表的 PutItem 來保護索引。

咬你的那條流程

這就是反壓路徑——基礎寫入均衡,索引寫入集中:

PutItemorder_state=PENDING基礎表 CUST# 分散非同步複寫 GSIGSI 分割PENDING(熱)超過分割上限節流基礎寫入

節流向後傳遞:一個熱 GSI 分割回絕了餵養它的那筆基礎表寫入。

讀例外,別憑直覺

例外的類型會精確告訴你撞上了哪一道上限。ResourceArn 指名 GSI;被節流的操作仍然是表寫入。

模式原因碼用完了什麼
佈建IndexWriteProvisionedThroughputExceededGSI 佈建的寫入容量
佈建IndexWriteKeyRangeThroughputExceeded單一熱 GSI 分割
隨需IndexWriteMaxOnDemandThroughputExceededGSI 設定的隨需上限
隨需IndexWriteAccountLimitExceeded帳號/區域的輸送量邊界

來源:Understanding GSI write throttling and back pressure

KeyRange 那個原因是上面熱分割情況的破綻:整體 GSI 容量看起來可以正常,而某一個鍵範圍卻已飽和。

如何修正

給 GSI 空間。 最單純的成因是容量佈建不足。GSI 有它自己的讀取與寫入容量,跟表完全分開——見 GSI 與 LSI

如果你給表佈建得很大方,卻把 GSI 留得很瘦,就調高 GSI 的寫入容量(或它的隨需上限)。

修正分割鍵。 容量救不了一個低基數的鍵——你沒辦法靠佈建去蓋過單一熱分割。挑一個能分散的 GSI 分割鍵。

把它組合起來:order_state#shard,其中 shard 是一個小的隨機後綴,或把日期摺進去(PENDING#2026-06-23)。寫入散布到各個分割,而你仍然能用查詢各個分片的方式 Query 某個狀態。

投影更少的屬性。 每一次 GSI 寫入都會複製投影的屬性。KEYS_ONLY 或一個精簡的 INCLUDE 投影,意味著更小的索引寫入,比 ALL 造成的壓力更小。不要去投影你永遠不會從索引讀出的東西。

如果 GSI 只是為了報表,就把它砍掉。 如果「按狀態看訂單」是偶爾的管理性提問,而不是熱路徑,那麼一個帶過濾條件的週期性掃描,可能勝過一個永遠發熱的索引——拿它跟 Query 與 Scan 權衡。

當你確實要查那個索引時,Expression Builder 會替你寫出 KeyConditionExpression——例如 #s = :state AND begins_with(SK, :prefix)——並把名稱和值正確跳脫:

KeyConditionExpression     "#s = :state"
ExpressionAttributeNames   { "#s": "order_state" }
ExpressionAttributeValues  { ":state": { "S": "PENDING" } }

要記住的陷阱

那個關聯式直覺——「索引只會稍微拖慢寫入」——並不適用於此。一個 DynamoDB GSI 是一個輸送量依賴,不是一個被動結構。把它做得太小,或挑一個會擠成一團的鍵,它就會反壓它所服務的那張表。

去盯 GSI 每個分割的 ConsumedWriteCapacityUnitsThrottledRequests,別只盯表的。

下一步

  • GSI 與 LSI——為什麼 GSI 有它自己的容量,以及一個不同的分割鍵。
  • 單表設計——超載一個 GSI 來服務多種模式,而不會讓熱索引倍增。
  • Query 與 Scan——什麼時候一個索引不值它的寫入成本。

試用 DynoTable,在一場搶購把它們變紅之前,對你自己的表觀察某個 GSI 的耗用容量與節流事件。

已更新