一个 DynamoDB GSI 在内部是如何存储的
一个 不是回指你表的一个指针。它是一张 单独的、内部托管的表——有自己的分区、自己的键模式、自己的容量——DynamoDB 通过把写入异步复制进它来保持同步。
从 SQL 过来,索引是一棵螺栓固定在同一张物理表上的 B 树,在同一个事务内部更新。一个 GSI 打破了这两个假设,而几乎每一个 GSI 的意外都能追溯到那一个事实。
一个 DynamoDB GSI 是如何存储的?
一个 DynamoDB GSI 作为一张单独的、内部托管的表来存储——有它自己的分区、键模式和容量——而不是作为一个指向基表的指针。DynamoDB 把每次写入异步复制进索引,只存储 GSI 的键、基表的键,以及任何被 的属性。
- 一个 GSI 是它自己的表。 它有一个以 GSI 分区键为键的完全独立的分区空间,而不是以基表的。
- 写入异步复制。 你的写入先提交到基表,然后 DynamoDB 在一条后台路径上把它扇出到每一个 GSI。
- 只存储被投影的属性。 索引持有 GSI 的键、基表的键,加上你所投影的任何属性——别的都没有。
- GSI 键不必唯一。 多个基项可以共享同一个 GSI 分区/排序键;基表的 是让它们保持区分的决胜手。
从一个基项开始
拿一个 SaaS 的 审计日志 来说。一个工作区里的每一个特权动作都成为一个不可变的事件。基表 WorkspaceEvents 这样建键,让一个工作区的所有事件都住在一个 里,按时间排序:
| EventPK | EventSK | actorId | verb | targetRef |
|---|---|---|---|---|
| WS#orbit-9 | TS#2026-06-23T14:02:11Z | USR#kp | ROLE_GRANTED | USR#mara |
EventPK = "WS#orbit-9" 按工作区分区;EventSK 是一个 ISO 时间戳,所以一次 Query 按时间顺序返回一个工作区的事件。那完美地服务了“给我看这个工作区的时间线”。
它服务不了别的任何东西。你没法问“USR#kp 在每个工作区都做了什么?”——actorId 不是一个键,所以在基表上回答它的唯一方式是一次完整的 Scan。那正是一个 GSI 存在的目的所要添加的访问模式。
添加一个 GSI,看一张第二张表冒出来
定义一个 GSI,ByActor,它把同样的事件按谁执行了它们来重新分区:
ByActor (GSI)
partition key = actorId ("USR#kp")
sort key = EventSK ("TS#2026-06-23T14:02:11Z")
DynamoDB 现在维护了一个第二个物理结构。同一个逻辑事件被存储 两次——一次在基表的 WS#orbit-9 分区里,再一次在 GSI 的 USR#kp 分区里:
| actorId | EventSK | EventPK | verb |
|---|---|---|---|
| USR#kp | TS#2026-06-23T14:02:11Z | WS#orbit-9 | ROLE_GRANTED |
注意随之搭车过来的东西:基表的键(EventPK、EventSK)被自动存进每一个 GSI 项。这就是一次 GSI 命中如何能把你指回完整的项——也是为什么一个 KEYS_ONLY 索引仍然要花存储。
GSI 里实际住着什么
索引 不 复制整个项。每一个 GSI 条目恰好持有三样东西,而你只控制第三样:
| 存储在 GSI 里 | 它从哪里来 | 可选? |
|---|---|---|
| GSI 分区键 + 排序键 | 你命名为 GSI 键的那些属性 | 否 |
| 基表键 | 从每一个基项复制而来 | 否 |
| 被投影的属性 | 你的 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 键不是一个唯一键
在 SQL 里,一个非唯一的二级索引是默认,而一个唯一的是你选择加入的约束。GSI 恰恰相反:它 永远 没有唯一性保证。
来自同一个执行者、时间戳恰好相撞的两个审计事件,会共享同一个 GSI1PK 和 GSI1SK。DynamoDB 把两个都存下来——它在内部用基表的主键来消歧,那个主键总是被一并携带着。
所以一次对一个执行者、在一个瞬间的 GSI Query 可以合法地返回若干个项。如果你像一个 SQL 唯一索引会给你的那样假设了每键一行,那就是那个暗雷。
当你查询索引时,DynamoDB 表达式构建器 会把 KeyConditionExpression 连同正确转义的名称和值一起写出来——例如匹配某个截止点以来的一个执行者:
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 的分区键和基表的一样要紧。一个低基数的 GSI 键会把写入聚集到一个索引分区上,即便基写入分布得完美无缺——一个你通过重新建键而亲手造出的热分区。
GSI 的写入放大(要计费)
在 us-east-1 的按需模式下,每一次会投影进 GSI 的基表写入都要花 基表 WCU + 索引 WCU。一个采用 ALL 投影的 1 KB 项通常一共计费 ~2 WCU——一次给表里那一行,一次给索引里的副本。KEYS_ONLY 会缩小索引写入;ALL 则让存储和写入放大都翻倍。在定价计算器里把项大小和投影一起建模。
陷阱与后续步骤
- 别指望拿回没被投影的属性。 一次 GSI
Query只返回索引存储的东西。如果你需要完整的项,就投影它,或者用那被一并携带的键从基表取它。 - 别把一个 GSI 键当作唯一的。 为一次
Query每键返回不止一个项做好打算;基表主键是唯一真正的身份。 - 别在喂给一个 GSI 的写入刚发生之后就读它。 那条异步路径意味着索引可能还没显示你的写入——当你需要读你自己写的时,就读基表。
- 审慎地为 GSI 的容量定尺寸。 它在读取上是独立的,在写入上是一个隐藏的依赖。
整个游戏就是选择能服务你那些模式的键形状——单表设计 把一个 GSI 重载到它们中的许多个之上;GSI 对比 LSI 讲的是什么时候一个本地索引反而合适。
在 DynamoDB 表达式构建器 里构建并预览你的 GSI KeyConditionExpression,然后 试试 DynoTable,去检视一个索引的被投影属性,并在你自己的表上看着写入复制进 GSI。