为什么 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——键结构和项数——并在一场促销把它们变红之前查询你的索引。