进阶阅读约 3 分钟

DynamoDB 中的反规范化

从 SQL 过来,反规范化听起来像是一种罪过——重复的数据,没有单一的真相来源。在 DynamoDB 里它就是全部要点。没有 join,所以你 把相关数据复制到需要它的那个项上,一次性把它读回来。

DynamoDB 中的反规范化是什么?

DynamoDB 中的反规范化意味着把相关数据复制到读取它的那个项上,这样一次查询就能一次性返回所有东西。因为 DynamoDB 没有 join,你在 写入时预先 join,而不是在读取时把表拼接起来。代价是陈旧——只复制那些极少变化的值。

  • 没有 join 意味着你在写入时预先 join。 把相关的值存到读取它的那个项上,这样一次查询就绝不需要第二次查找。
  • 两种形态。 在一个项上的一个 复杂属性 里嵌入嵌套数据,或者把一个值 复制 到许多项上。
  • 暗雷是陈旧。 当源变化时,每一份副本都是错的,直到你把更新扇出。只复制那些极少变化的值。
  • 它换来的是读取,不是写入。 你用更多(且更小心)的写入去换廉价的、单次请求的读取。

为什么没有 join 可以退守

一个关系型 JOIN 在读取时把规范化的行重新组装起来。DynamoDB 没有 join——一次 Query 读取一个 ,把恰好存在那里的东西交回来。没有东西替你把两张表拼接起来。(至少在生产读取路径上是这样——对于临时的审计或漂移检查,DynoTable 的 SQL Workbench 能在客户端对 DynamoDB 运行真正的 JOIN。)

所以数据必须已经被塑造成读取所需的形状。如果一个屏幕需要一篇帖子和它作者的名字,那个名字就必须存在于帖子读取本就会触及的某个地方。2007 年的 Amazon Dynamo 论文把这个权衡说明白了:舍弃关系型特性,去换取规模下可预测的读取——这个权衡如今 DynamoDB 以个位数毫秒的读取交付出来。

模式 1——用一个复杂属性来嵌入

DynamoDB 属性可以持有嵌套的 映射列表,而不只是标量。所以反规范化的一种常见形式,是把一个子对象直接塞进它的父项里,而不是给它自己的项。

一篇帖子连同它的标签和一小段作者快照,全在一个项上:

PKSKauthortags
POST#9f3META{id: U#12, name: "Mara Vance"}["dynamodb","aws"]

一次 GetItem 就把帖子、标签和作者块一起返回。没有第二次读取。这对于那种被父项 拥有 且大小有界的数据——寥寥几个标签、一段作者快照——很棒。

单个 DynamoDB 项的上限是 400 KB,属性名和值都算在内(Service Quotas)。嵌入一个无界的列表(一篇爆款帖子的每一条评论),你就会冲破它。

模式 2——把一个值复制到多个项上

博客这个案例是教科书式的。你列出帖子,想让每一行显示作者的显示名——但你不想为了取到它而每帖多做一次读取。

所以你在帖子被创建时,把作者的名字写到每一个帖子项上

PKSKauthorIdauthorNametitle
POST#9f3METAU#12"Mara Vance""Modeling 1:N"
POST#a71METAU#12"Mara Vance""Sparse GSIs"
POST#b04METAU#88"Lio Tan""Query vs Scan"

一个覆盖帖子的 (比如说 GSI1PK = "POST",或一个以作者为键的)就能渲染出整个列表——标题和作者——不用每行查找。在分区键上做 begins_with 是不存在的事;一次 Query 需要分区键等值,所以在每帖各自分区键的情况下,列表来自 GSI,而不是对 POST# 的一次 Query。作者名是被 反规范化 的:规范副本存在于 USER#12 上,而每一篇帖子都带着它自己的一份副本。

这个权衡就摆在那里。你把一次 N+1 读取变成了一次读取,代价是把 "Mara Vance" 保存在 N+1 个地方。

嵌入 对比 复制——选哪个

嵌入(复杂属性)复制(跨项复制)
形状子项嵌套在父项内部同一个值在许多项上
最适合有界的、父项拥有的数据许多项要展示的一个共享值
读取一次 GetItem一次 Query
更新成本重写那一个父项扇出到每一份副本
大小风险400 KB 项上限每项无风险

当子项只会与它的父项一起出现时,够用 嵌入。当许多独立的项都需要展示同一个共享值时,够用 复制

那个暗雷:陈旧的副本

这就是会咬人的部分。Mara 把自己改名成 “Mara V.”。你更新了 USER#12。每一个帖子项仍然写着 "Mara Vance",直到你去把它们改过来。

所以更新一个被复制的值是一次 扇出写入,而不是一行代码的事。你查询每一个受影响的项并重写每一个——理想情况下加上守卫,让你只碰那些仍持有旧值的行:

UPDATE POST#9f3
SET authorName = "Mara V."
WHERE authorName = "Mara Vance"

你可以在 表达式构建器 里针对 authorName 组合那个条件 SET,并把生成的 UpdateExpressionConditionExpression 直接复制进你的代码。

扇出是每项一次写入。对以作者为键的 GSI 查询那个作者的帖子,然后发起更新。这个序列:

"DynamoDB"App"DynamoDB"App"更新 USER"查询该作者的帖子""POST"逐个更新 authorName"

对源的每一次改动都是一次查询加上每份副本一次写入。在 DynoTable 中,这次扇出会先落进暂存区,成为每个项一份可审阅的逐属性差异——在任何东西发出之前,你都能看到每一份即将改变的副本。

这就是为什么规则是 只复制那些极少变化的值。一个显示名、一个套餐层级、一个分类标签——可以。一个实时计数器或一个频繁编辑的字段——别;扇出会把你生吞活剥。

扇出写入的成本

你更新的每一份副本都是一次单独计费的写入。在 us-east-1 的按需模式下,作者改名之后更新 50 个帖子项要花 50 × WCU——当每个帖子行 ≤ 1 KB 时,通常是每项每 KB 1 WCU。读取那一侧仍然只是一次 Query;写入那一侧则随你维护的副本数量而增长。在定价计算器里把两条路径都估一遍。

什么时候规范化仍然胜出

如果一个值经常变化,或者一个项被真正无法预测的模式读取,就让它保持规范化,接受那次额外的读取。反规范化是对 已知的、读密集的 访问模式的一种优化——而不是一个到处套用的默认值。为你真正会跑的那些读取预先 join,其余的别去动。

要决定这些被复制的属性 住在哪里,先对访问模式建模——参见 单表设计,以及权衡的读取一侧,Query 对比 Scan

下载 DynoTable,去检视一张反规范化的表、找出哪些副本已经漂移,并对你自己的数据运行那次扇出更新。它的 SQL Workbench 甚至能把事实源与各份副本 JOIN 起来,在一次查询里找出漂移。

更新于