DynamoDB 中的一对多关系
一个 SaaS 控制平面几乎总有一层包含层级:一个工作区(workspace)拥有多个项目(project)。在 SQL 中,你会在项目表上放一个 workspace_id 外键,然后用 JOIN。
DynamoDB 没有连接(join),也没有外键,所以这层关系必须存在于键 schema本身之中。做对了,"加载一个工作区及其内部的每个项目"就变成一次 Query,而不是一次读取外加一次后续扫描。
在 DynamoDB 中如何建模一对多关系?
让父项和它的全部子项拥有相同的 ,从而共享同一个 ,然后用排序键把它们区分开。DynamoDB 没有连接或外键,所以这层关系存在于键 schema 本身之中。这样,加载一个父项加上它的每个子项就变成了一次 Query,而不是一次连接。
- 对读取建模,而非对实体建模。 这层一对多关系存在的唯一目的,就是服务于"列出某个工作区的项目"——围绕那条查询来塑造键。
- 把父项编码进子项的 。 让工作区及其全部项目拥有相同的分区键值,这样它们就落入同一个。
- 于是列表读取就是一次
Query。 父项加上它的子项一并返回——没有连接,没有第二次往返(一次Query每页最多返回 1 MB,超出部分通过LastEvaluatedKey分页)。 - 留意。 单个庞大的租户会把它的全部流量集中到一个分区上;一个超大的工作区可能需要分片键(sharded key)和扇出读取(fan-out read)。
先从访问模式开始
DynamoDB 建模是访问模式优先,而非实体优先——这与 单表设计 背后是同一套纪律。在选择任何键之前,先写下应用实际发出的那些读取:
- 获取某个工作区的设置。
- 列出某个工作区中的每个项目,最新的在前。
- 按 id 获取某个特定项目。
"一个工作区、多个项目"这层关系之所以重要,仅仅是因为读取 #2。如果你从不需要把一个工作区的项目一并列出,你根本就不会去建模这层关系——你会把项目独立存储。
所以问题从来不是抽象地"我该如何表示一对多?",而是"这层关系必须服务于哪些查询?"回答了这个,再围绕它来塑造键。
为什么外键在这里帮不上忙
在 DynamoDB 中,每个 GetItem 和 Query 都以一个分区键为目标,服务会对该键做哈希,以定位存放该项的分区。
AWS 在 核心组件 文档中直接这样说:分区键值是内部哈希函数的输入,由它决定数据存放在哪里。
这种基于哈希的放置方式,继承自最初 2007 年那篇 Dynamo: Amazon's Highly Available Key-value Store 论文,其中用一致性哈希把键分布到各个节点上。
项目项上一个光秃秃的 workspace_id _属性_对那套机制是不可见的——DynamoDB 无法"顺着"它去查找。
要在一次请求中取回相关的项,父项的身份必须被编码进项目的分区键里,这样一个工作区的全部项就哈希到同一个分区,一次 Query 就能把它们一扫而空。
完整示例:工作区与项目
使用一个通用的、重载(overloaded)的键 schema。把分区键命名为 EntityRef,排序键命名为 Detail。工作区的身份被放进 EntityRef 里——既用于工作区项,也用于它下面的每个项目:
| EntityRef | Detail | attributes |
|---|---|---|
| WS#acme | META | displayName, region, seatLimit |
| WS#acme | PROJ#2026-0007 | title, status, createdBy |
| WS#acme | PROJ#2026-0042 | title, status, createdBy |
| WS#acme | PROJ#2026-0118 | title, status, createdBy |
| WS#globex | META | displayName, region, seatLimit |
| WS#globex | PROJ#2026-0009 | title, status, createdBy |
工作区及其全部项目共享 EntityRef = "WS#acme",所以它们构成一个单一的项集合,共同存放在一个分区上。
Detail 排序键把它们分开:META 是工作区记录,而每个项目都带一个 PROJ# 前缀,加上一个补零、按时间排序的 id,这样项目就能自然排序。
从视觉上看,父项和它的子项在一个分区内堆叠起来,按排序键排序:
一次对 EntityRef = "WS#acme" 的 Query 就把整个堆栈——父项加上每个子项——在一次读取中扫过。
现在三种访问模式各自都收敛为一次调用:
- 工作区设置 ——
GetItem(EntityRef="WS#acme", Detail="META")。 - 列出项目,最新的在前 ——
Query(EntityRef="WS#acme")配合Detail begins_with "PROJ#",以降序运行(ScanIndexForward = false)。 - 单个项目 ——
GetItem(EntityRef="WS#acme", Detail="PROJ#2026-0042")。
第二条才是全部重点所在。父项和它的子项从一次 Query 中返回,没有连接,也没有第二次往返——DynamoDB 每页最多返回 1 MB,并交给你一个 LastEvaluatedKey 去取回其余部分。这正是你用外键属性加一次 Scan 做不到的动作。
手写那个 begins_with 条件很磨人——键条件与投影表达式的语法很咬手。
DynamoDB Expression Builder 会生成 KeyConditionExpression、#name/:value 占位符映射,以及一段可直接运行的 SDK 代码片段,让你不必和这套语法较劲:
KeyConditionExpression "#er = :er AND begins_with(#d, :p)"
ExpressionAttributeNames { "#er": "EntityRef", "#d": "Detail" }
ExpressionAttributeValues { ":er": "WS#acme", ":p": "PROJ#" }
在 DynoTable 中查看项集合
这种布局的回报是可视化的。每一行共享同一个 EntityRef 的,就是工作区加上它的子项,彼此紧挨着。
DynoTable 会把它们分组,让你把这层一对多关系看作一个连续的整块,而不必在分散的多张表之间猜测。

陷阱与另一种布局
有几点需要留意:
- 热分区。 一个工作区的每个项都存放在一个分区上,所以单个非常大或非常繁忙的租户会集中流量。AWS 所描述的 自适应容量 行为能吸收适度的倾斜,但一个拥有数百万项目的工作区可能需要分片键(例如
WS#acme#01 … #10)和扇出读取。 - 项集合大小。 当存在本地二级索引时,单个分区的项集合上限为 10 GB;没有 LSI 则没有这样的限制。如果你在这里权衡索引类型,参见 GSI 与 LSI。
- 动用
Query,绝不用Scan。 整个设计存在的意义就是让你能Query一个分区。退回到用一次带筛选的Scan去"查找某个工作区的项目",就把这套模型丢掉了,并会读取整张表——这正是 Query 与 Scan 中讲到的陷阱。
如果你确实需要跨工作区列出项目(比如全局所有 status = ACTIVE 的项目),基础表回答不了这个问题——它的分区键是限定在工作区范围内的。
这是一个二级索引的活儿:它按另一个属性对项目重新分区,而不是靠重塑这层关系来解决。
后续步骤
对访问模式建模,把父项编码进子项的分区键,一对多读取就是一次 Query。用 DynamoDB Expression Builder 构建并验证键条件——而如果你更愿意从访问模式本身出发,免费的单表设计工具会起草带示例项的 PK/SK/GSI 方案。
然后 下载 DynoTable 加载这个 schema,实时浏览工作区→项目的项集合,并确认每条查询恰好只做一次读取。如果你更想把工作区和项目看作一个连接起来的关系型视图,DynoTable 的 SQL Workbench 也能运行那个 JOIN。


