DynamoDB 筛选策略
DynamoDB 里的“筛选”是四种不同的东西披着同一个词。三种在数据被读取并计费 之前 收窄它;一种——就是那个叫 Filter 的——在 之后 收窄它。分清哪个是哪个,就是这门功夫的大半。
DynamoDB 中的筛选是怎么工作的?
DynamoDB 有四种筛选方式,只有一种是在你被计费之后才运行。 挑出一个分区,排序键收窄一个切片,稀疏索引按属性存在与否筛选——这三种都在计量之前削减你的读取成本。一个 FilterExpression 在读取之后运行,所以它缩小响应但绝不缩小账单。
- 是最便宜的筛选:它挑出分区,所以你根本不碰表的其余部分。
- 用
begins_with、between、<、>在一个分区 内 筛选——仍在计费之前,仍然便宜。 - 按缺失来筛选:一个项只有在拥有被索引的属性时才出现在索引里,所以索引 就是 那个筛选后的集合。
FilterExpression是那个陷阱:它在 DynamoDB 计量读取之后才运行,所以它削减你的响应大小,但绝不削减你的账单。
搭好这个示例
一个产品目录。一张表,分区键 PK,排序键 SK:
PK = "DEPT#kitchen" SK = "PROD#00194"
每个产品还带着 price、inStock(一个布尔值)和 clearanceAt(一个 unix 时间戳,只在被标记为清仓的项上存在)。一个部门里的项共享一个分区,按产品 id 排序。
我们想要四种访问模式。每一种映射到一种不同的筛选策略——而其中任何一种上的错误选择,都是一次你要永远付费的 Scan。
按分区键筛选
“给我 kitchen 里的每一个产品。”分区键直接回答这个:
Query PK = "DEPT#kitchen"
DynamoDB 恰好读取一个分区。表里其他任何东西都不被触及或计费。这是在真正要紧的意义上唯一免费的筛选——它就是 Query 和 Scan 之间的区别。
从 SQL 过来,这感觉是反的:没有一个扫描索引的 WHERE department = 'kitchen',你只是 点名那个分区。如果你点不出它的名,那是一个建模问题,不是一个查询问题。
按排序键筛选
“给我从 PROD#00100 往上的 kitchen 产品。”排序键在分区 内 收窄,而且它是在读取被计量之前这么做的:
Query PK = "DEPT#kitchen" AND SK between "PROD#00100" AND "PROD#00200"
排序键条件被刻意限制:=、<、<=、>、>=、between 和 begins_with。没有 OR,没有任意谓词。
正是那个约束让读取保持有针对性——DynamoDB 走过一个连续的切片,而不是整个分区。
这里的杠杆是 你如何编码排序键。如果你的模式是“按价格区间”,一个 PROD#<id> 排序键帮不上忙——你得把价格烤进键里。
那是一个 排序键策略 决策,在设计时做出,不是在查询时。
按稀疏索引筛选
“给我当前所有在清仓的东西。”大多数产品都不在清仓,所以你不想为了找出那少数几个而读取整个目录。
一个 稀疏索引 通过缺失来解决这个问题。一个 只有在一个项拥有该索引的键属性 两者 时才包含这个项。
把一个常量 clearance = "CLEARANCE" 标志设为 GSI 分区键——只写在清仓项上——以 clearanceAt 作为排序键,那么索引就不含别的任何东西。
AWS 把这说明白了:一个全局二级索引只包含拥有该索引键属性的项,所以缺少该键属性的项根本不会被传播过去(AWS——利用稀疏索引)。
现在这个查询只读取清仓项,也只为它们计费:
Query ON ClearanceIndex GSI_PK = "CLEARANCE" (sorted by clearanceAt)
筛选发生在你 写入 数据的时候——通过选择到底要不要设 clearanceAt。索引就是那个筛选后的集合。关于哪种索引类型合适,参见 GSI 对比 LSI。
用 FilterExpression 筛选
“给我有库存的 kitchen 产品。”inStock 不是一个键属性,所以你伸手去用一个 FilterExpression:
Query PK = "DEPT#kitchen"
Filter inStock = true
陷阱来了。DynamoDB 读取 kitchen 分区里的每一个项,为它们全部计量容量,然后 才丢掉缺货的那些。
AWS 写明筛选表达式“在一次 Query 完成之后、但在结果被返回之前应用”,而且“无论是否存在筛选表达式,一次 Query 消耗相同数量的读容量”——你已经为完整的读取付了费(AWS——Query 的筛选表达式)。
所以如果 kitchen 有 10,000 个产品而 12 个有库存,你要为读取 10,000 个付费。响应很小;账单不小。FilterExpression 缩小的是跨越网络传输的载荷,绝不是读取。
还有第二道更锋利的刃:分页是在筛选 之前 计量的。一页是 1 MB 被读取的 项,而不是 1 MB 的匹配项。
一个筛选可以返回一个空页却带着一个设好的 LastEvaluatedKey——DynamoDB 读了整整一兆字节,一个都没匹配上,交给你一个空数组。你继续翻页,而你为每一个空页都付了费。
用 DynamoDB 表达式构建器 构建这个表达式——名称、值,以及对保留字的正确转义——让 #inStock/:val 占位符一次就对。
下面这个构建器预设为一次带 FilterExpression 的 Scan——正是上面那个反模式。注意这个筛选是在整张表上运行,而不是一个键切片:
对比这四种
| 何时筛选 | 削减读取成本? | 谓词能力 | 搭建成本 | |
|---|---|---|---|---|
| 分区键 | 读取之前 | 是——一个分区 | 只有等值 | 免费(它就是键) |
| 排序键 | 读取之前 | 是——一个切片 | 范围 / begins_with | 排序键设计 |
| 稀疏索引 | 读取之前 | 是——仅索引 | 一个属性的存在 | 额外的 GSI + 写入成本 |
| FilterExpression | 读取之后 | 否 | 几乎任何条件 | 无 |
从上往下读这张表:谓词能力 上升,成本控制 下降。FilterExpression 能精确地表达任何东西,正是因为它在已经读取的项上运行——这也正是它无法替你省钱的原因。
在 DynoTable 中查看
当你运行一个带筛选的 Query 时,被 读取 的项与被 返回 的项之间的差距就是整个故事。DynoTable 在被返回的项旁边显示被扫描的项——随着一次筛选读取的流入——这样一个悄悄读遍整个分区的筛选就可见了,而不是藏在你的月度账单里。
对于真正的跨项问题——一个筛选回答不了的,比如“每个部门的平均价格”“有库存的产品连同它们的评论”——DynoTable 的 SQL Workbench 在客户端对一个有界的结果集运行 GROUP BY、JOIN 和聚合,而不是编译成一次表级的 Scan。
陷阱与后续步骤
- 别把
FilterExpression当作你的主要访问路径。 如果一个模式很常见,就把它建模进一个键或一个稀疏索引。筛选是用来做最后那一点点收窄的,不是它的大头。 - 留意空页。 一个带筛选的查询可以翻很久的页却什么都不返回。尊重
LastEvaluatedKey;别假设一个空页意味着“完了”。 - 稀疏索引不是免费的。 它为每一个落入其中的项花费写容量和存储——当那个属性稀有时便宜,当它不稀有时就没那么便宜。
用 定价计算器 估算一次带筛选的读取实际会花多少,并 试试 DynoTable,在你自己的表上盯着消耗的容量对比返回的行。