高级阅读约 3 分钟

为什么 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。

一个实战示例:订单表

假设你运营一张订单表。基项:

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账户/区域吞吐量边界

来源:理解 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 的已消耗容量和限流事件。

更新于