进阶阅读约 3 分钟

DynamoDB JOIN:如何连接表(以及为什么你通常做不到)

DynamoDB 中没有 JOIN。API 没有连接运算符,数据模型 没有外键,而且——最让大多数人意外的部分——PartiQL,这个 SQL 风味的查询层,也没有添加连接。 一个 PartiQL SELECT 只读 正好一张表。

如果你来自关系型数据库,这是你撞上的第一堵墙。本 指南涵盖这堵墙为什么在那里、开发者转而采用的四种做法、 你确实需要真正连接的那一种情况——以及如何运行一个。

DynamoDB 能做连接吗?

不能。DynamoDB 无法连接表——既不能通过底层 API(GetItem / Query / Scan / BatchGetItem),也不能通过 PartiQL,更不能通过任何 内置的查询规划器,因为根本没有查询规划器。每次读取都映射到 单张表或它的某个索引。在匹配键上合并两张表是你 在 DynamoDB 交回项之后做的事,永远不在它内部。

  • DynamoDB 没有 JOIN 运算符。从来没有过。
  • PartiQL 的 SELECT单表的——语法字面上是 SELECT … FROM {{table}}[.{{index}}],对它指向两张表会返回 ValidationException: Only Select from a Single Table or index supported
  • AWS 推荐的修复是不需要连接:反范式化,或使用 单表设计,让相关项住在 你能在单次请求中获取的一个分区里。
  • 对于真正的跨表 / 临时场景,你在 DynamoDB 之外连接——在 你的应用中,或用一个为你完成连接的工具。

DynamoDB 能做连接吗?

不能。DynamoDB 无法连接表——既不能通过底层 API(GetItem / Query / Scan / BatchGetItem),也不能通过 PartiQL,更不能通过任何 内置的查询规划器,因为根本没有查询规划器。每次读取都映射到 单张表或它的某个索引。在匹配键上合并两张表是你 在 DynamoDB 交回项之后做的事,永远不在它内部。

这不是 AWS 忘了填的空缺。它是一个刻意的设计决定,其中的 道理值得在你去抓变通方法之前理解。

DynamoDB 为什么没有连接

SQL JOIN 要求数据库读取多张表并在查询时 组装它们。AWS 自己的 关系数据建模指南 讲清了代价:像这样的查询

SELECT * FROM Orders
  INNER JOIN Order_Items ON Orders.Order_ID = Order_Items.Order_ID
  INNER JOIN Products    ON Products.Product_ID = Order_Items.Product_ID
  ORDER BY Quantity_on_Hand DESC

是灵活的,但"查询中的每个连接都增加查询的运行时 复杂度,因为每张表的数据必须暂存然后被组装。" 那项 工作是无界的——它的成本取决于数据,而非查询——这 正是 DynamoDB 拒绝拥有的属性。

所以 AWS 把这个约束设计进去了。用他们的话说,DynamoDB "通过消除 JOIN(并鼓励数据反范式化)来最小化 [CPU 和网络] 两方面的约束,并优化数据库架构, 以便用对一个项的单次请求完全回答应用查询。" 这些就是 在任意规模下买来个位数毫秒延迟的品质:DynamoDB 读取的 运行时成本与表大小无关,是恒定的。从设计上就没有连接引擎, 也没有可供规划的外键概念。

"可是 PartiQL 是 SQL,它肯定能连接吧?"

不能。PartiQL 给你提供针对 DynamoDB 的 SELECT / INSERT / UPDATE / DELETE 语法,但它是 SQL兼容的,而非 SQL。 官方 SELECT 语法 是:

SELECT  {{expression}}  [, ...]
FROM    {{table}}[.{{index}}]
[ WHERE {{condition}} ]
[ ORDER BY {{key}} [DESC|ASC], ... ]

FROM 接受一张表(可选地它的某个索引)。没有第二个 FROM 表,没有 JOIN,没有子查询,没有 CTE。对 PartiQL 指向两张表, DynamoDB 会拒绝它 (在 AWS re:Post 上报告):

ValidationException: Only Select from a Single Table or index supported

如果你想要 PartiQL 看起来像 SQL 却不能表现得像它的完整 道理,参见 PartiQL vs SQL

开发者实际使用的 4 种变通方法

1. 反范式化(把数据复制进来)

把你本来要连接的字段直接存到项上。一个 Order 携带 customerNameshippingAddress 的快照,而非一个你之后要 解析的 customerId。一次读取,无连接。

代价是写入时的扇出:当源变化时你要更新每一份 副本(通常通过一个 DynamoDB Streams 处理器)。你是在用读取复杂度 换写入复杂度——对于读密集型应用来说通常是划算的交易。

2. 单表设计(在分区中预连接)

把相关实体放在一张表中,在一个共享的分区键下,让一个 项集合就是连接的结果。一个客户和他的所有订单共享 PK = "CUSTOMER#42";一次 Query 返回客户项加上每个订单 项——"连接"已经在写入时发生了。

Query  PK = "CUSTOMER#42"
→ CUSTOMER#42 / PROFILE      (客户)
→ CUSTOMER#42 / ORDER#1001   (一个订单)
→ CUSTOMER#42 / ORDER#1002   (一个订单)

这是 DynamoDB 对一对多关系的标准答案。完整 讲解见单表设计

3. 应用侧连接(两次读取,在代码中拼接)

从表 A 读取,拿你拿到的键去表 B 读取,再在你的 应用中合并这两个结果集。这就是关系型连接逻辑——只是 运行在你的代码里而非数据库里:

