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 携带
customerName 和 shippingAddress 的快照,而非一个你之后要
解析的 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 BY、SUM、COUNT)——DynamoDB 对此 根本没有运算符。
这些正是你无法预先烘焙进一个分区的查询,因为按
定义你不知道你会问它们。关系型的直觉——写一个
JOIN——在这里是对的。DynamoDB 只是无法原生服务它,
PartiQL 也不行。
通常的重量级答案是 导出到 S3 并用 Athena 查询, 或导入数仓。对于真正大规模的分析这是对的,但对于一个你想 现在、对你的实时表回答的问题,这是一堆繁琐的管道工程。
用 DynoTable 的 SQL Workbench 运行真正的 JOIN
DynoTable 是一款桌面 DynamoDB 客户端,其 SQL Workbench 运行
真正的 SQL——包括 JOIN、GROUP 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。