高级阅读约 4 分钟

一个 DynamoDB GSI 在内部如何被存储

一个 Global Secondary Index 不是回到你表里的指针。它是一张 独立的、由内部管理的表—— 它自己的分区、它自己的 key schema、它自己的容量——DynamoDB 靠把写入异步地复制进它来保持 同步。

从 SQL 过来,一个索引是螺在同一张物理表上的 B 树,在同一个事务里更新。一个 GSI 打破了这 两个假设,而几乎每一个 GSI 的意外都能追回到这一个事实上。

一个 DynamoDB GSI 如何被存储?

一个 DynamoDB GSI 被存储为一张独立的、由内部管理的表——它自己的分区、key schema 和容量——而不是指向基表的指针。DynamoDB 把每一次写入异步地复制进索引,只存储 GSI key、基表 key,以及任何被投影的属性。

  • 一个 GSI 是它自己的表。 它有一个完全独立的分区空间,以 GSI 的 partition key 为 key, 而不是基表的。
  • 写入异步复制。 你的写入先提交到基表,然后 DynamoDB 在一条后台路径上把它扇出到每个 GSI。
  • 只有被投影的属性才被存储。 索引装着 GSI key、基表 key,外加你投影的任何属性——别的 没有。
  • GSI key 不必唯一。 多个基表 item 可以共享一个 GSI partition/sort key;基表主键是把 它们区分开的决胜项。

从一个基表 item 开始

拿一个 SaaS 审计日志 来说。一个 workspace 里每一个特权动作都变成一个不可变的事件。 基表 WorkspaceEvents 的 key 这样设计,让一个 workspace 的所有事件都住在一个 item 集合 里,按时间排序:

WorkspaceEvents (基表)
EventPKEventSKactorIdverbtargetRef
WS#orbit-9TS#2026-06-23T14:02:11ZUSR#kpROLE_GRANTEDUSR#mara

EventPK = "WS#orbit-9" 按 workspace 分区;EventSK 是一个 ISO 时间戳,所以一次 Query 按时间顺序返回一个 workspace 的事件。那把"给我看这个 workspace 的时间线"服务得完美。

它服务不了别的。你问不出"USR#kp 在每个 workspace 里都做了什么?"——actorId 不是 key, 所以在基表上回答它的唯一办法是一次完整的 Scan。那正是一个 GSI 存在去补上的访问模式。

加一个 GSI,看着第二张表出现

定义一个 GSI,ByActor,把同样的事件按谁执行的来重新分区:

ByActor (GSI)
GSI1PK = actorId   ("USR#kp")
GSI1SK = EventSK   ("TS#2026-06-23T14:02:11Z")

DynamoDB 现在维护着第二个物理结构。同一个逻辑事件被存了 两次——一次在基表的 WS#orbit-9 分区里,又一次在 GSI 的 USR#kp 分区里:

ByActor (GSI) — 它自己的分区空间
GSI1PKGSI1SKEventPKEventSKverb
USR#kpTS#2026-06-23T14:02:11ZWS#orbit-9TS#2026-06-23T14:02:11ZROLE_GRANTED

注意有什么一起搭车来了:基表的 keyEventPKEventSK)被自动存进每个 GSI item。 这就是一个 GSI 命中如何能把你指回完整 item 的原因——也是为什么一个 KEYS_ONLY索引仍然花存储。

GSI 里实际住着什么

索引 不会 复制整个 item。每个 GSI 条目恰好装三样东西,而你只控制第三样:

存在 GSI 里的它从哪来可选?
GSI partition + sort key你命名为 GSI key 的属性
基表 key从每个基表 item 复制
被投影的属性你的 Projection 选择

ProjectionKEYS_ONLYINCLUDE(一个具名列表)或 ALL。一次对 GSI 的 Query 只能 返回索引里有的属性。

