進階閱讀時間 3 分鐘

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

你向表寫入。寫入因吞吐量異常而失敗——但異常指向的是一個全域次要索引(GSI),而不是這張表。表本身還有富餘容量。

從 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 只是用來做報表,就把它砍掉。如果“按狀態查訂單”只是偶爾的管理員提問,而不是熱路徑,那麼一次帶篩選的週期性 scan 可能勝過一個永久過熱的索引——把它和 Query 對比 Scan 權衡一下。

當你確實要查詢那個索引時,運算式構建器會替你寫好 KeyConditionExpression——例如 #s = :state AND begins_with(SK, :prefix)——並把名稱和值正確轉義:

KeyConditionExpression     "#s = :state AND begins_with(SK, :prefix)"
ExpressionAttributeNames   { "#s": "order_state" }
ExpressionAttributeValues  { ":state": { "S": "PENDING" }, ":prefix": { "S": "ORD#2026-06-23" } }

需要記住的陷阱

那種關係型直覺——“索引只會讓寫入稍微慢一點”——並不適用於這裡。DynamoDB 的 GSI 是一個吞吐量依賴,而非一個被動的結構。把它配小了,或者選了一個會扎堆的鍵,它就會反壓它所服務的那張表。

要盯著 GSI 維度上的 ConsumedWriteCapacityUnitsWriteThrottleEvents,而不只是表的,並用 Contributor Insights 找出熱鍵。

後續步驟

  • GSI 對比 LSI —— 為什麼 GSI 有它自己的容量和一個不同的分割區索引鍵。
  • 單表設計 —— 用一個 GSI 承載多種模式,而不必讓熱索引成倍增加。
  • Query 對比 Scan —— 什麼時候一個索引值不回它的寫入成本。

試試 DynoTable,檢視你表上的每一個 GSI——鍵結構和項數——並在一場促銷把它們變紅之前查詢你的索引。

已更新