DynamoDB 稀疏索引
稀疏索引是一种只保存那些携带其键属性的项的二级 索引 —— 于是一张巨大表中一个又小又热的子集,就变成了它自己的 预过滤、可直接查询的集合。
你有数百万行,但你整天运行的那个查询只触及很小的一片:那些 未关闭的支持工单、未付款的发票、被标记为待审查的账户。
过滤那一片仍然要扫描整张表,并为每一次读取向你计费。而 稀疏索引则让索引本身变小。
什么是 DynamoDB 中的稀疏索引?
稀疏索引是一种只保存那些携带其键属性的项的二级索引。因为 DynamoDB 会跳过任何缺失该键的项,所以你发明一个只有你想要的项才会写入的键 —— 未关闭的工单、未付款的发票 —— 于是索引就恰好成为那个子集。查询随后只读取它,无过滤器,无浪费的读取容量。
- 二级索引只索引那些拥有其键的项。 在某个项上省略这个键,它就 永不进入索引 —— 没有占位符,没有 null 行。
- 所以你发明一个只有你想要的项才携带的键。 在你要查询的项上写入它, 在其余项上移除它。索引就恰好成为那个子集。
- 查询只读取那个子集,无过滤器。 它的大小追随那个又小又热的 集合,而不是表的总量。
REMOVE才是杠杆,而不是清空。 一个空字符串不是有效的索引 键 —— DynamoDB 会以 ValidationException 拒绝整个写入 —— 所以你必须 删除这个属性。
问题:过滤并不省读取
从 SQL 过来,你以为一个 WHERE 子句会收窄工作量。DynamoDB 的
FilterExpression 并不会。它在项被读取之后才运行,而不是之前。
按 AWS 开发者指南 所述,"无论是否存在过滤表达式,一次 Query 都消耗相同数量的读取容量" —— 你为检查过的每一个项付费,然后把不匹配的 丢弃。
所以如果你 500 万张工单里有 50 张是未关闭的,一次带过滤的 Query/Scan 会
翻遍数百万张,才交给你那 50 张。
那正是每一个"为什么我的 scan 这么贵"帖子背后的陷阱; query 与 scan 对比有完整的成本全景。
稀疏索引通过让索引本身变小来绕开它。
稀疏性是如何运作的
一个二级索引只索引那些实际拥有该索引键属性的 项。
AWS 关于稀疏索引的文档 把这一点讲得很清楚:只有当一个项携带索引的键属性时,DynamoDB 才会把它写入二级索引,所以一个建立在很少被设置的属性上的索引 自然会保持很小。
在某个项上缺失 GSI 的分区键(或排序键),DynamoDB 就干脆不 把它写入索引。没有占位符,没有 null 行 —— 这个项是缺席的。
那种"默认缺席"正是整个诀窍所在。别去索引一个_每个_项都
携带的 status 属性。发明一个只有你想要查询的项才根本
携带的属性。
索引于是就变成一份恰好由那些项组成的干净列表,而针对它的一次
Query 只读取它们 —— 无过滤器,无浪费的容量。
设想基表在给索引供料,只有携带该键的项才跨越过去:
只有带键的(未关闭的)项才复制到索引;已关闭的项从不进入它。
这与单表设计是同一种塑键 心法:键是你为某个特定访问模式打造的工具,而不是你数据的 忠实镜像。
一个完整示例:"只看未关闭工单"
设想一张支持工单表。基表被建键用来按 id 取一张工单, 以及列出某个客户的工单:
| PK | SK | attributes |
|---|---|---|
| TICKET#a91f | DETAIL | subject, body, priority, openState |
| CUSTOMER#88 | TICKET#a91f | subject, priority, openState |
在这张表的整个生命周期里,大多数工单最终会关闭。但你的坐席 整天点击的那个仪表盘查询是"给我看每一张未关闭工单,最早的优先" —— 藏在数百万张里的区区几百行。
定义一个 ,分区键为 openBucket,排序键为
openedAt,并只在未关闭工单上写入 openBucket。在工单
创建时设置它;在工单解决时 REMOVE 它。
| PK | SK | openBucket | openedAt | |
|---|---|---|---|---|
| TICKET#a91f | DETAIL | OPEN | 2026-06-23T09:14:00Z | ← open: in the index |
| TICKET#b02c | DETAIL | OPEN | 2026-06-22T16:40:00Z | ← open: in the index |
| TICKET#77de | DETAIL | (absent) | 2026-05-30T11:02:00Z | ← closed: NOT in the index |
工单 a91f 和 b02c 携带 openBucket,所以它们存在于 GSI 中。工单
77de 已被解决且其 openBucket 已被移除,所以它悄然出局了。仪表盘
现在就是一次廉价的查询:
Query IndexName = "open-tickets-index"
KeyConditionExpression: openBucket = "OPEN"
ScanIndexForward: true # oldest first
这只读取未关闭工单。随着工单关闭,索引会自行收缩 —— 它的 大小追随_未关闭_工单的数量,绝不追随总量。
一个静态的分区值("OPEN")在这里没问题,恰恰因为这个集合
保持很小。一个庞大的未关闭集合会需要一个分片的分区键,但"小子集"
索引正是一个单一值恰当的用武之地。
让它成立的那次转变,就是一次单独的 —— 在工单解决时 移除那个属性。
在DynamoDB 表达式构建器里为读取侧
原型化那个 REMOVE 子句和带类型的键条件,而不是自己手工
拼装 ExpressionAttributeNames 和 :val 占位符。
在 DynoTable 中操作
稀疏索引最难的部分不是读取 —— 而是_看清_哪些项进了 索引,哪些又悄然出局了。
DynoTable 让你把一个表视图切换到一个二级索引,看清那个
已填充的子集。这样你就能确认一张已解决的工单真的离开了
open-tickets-index,而不是带着一个陈旧的键滞留其中。

陷阱与后续步骤
有几件事要留意:
- 移除这个键,别清空它。 一个空字符串不是有效的索引键 ——
写入
openBucket = ""会以 ValidationException 失败,所以这个项从不会 被它索引。要把一个项从索引中剔除,你必须REMOVE这个属性。 - 索引是的。 GSI 是异步更新的,所以一张 刚解决的工单可能短暂地仍然出现 —— GSI 读取 只支持最终一致性。 别用它来判断"这张工单此刻是否未关闭"。
- 留意属性。 对索引的一次
Query只返回被 投影进它的属性。如果仪表盘需要主题和优先级,就 投影它们 —— 否则就为完整的基项多付一次GetItem。 - GSI 和 LSI 都可以是稀疏的 —— 杠杆是一样的:在你不想索引的 项上省略索引的排序键。不过 GSI 通常是更好的选择: 你可以在建表之后再添加它,并给它自己的键 schema 和 容量。GSI 与 LSI 对比拆解了这个取舍。
稀疏索引是这套模型里最古老的想法之一。最初的 2007 年 Amazon Dynamo 论文 就是围绕着廉价地服务已知的、高流量的访问模式来构建这个存储的。
而稀疏索引恰恰就是这个:塑造你的键,让常见查询不读取任何它 不需要的东西。
要真正构建并检视一个,就下载 DynoTable,把它指向 你的表,把数据视图切到你的稀疏 GSI —— 看着子集随着 项获得和失去索引键而更新。


