进阶阅读约 3 分钟

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 中,每个 GetItemQuery 都以一个分区键为目标,服务会对该键做哈希,以定位存放该项的分区。

AWS 在 核心组件 文档中直接这样说:分区键值是内部哈希函数的输入,由它决定数据存放在哪里。

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

项目项上一个光秃秃的 workspace_id _属性_对那套机制是不可见的——DynamoDB 无法"顺着"它去查找。

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

完整示例:工作区与项目

使用一个通用的、重载(overloaded)的键 schema。把分区键命名为 EntityRef,排序键命名为 Detail。工作区的身份被放进 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 中返回,没有连接,也没有第二次往返——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 会把它们分组,让你把这层一对多关系看作一个连续的整块,而不必在分散的多张表之间猜测。

工作区的 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 构建并验证键条件——而如果你更愿意从访问模式本身出发,免费的单表设计工具会起草带示例项的 PK/SK/GSI 方案。

然后 下载 DynoTable 加载这个 schema,实时浏览工作区→项目的项集合,并确认每条查询恰好只做一次读取。如果你更想把工作区和项目看作一个连接起来的关系型视图,DynoTable 的 SQL Workbench 也能运行那个 JOIN

更新于

交互式地试用这个设计

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

打开单表设计工具