为什么 DynamoDB 中一个 GSI 会限流基表写入
你向表里写入。写入因吞吐量异常而失败 —— 但异常点名的是某个全局二级索引,而不是表。表还有富余的容量。
从 SQL 过来,这说不通:二级索引不可能挡住一个 INSERT。在 DynamoDB 里它能,这套机制叫做 GSI 反压。
为什么 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 | 账户/区域吞吐量边界 |
来源:理解 GSI 写入限流与反压。
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"
ExpressionAttributeNames { "#s": "order_state" }
ExpressionAttributeValues { ":state": { "S": "PENDING" } }
要记住的陷阱
关系型直觉 —— “索引只会让写入稍微慢一点” —— 并不迁移过来。一个 DynamoDB GSI 是一个吞吐量依赖,而非一个被动的结构。把它建得太小,或者挑了一个会扎堆的键,它就会反压它所服务的那张表。
盯着 GSI 的 ConsumedWriteCapacityUnits 和每分区的 ThrottledRequests,而不只是表的。
下一步
- GSI 对比 LSI —— 为什么 GSI 有它自己的容量和一个不同的分区键。
- 单表设计 —— 重载一个 GSI 来服务多种模式,而不增殖出多个热索引。
- Query 对比 Scan —— 何时一个索引不值它那份写入成本。
试用 DynoTable,在一场抢购把它们变红之前,对着你自己的表盯住一个 GSI 的已消耗容量和限流事件。