一个 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 集合
里,按时间排序:
| EventPK | EventSK | actorId | verb | targetRef |
|---|---|---|---|---|
| WS#orbit-9 | TS#2026-06-23T14:02:11Z | USR#kp | ROLE_GRANTED | USR#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 分区里:
| GSI1PK | GSI1SK | EventPK | EventSK | verb |
|---|---|---|---|---|
| USR#kp | TS#2026-06-23T14:02:11Z | WS#orbit-9 | TS#2026-06-23T14:02:11Z | ROLE_GRANTED |
注意有什么一起搭车来了:基表的 key(EventPK、EventSK)被自动存进每个 GSI item。
这就是一个 GSI 命中如何能把你指回完整 item 的原因——也是为什么一个
KEYS_ONLY索引仍然花存储。
GSI 里实际住着什么
索引 不会 复制整个 item。每个 GSI 条目恰好装三样东西,而你只控制第三样:
| 存在 GSI 里的 | 它从哪来 | 可选? |
|---|---|---|
| GSI partition + sort key | 你命名为 GSI key 的属性 | 否 |
| 基表 key | 从每个基表 item 复制 | 否 |
| 被投影的属性 | 你的 Projection 选择 | 是 |
Projection 是 KEYS_ONLY、INCLUDE(一个具名列表)或 ALL。一次对 GSI 的 Query 只能
返回索引里有的属性。
要一个没被投影的,DynamoDB 不会 透明地去取它——那个字段你拿不到任何东西回来。 (AWS GSI 文档)
那是反过来的关系型陷阱:SQL 会回到堆里去 join 那个缺失的列。一个 GSI 从不这么干。投影就是 全部的合约。
一次写入如何到达索引
复制是最狠地打破 SQL 直觉的部分。一次基表写入和它的索引更新 不是 一个原子操作。
当你 PutItem,DynamoDB 持久地提交到基表,确认你的写入,然后 把这个改动传播到一条更新
每个 GSI 的后台路径上。这个确认不等索引。
下面是我们这次审计写入的事件顺序,从上到下:
调用方在第三步拿到它的 200 OK,早于第四到第六步完成——所以在这个空档里一次对 ByActor
的 Query 可能错过一个崭新的事件。
那种异步是设计如此,不是缺陷:它是 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。