进阶阅读约 3 分钟

DynamoDB 中的多对多关系

一个学生选修许多课程;一门课程容纳许多学生。在 SQL 里你会去找一张连接表和一个三表 JOIN

DynamoDB 没有连接,所以关系得住在里——诀窍是把每条选课边存成一种让双方都能直接 Query 的形态。

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

你该如何在 DynamoDB 中建模一个多对多关系?

DynamoDB 没有连接,所以你用模式来建模多对多关系:把每条链接存成它自己的、以一方为键的边项目,然后加一个把键对调的反转 GSI。一条边,只写一次,就能廉价地从两个方向回答查询。

  • 把每次选课存成它自己的边项目,而不是任一方上的一个列表属性。
  • 以学生为边的键PK = STU#…SK = ENROLL#CRS#…),这样单次 Query 就返回一位学生的整份课程清单。
  • 加一个反转的 对调角色(GSI1PK = CRS#…),这样同一条边也能回答「谁在这门课里?」。
  • 一条边,只写一次,双向都读得便宜——这就是全部的门道。

先框定访问模式

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

  • 取一位学生的 profile,并列出该学生所选的每一门课程
  • 取一门课程的元数据,并列出选修该课程的每一位学生
  • 查一条单独的选课边——用来改成绩或退课。

那两个列表读取在同一组边上指向相反的方向。一个朴素的设计会廉价地服务其中一个,却逼另一个走 Scan——正是 Query vs Scan 里讲到的那个坑。

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

使用邻接表模式

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

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

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

一次 Query 在一个分区键下、在一次计费操作里读取一段连续的排序键值区间——那是引擎所提供的唯一「连接」。

要得到一个能从_两侧_都读得便宜的关系,你就把边复制一份:以学生为键写一次,然后用一个二级索引把同一条边以课程为键再投影一份。

这就是单表设计里那种重载键的思路,只不过应用到一个关系上,而非一个父子层级。

其形态是同一条边的两层堆叠视图——基表以学生为键,反转 GSI 以课程为键:

同一条边,键对调同一条边,键对调反转 GSI1 —— 以课程为键GSI1PK CRS#math204GSI1SK STU#a91GSI1PK CRS#cs101GSI1SK STU#a91基表 —— 以学生为键PK STU#a91SK ENROLL#CRS#math204PK STU#a91SK ENROLL#CRS#cs101

每条边在基表上只写一次,并把键对调后投影进 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" 在一次读取里返回学生的 profile 以及每一次选课。用 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 vs LSI

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

把课程名单读取当作来对待——开发者指南对全局二级索引明确点出了这一点。

在 DynoTable 中写入并读取它

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

那正是你可以用 DynamoDB 表达式构建器可视化地组装出来的那种条件,而不必手写 ExpressionAttributeNames 和占位符值。

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

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

陷阱与后续步骤

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

要在周边模式上深入,读一读单表设计了解重载键,以及 GSI vs LSI 了解反转索引何时必须是全局的。而要从你自己的关系出发,免费的单表设计工具能把「列出某个学生的课程 / 列出某门课程的学生」这样的访问模式清单,变成一份带示例项的 PK/SK/GSI 方案。

然后下载 DynoTable 去真刀真枪地建模「学生 ↔ 课程」schema——写入那些边、用表达式构建器构建条件,并在不做一次扫描的前提下查询关系的两个方向。而当你终究还是想要那个经典的三表 JOIN 视图时,DynoTable 的 SQL Workbench 能对你的实时表运行它。

更新于

交互式地试用这个设计

在免费的 DynamoDB 单表设计工具中勾勒你的实体和访问模式 —— 它会建议 PK/SK 键模板、预览条目集合,并显示哪些模式需要 GSI。

打开单表设计工具