为什么 DynamoDB 的 GSI 是最终一致的
你写入一个项,立刻在一个全局二级索引上查询它,却什么也拿不到——尽管写入成功了,并且
一次基础表 GetItem 能正常返回该项。
没有任何东西坏掉。你撞上了 GSI 最令人意外的属性:每一次对 GSI 的读取都是最终一致的。在一次写入 之后有一个短暂的窗口,索引还没赶上。
DynamoDB 的 GSI 是最终一致的吗?
是的——每一次对全局二级索引的读取都是最终一致的,没有办法退出。你的写入先提交到基础表,然后异步传播到索引,所以写入之后立刻发起的查询可能返回陈旧或缺失的行。DynamoDB 不为 GSI 提供 ConsistentRead 标志。
- 一个 GSI 是一张独立的、异步复制的表——你的写入先提交到基础表,然后传播到索引。
- GSI 不存在
ConsistentRead标志。 与基础表不同,你无法强制一次强一致读取来弥合差距。 - 从基础表读你自己的写入,而不是从 GSI。写入之后你已经握着主键了。
- 用条件写入而不是 GSI 查询来强制唯一性。 传播差距把一次"这个被占了吗?"的检查变成一场竞争。
症状:一个"找不到自己"的注册
拿一个用户账户服务的 Members 表。基础表以一个内部 id 设键,但用户用邮箱登录,所以有一个邮箱查找
GSI:
| PK | SK | displayName | |
|---|---|---|---|
| ACC#a1f9c | PROFILE | ada@northwind.test | Ada L. |
| GSI1PK | GSI1SK |
|---|---|
| ada@northwind.test | ACC#a1f9c |
注册流程接连做两件事:PutItem 新成员,然后 Query EmailIndex WHERE GSI1PK = "ada@northwind.test"
来检查没有别人占用那个地址,并加载资料。
把这两个调用相隔几毫秒运行,那个 Query 可能返回零个项。一秒后再做一次,那一行就在了。写入
没有失败——只是索引还没被更新。
为什么会这样:GSI 是异步复制的
一个 GSI 是一张独立的、内部管理的表,有它自己的分区和它自己的键 schema。它不是在与你的基础表 写入相同的事务里维护的。
当你 PutItem 时,DynamoDB 持久地提交到基础表,向你确认写入,然后异步地把变更传播到每个 GSI。
AWS GSI 文档
讲得直白:GSI 只支持最终一致读取。
基础表写入与索引更新之间的传播延迟通常是几分之一秒——但在负载下它不被保证、也不被设上界。把它 当作有上界来设计,就是那个坑。
这不是 bug;这是最初的 Dynamo 设计取舍。2007 年的 Amazon Dynamo 论文 选择了可用性和分区容忍,而非强一致。
GSI 继承了那条血脉。松耦合正是让索引能独立于基础表扩展并保持可写的原因。
200 OK 与"复制变更"之间的缝隙,就是你的索引读取陈旧的那个窗口。没有一致读取标志能合上它。
与基础表不同——那里你传 ConsistentRead = true 来强制一次强一致的 GetItem/Query——GSI 干脆
拒绝那个选项。
一个 LSI 可以被强一致读取,因为它共享基础表的分区;参见 GSI 与 LSI了解这个区分为什么存在。
更隐蔽的坑:陈旧的旧值,而不只是缺失的新值
缺行的情况是显而易见的那个。更安静的 bug 是读到一个陈旧的旧值。
假设 Ada 把她的邮箱从 ada@northwind.test 改成 ada.l@northwind.test。基础表原子地更新,但有那么一刻
GSI 仍然能返回旧的索引条目。
对新值的一次查找落空,而那个被废弃的值仍然解析得到。
更糟:如果你查询 GSI 并基于你读到的东西写回,你可能基于一个已经不存在的值去行动。把任何 GSI 读取都 当作一个可能落后于现实的快照。
绕开它来设计——别和它较劲
传播窗口是真实的,所以解法是架构性的,而不是一个你拨弄的重试旋钮。四种模式,大致按偏好顺序排列:
从基础表读你自己的写入。 写入之后你已经握着主键(
ACC#a1f9c),所以对基础表做一次强一致的GetItem,而不是查询 GSI。GSI 是给另一个访问模式用的——"我有一个邮箱,找出账户"——而不是用来确认你刚做的那次写入。
用一个守卫项而不是 GSI 来强制唯一性。 永远别信任一次 GSI 查询去证明一个邮箱未被占用——传播 差距让那变成一场两个同时注册都可能输掉的竞争。
相反,在一个
TransactWriteItems里写一个以邮箱本身设键 (PK = "EMAIL#ada@northwind.test")的专用唯一性项,配一个attribute_not_exists(PK)的ConditionExpression。强一致的基础表条件,原子地施加,才是真正强制唯一性的东西。
TransactWriteItems: - Put member item (PK = ACC#a1f9c, SK = PROFILE) - Put uniqueness item (PK = EMAIL#ada@northwind.test) ConditionExpression: attribute_not_exists(PK)如果第二个注册竞争同一个地址,它的条件失败,整个事务被拒绝——没有 GSI,没有传播延迟,没有双重占用。
在把那个
attribute_not_exists条件接进代码之前,用 DynamoDB Expression Builder 构建并预览它。在 UX 里容忍延迟。 当 GSI 读取确实是正确的工具时(一个已有用户用邮箱登录),窗口是亚秒级且 无害的——一个已建立的账户早就传播过去了。
把强一致的基础表路径只留给写后立即读的那一刻。
重新查询,别假设。 如果一个工作流必须通过 GSI 观察到一个全新的项,把空结果当作"尚不可见", 而不是"不存在",并在一个短暂的退避之后重新查询。
但优先用模式 1 和 2,它们完全消除了猜测。
自己看那个传播缝隙
建立直觉最快的办法是亲眼看它发生。在 DynoTable 中,你把一个项放入基础表,并立刻在第二个标签页里查询 GSI。
在一张有负载的表上,你会偶尔逮到索引落后于基础数据,然后看着它在下一次刷新时收敛。
用你自己的数据看到那个延迟,会让"从基础表读你自己的写入"这条规则比任何图都记得牢。
陷阱与下一步
- 别把逻辑门控在一次 GSI 的写后读上。 唯一性检查、"我的写入落地了吗"的确认,以及读-改-写循环, 都属于强一致的基础表。
- 别对 GSI 动用
ConsistentRead——它不被允许,会报错。 - 当基础键已经能回答时,别把一个访问模式建模成 GSI。 从主键服务一次读取,你就完全跳过了传播窗口。
挑选正确的键形态是单表设计的全部游戏;懂得何时 Query 胜过 Scan
能让你一开始就远离索引(Query 与 Scan)。
在 DynamoDB Expression Builder 中构建并测试你的唯一性
ConditionExpression。然后试试 DynoTable 实时观看基础表写入传播到一个 GSI,并设计
你的键,让最终一致窗口永远不咬你。