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

更新于