进阶阅读约 3 分钟

DynamoDB 中的一对多关系

SaaS 控制平面几乎总有一层包含层级:一个工作区拥有多个项目。在 SQL 里,你会在项目表上放一个 workspace_id 外键,然后 JOIN

DynamoDB 没有连接,也没有外键,所以这种关系必须存在于键 schema 本身之中。做对了, "加载一个工作区及其内部的每个项目"就变成单次 Query,而不是一次读取加上后续的扫描。

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

让父项及其所有子项共享同一个分区键,使它们落入同一个项集合,再用排序键加以区分。DynamoDB 没有连接也没有外键,所以关系必须体现在键 schema 本身之中。这样一来,加载父项及其所有子项就变成单次 Query,而不是一次连接。

  • 建模读取,而非实体。 一对多关系存在的唯一目的就是服务于"列出某个工作区的项目" ——围绕这个查询来塑造键。
  • 把父项编码进子项的分区键。 让工作区及其所有项目共享同一个分区键值,使它们落入 同一个项集合
  • 于是列表读取就是一次 Query 父项加上任意数量的子项在单次计费调用中全部返回 ——没有连接,没有第二次往返。
  • 当心热分区。 一个巨大的租户会把它的全部流量集中在一个分区上;一个超大的工作区 可能需要分片键和扇出读取。

先看访问模式

DynamoDB 建模是访问模式优先,而非实体优先——这与 单表设计背后的纪律一致。在选择任何键之前,先写下 应用真正会发出的读取:

  • 获取某个工作区的设置。
  • 列出某个工作区中的每个项目,最新优先。
  • 通过 id 获取某个特定项目。

"一个工作区、多个项目"这种关系之所以重要,完全是因为读取 #2。如果你从不需要把一个 工作区的项目一起列出,你根本就不会去建模这种关系——你会把项目独立存储。

所以问题从来不是抽象地"我该怎么表示一对多?",而是"这种关系必须服务哪些查询?"。 回答它,然后围绕它塑造键。

为什么外键在这里帮不上忙

在 DynamoDB 中,每个 GetItemQuery 都针对一个分区键,服务会对该键做哈希, 以定位持有该项的分区。

AWS 在 Core Components 文档里直接讲明了这一点:分区键值是一个内部哈希函数的输入,由它决定数据存放在哪里。

这种基于哈希的放置,继承自最初 2007 年的 Dynamo: Amazon's Highly Available Key-value Store 论文,论文中用一致性哈希把键分布到各个节点。

项目项上一个光秃秃的 workspace_id 属性 对那套机制是不可见的——DynamoDB 没法"跟着它走"。

要在一次请求中取回相关项,父项的身份必须被编码进项目的分区键,这样一个工作区的 所有项才会哈希到同一个分区,一次 Query 就能把它们一扫而尽。

实例演练:工作区与项目

使用一个通用的、重载的键 schema。把分区键叫作 EntityRef,排序键叫作 Detail。 工作区的身份既进入工作区项的 EntityRef进入其下每个项目项的 EntityRef

EntityRefDetailattributes
WS#acmeMETAdisplayName, region, seatLimit
WS#acmePROJ#2026-0007title, status, createdBy
WS#acmePROJ#2026-0042title, status, createdBy
WS#acmePROJ#2026-0118title, status, createdBy
WS#globexMETAdisplayName, region, seatLimit
WS#globexPROJ#2026-0009title, status, createdBy

工作区及其所有项目共享 EntityRef = "WS#acme",因此它们构成一个单一的项集合, 一起住在同一个分区上。

Detail 排序键把它们区分开:META 是工作区记录,每个项目带一个 PROJ# 前缀,配上 零填充、按时间排序的 id,使项目自然有序。

直观地看,父项及其子项在一个分区内堆叠起来,按排序键排序:

分区:EntityRef = WS#acmeMETA 工作区设置PROJ#2026-0007PROJ#2026-0042PROJ#2026-0118

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 中返回,没有连接, 也没有第二次往返。这正是用外键属性加 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 会把它们分组,让你把一对多关系看作一个连续的整块,而不必跨越多张表去猜测。

工作区 META 项及其 PROJ# 子项在 DynoTable 表视图中作为一个项集合分组显示。
工作区 META 项及其 PROJ# 子项在 DynoTable 表视图中作为一个项集合分组显示。

陷阱与替代形态

有几点要留意:

  • 热分区。 一个工作区的每个项都住在一个分区上,所以单个非常大或非常繁忙的租户会把 流量集中起来。AWS 描述的自适应容量 行为能吸收中等程度的倾斜,但一个有数百万个项目的工作区可能需要分片键 (例如 WS#acme#01 … #10)和扇出读取。
  • 项集合大小。 在存在本地二级索引时,单个分区的项集合上限为 10 GB;没有 LSI 则没有 这种限制。如果你正在这里权衡索引类型,参见 GSI 与 LSI
  • 优先 Query,绝不用 Scan 整个设计存在的意义就是让你能 Query 一个分区。退回到 用过滤的 Scan 去"找某个工作区的项目",等于把模型扔掉,并读取整张表——这正是 Query 与 Scan 里讲到的陷阱。

如果你确实需要工作区列出项目(比如全局所有 status = ACTIVE 的项目),基础表回答不了 ——它的分区键是按工作区限定的。

那是一个需要二级索引按不同属性对项目重新分区的活儿,而不是去重塑这种关系。

下一步

建模访问模式,把父项编码进子项的分区键,一对多读取就是一次 Query。用 DynamoDB Expression Builder 构建并验证键条件。

然后下载 DynoTable加载这个 schema,实时浏览工作区→项目的项集合,并确认 每个查询恰好只做一次读取。

更新于