為什麼 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。
一個例項:訂單表
假設你營運一張訂單表。基本項如下:
| field | value | note |
|---|---|---|
| PK | "CUST#8841" | partition key |
| SK | "ORD#2026-06-23#A7" | sort key |
| order_state | "PROCESSING" | |
| warehouse | "EU-MAD-2" | |
| total_cents | 4990 |
基表寫入很健康。CUST#... 基數很高,所以訂單寫入均勻分散到各個基表分割區。沒有熱鍵,容量充裕。
現在你加一個 GSI,用來回答“給我看某個狀態下的所有訂單”:
| field | value | note |
|---|---|---|
| GSI-PK | order_state | "PENDING" | "PROCESSING" | "SHIPPED" | "CANCELED" |
| GSI-SK | SK |
只有四個可能的分割區索引鍵值。在一次限時搶購期間,幾乎每個新訂單都落在 order_state = "PENDING"。這些寫入每一次都命中同一個 GSI 分割區。
那個分割區有它自己的單分割區吞吐量上限,而你恰好把整場寫入風暴都對準了它。
基表沒事。PENDING 那個 GSI 分割區著火了。DynamoDB 限流基表的 PutItem 來保護索引。
咬你一口的流向
這就是反壓的路徑——基表寫入均衡,索引寫入集中:
限流是往回傳的:一個過熱的 GSI 分割區,拒絕了餵給它的那次基表寫入。
讀異常,別憑直覺
異常型別會準確告訴你撞到了哪個上限。ResourceArn 指向 GSI;被限流的操作仍然是表寫入。
| 模式 | 原因程式碼 | 耗盡的是什麼 |
|---|---|---|
| 預置 | IndexWriteProvisionedThroughputExceeded | GSI 的預置寫入容量 |
| 兩者 | IndexWriteKeyRangeThroughputExceeded | 單個過熱的 GSI 分割區 |
| 按需 | IndexWriteMaxOnDemandThroughputExceeded | GSI 配置的按需最大上限 |
| 按需 | 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 維度上的 ConsumedWriteCapacityUnits 和 WriteThrottleEvents,而不只是表的,並用 Contributor Insights 找出熱鍵。
後續步驟
- GSI 對比 LSI —— 為什麼 GSI 有它自己的容量和一個不同的分割區索引鍵。
- 單表設計 —— 用一個 GSI 承載多種模式,而不必讓熱索引成倍增加。
- Query 對比 Scan —— 什麼時候一個索引值不回它的寫入成本。
試試 DynoTable,檢視你表上的每一個 GSI——鍵結構和項數——並在一場促銷把它們變紅之前查詢你的索引。