要一个没被投影的,DynamoDB 不会 透明地去取它——那个字段你拿不到任何东西回来。 (AWS GSI 文档

那是反过来的关系型陷阱:SQL 会回到堆里去 join 那个缺失的列。一个 GSI 从不这么干。投影就是 全部的合约。

一次写入如何到达索引

复制是最狠地打破 SQL 直觉的部分。一次基表写入和它的索引更新 不是 一个原子操作。

当你 PutItem,DynamoDB 持久地提交到基表,确认你的写入,然后 把这个改动传播到一条更新 每个 GSI 的后台路径上。这个确认不等索引。

下面是我们这次审计写入的事件顺序,从上到下:

PutItemWS#orbit-9 事件提交到基表分区200 OK给调用方异步路径:提取 GSI key路由到 ByActor分区 USR#kp写入被投影的属性

调用方在第三步拿到它的 200 OK,早于第四到第六步完成——所以在这个空档里一次对 ByActorQuery 可能错过一个崭新的事件。

那种异步是设计如此,不是缺陷:它是 2007 年 Amazon Dynamo 论文的 血脉,那篇论文选了可用性而非同步一致性。完整的后果住在 为什么一个 GSI 是最终一致的里。

GSI key 不是一个唯一 key

在 SQL 里,一个非唯一的二级索引是默认,而一个唯一的是你选择加入的约束。一个 GSI 正相反: 它 从不 有唯一性保证。

来自同一个 actor、时间戳撞上的两个审计事件会共享同一个 GSI1PK GSI1SK。DynamoDB 把两个都存——它在内部靠基表的主键来区分它们,那个主键总是一起被带着。

所以一次对一个 actor 在一个瞬间的 GSI Query 可以合法地返回好几个 item。如果你像一个 SQL 唯一索引会给你的那样假设了每个 key 一行,那就是自坑。

当你查询索引时, DynamoDB 表达式构建器KeyConditionExpression 连同 被正确转义的 names 和 values 一起写出来——例如匹配一个 actor 自某个截止时间以来:

KeyConditionExpression: "#a = :actor AND #ts > :since"
ExpressionAttributeNames:  { "#a": "actorId", "#ts": "EventSK" }
ExpressionAttributeValues: {
  ":actor": { "S": "USR#kp" },
  ":since": { "S": "TS#2026-06-01T00:00:00Z" }
}

容量住在索引上,不在表上

因为 GSI 是它自己的表,它有它 自己的 读取和写入容量,与基表分开计费、分开限流。一次对 ByActor 的读取消耗 GSI 的读取单元,从不消耗表的。

反向的耦合才是咬人的那个:每一次基表写入也写索引,而如果 GSI 吸收不了那个写入,它就给基表 写入施加背压。那个机制有它自己的指南—— 一个 GSI 何时限流基表写入

这也是为什么一个 GSI 的 partition key 和基表的一样重要。一个低基数的 GSI key 即使在基表 写入完美铺开时也会把写入扎堆到一个索引分区上——一个你靠重新设 key 造出来的热分区。

坑与下一步

  • 别指望拿回没被投影的属性。 一次 GSI Query 只返回索引存着的东西。如果你需要完整 item,要么投影它,要么靠那些一起被带着的 key 从基表取。
  • 别把一个 GSI key 当唯一的。 为一次 Query 每个 key 返回不止一个 item 做好准备;基表 主键才是唯一真正的身份。
  • 别在喂给一个 GSI 的写入之后立刻读它。 异步路径意味着索引可能还没显示你的写入——当你 需要读到自己刚写的,去读基表。
  • 刻意给 GSI 的容量定大小。 它在读取上是独立的,在写入上是一个隐藏的依赖。

整个游戏就是选择服务你的模式的 key 形态—— 单表设计把一个 GSI 重载到许多模式上; GSI vs LSI讲一个本地索引何时反而合适。

DynamoDB 表达式构建器里构建并预览你的 GSI KeyConditionExpression,然后 试用 DynoTable,去检查一个索引被投影的属性, 并在你自己的表上看着写入复制进 GSI。

更新于