DynamoDB 中的反范式化
从 SQL 过来,反范式化听起来像是一种罪 —— 重复的数据,没有单一可信源。在 DynamoDB 里它正是重点所在。这里没有连接,所以你把相关数据复制到需要它的那个项上,再一次性把它读回来。
什么是 DynamoDB 中的反范式化?
DynamoDB 中的反范式化,是指把相关数据复制到读取它的那个项上,于是一次查询就能一次性返回所有内容。因为 DynamoDB 没有连接,你在写入时预先连接,而不是在读取时把多张表缝在一起。代价是陈旧 —— 只复制那些极少改变的值。
- 没有连接意味着你在写入时预先连接。 把相关的值存在要读它的那个项上,于是查询永远不需要第二次查找。
- 两种风味。 把嵌套数据嵌入一个项上的复杂属性里,或者把一个值复制到许多项上。
- 暗坑是陈旧。 当源头改变时,每一份副本都是错的,直到你扇出更新。只复制那些极少改变的值。
- 它买来的是读取,不是写入。 你用更多(且更费心的)写入,换取廉价的、单次请求的读取。
为什么没有连接可退守
关系型的 JOIN 在读取时把范式化的行重新拼装起来。DynamoDB 没有连接 —— 一次 Query 读取一个项集合,并原样交回那里存着的东西。没有任何东西替你把两张表缝在一起。
所以数据必须已经为读取塑好了形。如果某个界面需要一篇文章和它作者的名字,那个名字就必须存在文章读取已经触及的某个地方。2007 年那篇 Amazon Dynamo 论文把这桩交换说得很明白:放弃关系型特性,以在规模下换取可预测的、个位数毫秒的读取。
模式 1 —— 用复杂属性嵌入
DynamoDB 的属性可以容纳嵌套的映射和列表,而不只是标量。所以反范式化的一种常见形式,是把一个子对象直接塞进它的父项里,而不是给它一个自己的项。
一篇文章连同它的标签和一个小的作者快照,全在一个项上:
| PK | SK | author | tags |
|---|---|---|---|
| POST#9f3 | META | {id: U#12, name: "Mara Vance"} | ["dynamodb","aws"] |
一次 GetItem 把文章、标签和作者块一起返回。没有第二次读取。这对于由父项拥有且大小有界的数据很棒 —— 寥寥几个标签,一个作者快照。
要尊重的限制:单个 DynamoDB 项最多 400 KB,属性名和值都算在内(服务配额)。嵌入一个无界的列表(一篇爆款文章下的每一条评论),你就会撑爆它。
模式 2 —— 把一个值复制到各项上
博客这个例子是教科书式的。你列出文章,并想让每一行显示作者的显示名 —— 但你不想为每篇文章多读一次去取它。
所以你在文章创建时把作者的名字写到每一个文章项上:
| PK | SK | authorId | authorName | title |
|---|---|---|---|---|
| POST#9f3 | META | U#12 | "Mara Vance" | "Modeling 1:N" |
| POST#a71 | META | U#12 | "Mara Vance" | "Sparse GSIs" |
| POST#b04 | META | U#88 | "Lio Tan" | "Query vs Scan" |
现在 Query PK begins_with "POST#"(或一个覆盖文章的 GSI)就渲染出整张列表 —— 标题和作者 —— 没有逐行查找。作者名是反范式化的:规范副本住在 USER#12 上,而每一篇文章携带它自己的一份副本。
交换就明摆在那里。你把一次 N+1 读取变成了一次读取,代价是在 N+1 个地方持有 "Mara Vance"。
嵌入 vs. 复制 —— 选哪个
| 嵌入(复杂属性) | 复制(跨项拷贝) | |
|---|---|---|
| 形态 | 子项嵌套在父项内部 | 同一个值在许多项上 |
| 最适合 | 有界、父项所拥有的数据 | 许多项都要显示的一个共享值 |
| 读取 | 一次 GetItem | 一次 Query |
| 更新成本 | 重写那一个父项 | 扇出到每一份副本 |
| 大小风险 | 400 KB 项上限 | 每项无风险 |
当子项永远只与它的父项一起出现时,去够嵌入。当许多独立的项都需要显示同一个共享值时,去够复制。
暗坑:陈旧的副本
下面是咬人的那部分。Mara 把自己改名为 “Mara V.”。你更新了 USER#12。每一个文章项仍然写着 "Mara Vance",直到你去把它们改掉。
所以更新一个被复制的值是一次扇出写入,而非一行代码。你查询每一个受影响的项并重写每一个 —— 理想情况下加上守护,让你只触碰那些仍然持有旧值的行:
UPDATE POST#9f3
SET authorName = "Mara V."
WHERE authorName = "Mara Vance"
你可以在表达式构建器里针对 authorName 组合那个带条件的 SET,并把生成的 UpdateExpression 和 ConditionExpression 直接拷进你的代码。
扇出本身是每项一次写入:查询作者的文章,然后下发这些更新。序列:
复制数据的成本:源头的每一次变更都是一次查询外加每份副本一次写入。
这就是为什么规则是只复制那些极少改变的值。一个显示名、一个套餐档位、一个分类标签 —— 没问题。一个实时计数器或一个频繁编辑的字段 —— 别;那扇出会把你活活吃掉。
何时范式化仍然胜出
如果一个值频繁改变,或者某个项被真正不可预测的模式读取,那就把它保持范式化,并接受那次额外的读取。反范式化是一种针对已知的、读取密集型访问模式的优化 —— 而不是一个到处套用的默认。预先连接你真正会运行的读取,剩下的别去碰。
要决定这些被复制的属性 住在哪儿,先给访问模式建模 —— 参见单表设计;至于这桩交换的读取侧,参见 Query 对比 Scan。
下载 DynoTable,去检视一张反范式化的表,发现哪些副本已经漂移,并对着你自己的数据运行扇出更新。