进阶阅读约 3 分钟

DynamoDB 中的多对多关系

一个学生选修多门课程;一门课程容纳多名学生。在 SQL 里,你会动用一张连接表和一个四向 JOIN

DynamoDB 没有连接,所以这种关系必须存在于之中——窍门是把每条选课边以一种两侧都能 直接 Query 的形态存储起来。

本指南从头到尾走一遍学生 ↔ 课程问题:访问模式、解决它们的邻接表模式、一个你可以照抄的 原创键 schema,以及如何在从不扫描表的前提下双向读回。

如何在 DynamoDB 中建模多对多关系?

DynamoDB 没有连接,所以你要用邻接表模式来建模多对多关系:把每条链接存为以某一侧为键的独立边项,再添加一个调换键顺序的反转 GSI。一条边,写入一次,即可双向廉价地响应查询。

  • 把每条选课存为它自己的边项,而不是任一侧的列表属性。
  • 以学生为边项设键PK = STU#…SK = ENROLL#CRS#…),这样一次 Query 就返回一个学生的整份课程列表。
  • 加一个反转的 GSI,调换角色(GSI1PK = CRS#…),让同一条边也回答"谁在这门课里?"。
  • 一条边,写入一次,两个方向都读得便宜——这就是全部的游戏。

先框定访问模式

DynamoDB 建模是访问模式优先:你在挑选任何一个属性名之前先确定读取。多对多关系几乎总有 两个对称读取,外加实体查找:

  • 获取一个学生的资料,并列出该学生选修的每一门课程
  • 获取一门课程的元数据,并列出选修该课程的每一名学生
  • 查找单条选课边——用于更新成绩或退课。

痛点在于:这两个列表读取指向同一组边的相反方向。一个朴素的设计会便宜地服务其中一个,却 逼迫另一个去 Scan——正是 Query 与 Scan 里讲到的那个坑。

任务是让两个方向都是单次 Query

使用邻接表模式

DynamoDB 自己对关系的指引就是邻接表:把每段关系建模为一个项,它的分区键是一个端点, 排序键是另一个端点。

AWS 在 DynamoDB 开发者指南的 管理多对多关系的最佳实践 页面记录了这一点。

为什么用键而不是第二张表?因为 DynamoDB 给你的原语就是针对单个分区的 Query

一个 Query 在一次计费操作中读取单个分区键下排序键值的一个连续范围——这是引擎提供的 唯一一种"连接"。

要得到一种从两侧都读得便宜的关系,你需要复制这条边:以学生设键写一次,然后用二级索引 以课程设键投影同一条边。

这是来自单表设计的重载键思路,只不过应用在一段关系上, 而不是父子层级上。

它的形态是同一条边的两个堆叠视图——基础表以学生设键,反转的 GSI 以课程设键:

反转的 GSI1 以课程设键基础表 以学生设键同一条边,调换键同一条边,调换键PK STU#a91SK ENROLL#CRS#math204PK STU#a91SK ENROLL#CRS#cs101GSI1PK CRS#math204GSI1SK STU#a91GSI1PK CRS#cs101GSI1SK STU#a91

每条边在基础表上写一次,并以调换后的键投影进 GSI,于是针对任一分区的 Query 都读得便宜。

它的渊源可追溯到 2007 年的 Amazon Dynamo 论文: 分区键是分布的单位,而单键访问是快速路径。

DynamoDB 中的关系,就是把多对多读取扭进那条快速路径的一项练习。

演练实例:学生 ↔ 课程

用一张表配通用键 PKSK,把实体类型编码进值里。选课边是它的核心:

PKSKattributes
STU#a91PROFILEname, year, major
STU#a91ENROLL#CRS#math204 enrolledOn, grade
STU#a91ENROLL#CRS#cs101enrolledOn, grade
CRS#math204METADATAtitle, credits, term
CRS#cs101METADATAtitle, credits, term

单次 Query PK = "STU#a91" 在一次读取中返回该学生的资料以及每一条选课记录。用 SK begins_with "ENROLL#" 收窄它,只取课程边。这就解决了"列出一个学生的课程"。

但"列出一门课程的学生"指向另一个方向——基础表回答不了,因为学生 id 在分区键里, 不在排序键里。

加一个反转的全局二级索引来调换角色。给边项一对通用的 GSI1PK/GSI1SK,让课程在分区侧、 学生在排序侧:

PKSKGSI1PKGSI1SK
STU#a91ENROLL#CRS#math204CRS#math204STU#a91
STU#b30ENROLL#CRS#math204CRS#math204STU#b30
STU#a91ENROLL#CRS#cs101CRS#cs101STU#a91

现在 Query GSI1 WHERE GSI1PK = "CRS#math204" 列出该课程中的每名学生——这正是基础表 服务不了的读取。一条边项,写入一次,两个方向都能回答。

它必须是 GSI,而不是 LSI:课程分区与学生分区完全不同,而 LSI 共享基础表的分区键。

这个索引跨越多个分区,所以它必须是全局的——参见 GSI 与 LSI

有一个坑:DynamoDB 中的 GSI 是异步填充的。一条全新的选课记录可能要过一会儿才会在 CRS#… 方向出现。

把课程花名册读取当作最终一致——开发者指南对全局二级索引明确指出了这一点。

在 DynoTable 中写入并读取

写入选课意味着设置四个键属性外加边自身的数据。阻止一个学生在同一门课重复选课的条件是对 复合键的 attribute_not_exists(PK) 守卫。

这恰恰是你可以用 DynamoDB Expression Builder 可视化地拼出的那类条件,而不必手写 ExpressionAttributeNames 和占位值。

在 DynoTable 中,你把一个 Query 指向 GSI1,设 GSI1PK = "CRS#math204",花名册就以 一张你可以读取、排序、原地编辑的表返回——关系的两个方向都能从一个 schema 浏览。

在 DynoTable 中查询反转的 GSI,列出选修某门课程的每一名学生。
在 DynoTable 中查询反转的 GSI,列出选修某门课程的每一名学生。

陷阱与下一步

  • 别把一侧存为列表属性。 在学生项上放一个 courseIds 数组看着整洁,直到某门课需要它的 花名册、数组撞上 400 KB 的项上限,或者两次选课竞争并互相覆盖。离散的边项独立地扩展和更新。
  • 把边数据留在边上。 选课的 gradeenrolledOn 属于边项,而不该重复到学生或课程上 ——每一对(学生、课程)恰好只有一行要更新。
  • 留意 GSI 传播。 反转索引的方向是最终一致的,所以紧接选课之后的一次读取可能滞后几分之一秒。
  • 只投影花名册需要的东西。 当花名册视图只需要 id 时,KEYS_ONLY 或窄投影能让 GSI 保持精简。

要更深入地了解周边模式,读 单表设计了解重载键,读 GSI 与 LSI了解反转索引何时必须是全局的。

然后下载 DynoTable真正建模学生 ↔ 课程的 schema——写入边、用 Expression Builder 构建条件,并在没有一次扫描的情况下查询关系的两个方向。

更新于