// "获取每个订单及其客户名"——手动连接。
const {Items: orders} = await ddb.query({TableName: 'Orders' /* … */});

const customers = await Promise.all(
  orders.map((o) => ddb.getItem({TableName: 'Customers', Key: {id: o.customerId}}))
);

const joined = orders.map((o, i) => ({
  ...o,
  customerName: customers[i].Item?.name
}));

对于小扇出没问题。订单很多时它会变成 N+1 问题——一次读取 列出订单,然后每个订单一次读取——又慢又烧读容量。 BatchGetItem(下一节)把第二波读取压缩成一次往返。

4. BatchGetItem(一次往返,多张表)

BatchGetItem 是该 API 最接近"一次触及两张表"的东西:一次 请求返回"来自一张或多张表的一个或多个项的属性", 每次调用上限为 100 个项或 16 MB,以先到者为准。它 削减了应用侧连接的往返次数——但它不是连接。你 "用主键标识请求的项";没有 ON 条件,也没有 关系匹配。你仍然必须提前知道键,并自己拼接 响应。

当真正的 JOIN 无法避免时

这四种变通方法很好地覆盖了生产读取路径。它们失效之处是 临时的、探索性的、分析型的查询——你没为之建模的那种:

  • 跨一张 Orders 表和一张 Customers 表的*"上个月在欧盟下了一笔超过 $500 订单的客户是哪些?"*
  • 连接两种实体类型的一次性数据质量检查。
  • 报表和聚合(GROUP BYSUMCOUNT)——DynamoDB 对此 根本没有运算符。

这些正是你无法预先烘焙进一个分区的查询,因为按 定义你不知道你会问它们。关系型的直觉——写一个 JOIN——在这里是对的。DynamoDB 只是无法原生服务它, PartiQL 也不行。

通常的重量级答案是 导出到 S3 并用 Athena 查询, 或导入数仓。对于真正大规模的分析这是对的,但对于一个你想 现在、对你的实时表回答的问题,这是一堆繁琐的管道工程。

用 DynoTable 的 SQL Workbench 运行真正的 JOIN

DynoTable 是一款桌面 DynamoDB 客户端,其 SQL Workbench 运行 真正的 SQL——包括 JOINGROUP BY 和聚合函数——对你的 DynamoDB 表执行。它通过常规的 DynamoDB API 读取项,然后 在客户端执行查询的关系型部分。所以你可以写:

SELECT  c.name, SUM(o.total) AS spend
FROM    Customers c
JOIN    Orders o ON o.customerId = c.id
WHERE   c.region = 'EU'
GROUP BY c.name
HAVING  SUM(o.total) > 500

——并得到一个结果集,针对的是没有定义关系的表,以及一个 没有 JOIN 关键字的查询引擎。

诚实的告诫——"在 DynamoDB 的访问模式规则内":Workbench 仍然通过 DynamoDB 读取,所以无界连接就是无界读取。 最快的查询是那些 WHERE 子句(或连接的 ON 属性)在至少一侧命中分区键或 GSI 的查询, 这样在连接执行前 DynamoDB 运行的是 Query 而非整表 扫描。Workbench 不会废除 本指南中的约束——它只是让你提出 SQL 问题 而非自己手写拼接,并告诉你它在底层做了什么。

它是唯一一个"是的,你可以连接"且真正成立的工具:PartiQL 和 AWS 自己的 NoSQL Workbench ——其操作构建器限于单表数据平面操作 (Query / Scan / GetItem)——都止步于单表墙,大多数 其他 GUI 客户端也是如此。看看 DynoTable 作为 DynamoDB GUI 的对比。

常见问题

PartiQL 支持 JOIN 吗? 不支持。PartiQL 的 SELECT 读取单张表(或它的某个索引)。一个 多表查询返回 ValidationException: Only Select from a Single Table or index supported。与该 API 其余部分是同一堵墙。

你能在一次查询中连接两张 DynamoDB 表吗? 原生不能。DynamoDB API 没有读取两张表并在键上匹配 它们的语句。BatchGetItem 能在一次请求中从多张表读取项, 但它没有 ON 条件——它返回你按主键指定的项, 并把匹配留给你。真正的 JOIN … ON … 只发生在 DynamoDB 之外: 在你的应用中,或在 DynoTable 的 SQL Workbench 中。

你能把一张表连接到它的 GSI 吗? 不能——全局辅助索引不是一张你 连接的独立表;它是同一批项的另一种键视图。在给定的 SELECT 中,你查询要么要么索引,不能把两者连接在一起。GSI 让你能用不同的键触达项,这往往一开始就消除了 连接的需要。

你能跨两个 AWS 账户连接吗(或连接不同账户中的两张表)? 原生不能,用 BatchGetItem 也不能——单次请求无法跨越 凭证,也没有跨账户连接原语。你需要用各自账户的凭证 读取每张表,并在你的应用中或在 DynoTable 的 Workbench 这样的工具中连接结果。

反范式化真的比连接好吗? 对于 DynamoDB 的目标工作负载——可预测的、高吞吐的读取——是的。你把 成本移到写入时(并接受一些数据重复),换来 随规模平稳扩展的单次请求读取。 单表设计指南涵盖了这些取舍。


手动构建这些读取的键和条件很繁琐—— 表达式构建器为你生成 KeyConditionExpression / FilterExpression 语法,而 DynoTable 在变通方法行不通时运行真正的 SQL。

更新于