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 以课程为键:
每条边在基表上只写一次,并把键对调后投影进 GSI,于是对任一分区的一次 Query 都能廉价地读取这段关系。
这个渊源可以追溯到 2007 年 Amazon 的 Dynamo 论文:分区键是分布的单元,而单键访问是那条快速路径。
DynamoDB 里的关系,就是一门把多对多读取弯折进那条快速路径的功夫。
走一遍示例:学生 ↔ 课程
用一张带通用键 PK 和 SK 的表,并把实体类型编码进值里。选课边是它的核心:
| PK | SK | attributes |
|---|---|---|
| STU#a91 | PROFILE | name, year, major |
| STU#a91 | ENROLL#CRS#math204 enrolledOn, grade | |
| STU#a91 | ENROLL#CRS#cs101 | enrolledOn, grade |
| CRS#math204 | METADATA | title, credits, term |
| CRS#cs101 | METADATA | title, credits, term |
单次 Query PK = "STU#a91" 在一次读取里返回学生的 profile 以及每一次选课。用 SK begins_with "ENROLL#" 收窄,就只拿到课程边。那就解决了「列出一位学生的课程」。
但「列出一门课程的学生」指向的是另一个方向——而基表答不了它,因为学生 id 在分区键里,不在排序键里。
加一个反转的全局二级索引来对调角色。给边项目一对通用的 GSI1PK/GSI1SK,把课程放在分区一侧、学生放在排序一侧:
| PK | SK | GSI1PK | GSI1SK |
|---|---|---|---|
| STU#a91 | ENROLL#CRS#math204 | CRS#math204 | STU#a91 |
| STU#b30 | ENROLL#CRS#math204 | CRS#math204 | STU#b30 |
| STU#a91 | ENROLL#CRS#cs101 | CRS#cs101 | STU#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 里浏览。

陷阱与后续步骤
- 别把一方存成一个列表属性。 学生项目上的一个
courseIds数组看着整洁,直到某门课程需要它的名单、数组撞上 400 KB 项目上限,或两次选课竞争并互相覆盖。离散的边项目能独立地伸缩和更新。 - 把边的数据留在边上。 选课的
grade和enrolledOn属于边项目,而不是复制到学生或课程上——每一对(学生,课程)恰好只有一行要更新。 - 留意 GSI 传播。 反转索引这个方向是最终一致的,所以选课之后立刻读取,可能会滞后零点几秒。
- 只投影名单需要的东西。 当名单视图只需要 id 时,一个
KEYS_ONLY或窄投影能让 GSI 保持小巧。
要在周边模式上深入,读一读单表设计了解重载键,以及 GSI vs LSI 了解反转索引何时必须是全局的。而要从你自己的关系出发,免费的单表设计工具能把「列出某个学生的课程 / 列出某门课程的学生」这样的访问模式清单,变成一份带示例项的 PK/SK/GSI 方案。
然后下载 DynoTable 去真刀真枪地建模「学生 ↔ 课程」schema——写入那些边、用表达式构建器构建条件,并在不做一次扫描的前提下查询关系的两个方向。而当你终究还是想要那个经典的三表 JOIN 视图时,DynoTable 的 SQL Workbench 能对你的实时表运行它。


