DynamoDB JOIN:如何连接表
DynamoDB 里没有 JOIN。API 没有连接运算符,数据模型没有外键,而且——最让人意外的那部分—— 这个带 SQL 味道的查询层也没有补上它。一个 PartiQL SELECT 恰好只读一张表。
如果你从关系型数据库过来,这是你撞上的第一堵墙。这份指南讲这堵墙为什么在那里、开发者转而去做的四件事、你确实需要一个真正连接的那唯一一种情形——以及如何跑一个。
DynamoDB 能做连接吗?
不能。DynamoDB 无法连接表——不能通过底层 API(GetItem / Query / Scan / BatchGetItem),不能通过 ,也不能通过任何内置的查询规划器,因为压根就没有。每次读取都映射到一张表或它的某个索引;把两张表按匹配的键组合起来,是你在 DynamoDB 返回项_之后_、在你的应用里做的事,绝不在它内部。
- DynamoDB 没有
JOIN运算符。从来没有。 - PartiQL 的
SELECT是只支持单表的——语法就是字面的SELECT … FROM {{table}}[.{{index}}],把它指向两张表会返回ValidationException: Only select from a single table or index is supported. - AWS 推荐的修复办法是让你不需要连接:,或者用单表设计,让相关的项住在一个你用单次请求就能取回的分区里。
- 对于真正的跨表/临时查询情形,你在 DynamoDB 之外做连接——在你的应用里,或者用一个替你做连接的工具。
为什么 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
INNER JOIN Inventories ON Products.Product_ID = Inventories.Product_ID
ORDER BY Quantity_on_Hand DESC很灵活,但“查询里的每一次连接都会增加查询的运行时复杂度,因为每张表的数据都必须先暂存、然后再拼装。”那份工作是无界的——它的成本取决于数据,而不是查询——而这恰恰是 DynamoDB 拒绝拥有的性质。
所以 AWS 把这个约束设计了进去。用他们的话说,DynamoDB 是“为了把 [CPU 和网络] 这两种约束都降到最低而构建的,办法是消除 JOIN(并鼓励数据反规范化),并优化数据库架构,使其用对一个项的单次请求就能完整回答一个应用查询。”正是这些性质买来了任意规模下个位数毫秒的延迟:一次 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。我们把这三种写法都对着 DynamoDB 跑了一遍,看看每一种究竟是怎么失败的。
一个显式的 JOIN:
SELECT o.pk FROM "Orders" o JOIN "Customers" c ON o.customerId = c.pkValidationException: Only select from a single table or index is supported.在 FROM 里放两张表——同样的拒绝,所以引擎拒的不是 JOIN 关键字,而是第二张表:
SELECT * FROM "Orders", "Customers"ValidationException: Only select from a single table or index is supported.子查询的失败方式不同,如果你正在调试它,这一点值得知道。PartiQL 根本走不到多表检查那一步——它在此之前就拒绝了 IN 的操作数,所以你拿到的消息压根没提到表:
SELECT * FROM "Orders" WHERE customerId IN (SELECT pk FROM "Customers")ValidationException: IN operator must have a left hand argument of type Variable
Reference and right hand argument of type Seq with at least one member没有任何写法能给你一个连接。前两种死在第二张表上,第三种死得更早,死在操作数上。
如果你想要关于 PartiQL 为什么看起来像 SQL 却不能像它那样表现的完整推理,参见 PartiQL 对比 SQL。
开发者实际使用的 4 种变通办法
1. 反规范化(把数据复制进来)
把你原本要连接的字段直接存到项上。一个 Order 携带 customerName 和 shippingAddress 的快照,而不是一个你稍后要解析的 customerId。一次读取,没有连接。
写入时的扇出就是代价。当源变化时,你要更新每一份副本(通常通过一个 处理器)。你是在用读取复杂度换写入复杂度——对一个读密集的应用来说,这通常是笔划算的交易。
2. 单表设计(在分区里预先连接)
把相关的实体放进一张表、置于一个共享的分区键下,让一个_就是_那个连接后的结果。一个客户和他们所有的订单共享 PK = "CUSTOMER#42";一次 Query 就返回客户项加上每一个订单项——那个“连接”在写入时就已经发生了。
Query PK = "CUSTOMER#42"
→ CUSTOMER#42 / PROFILE (the customer)
→ CUSTOMER#42 / ORDER#1001 (an order)
→ CUSTOMER#42 / ORDER#1002 (an order)
这是 DynamoDB 对一对多关系的经典答案。完整走查见单表设计。
3. 应用侧连接(两次读取,在代码里缝合)
从表 A 读取,拿你取回的键,去表 B 读取,再在你的应用里合并这两个结果集。这就是关系型连接的逻辑——只是跑在你的代码里而不是数据库里:
// "Get each order with its customer name" — the manual join.
const {Items: orders} = await ddb.query({TableName: 'Orders' /* … */});
const customers = await Promise.all(
orders.map((o) => ddb.get({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 无法回避
那四种变通办法很好地覆盖了生产读取路径。它们撑不住的地方是临时的、探索性的、分析性的查询——你没为它建过模的那种:
- “上个月哪些欧盟客户下过一笔超过 500 美元的订单?”——横跨一张
Orders表和一张Customers表。 - 一次性的、连接两种实体类型的数据质量检查。
- 报表与聚合(
GROUP BY、SUM、COUNT)——这些 DynamoDB 压根没有对应的运算符。
这些恰恰是你没法预先烘焙进一个分区的查询,因为按定义你当初不知道自己会问它们。那种关系型直觉——写一个 JOIN——在这里是对的。只是 DynamoDB 原生服务不了它,PartiQL 也不行。
通常那个重量级的答案是导出到 S3 并用 Athena 查询(或用 Athena 的联邦查询连接器去 JOIN 一张实时表),或者管道灌进一个数据仓库。对于真正的规模化分析,这是对的,但对一个你想_现在_就针对实时表得到答案的问题来说,这是一大堆管道活。
用 DynoTable 的 SQL Workbench 跑一个真正的 JOIN
DynoTable 是一个桌面 DynamoDB 客户端,它的 SQL Workbench 能在你的 DynamoDB 表上跑真正的 SQL——包括 JOIN、GROUP BY 和聚合函数。它通过正常的 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 问题_,而不必自己手写缝合,并告诉你它底下在做什么。
在各种 GUI 客户端里,它是唯一一个真正为真的“是的,你能连接”:PartiQL 和 AWS 自家的 NoSQL Workbench——它的操作构建器跑单表操作和 PartiQL 语句(没有 JOIN,没有多表 SELECT)——都止步于单表这堵墙,大多数其他 GUI 客户端也是。看看 DynoTable 作为一个 DynamoDB GUI 如何比较。
常见问题
PartiQL 支持 JOIN 吗?
不支持。PartiQL 的 SELECT 读单张表(或它的某个索引)。多表查询会返回 ValidationException: Only select from a single table or index is supported. 和 API 其余部分是同一堵墙。
你能在一次查询里连接两张 DynamoDB 表吗?
原生不能。DynamoDB API 没有任何语句能读两张表并按键匹配它们。BatchGetItem 能在一次请求里从多张表读取项,但它没有 ON 条件——它返回你按主键点名的那些项,把匹配留给你。一个真正的 JOIN … ON … 只发生在 DynamoDB 之外:在你的应用里,或在 DynoTable 的 SQL Workbench 里。
你能把一张表连接到它的 GSI 吗?
不能——一个全局二级索引不是你可以连接过去的一张独立的表;它是同一批项的一个替代键视图。在某个给定的 SELECT 里,你 Query 表_或者_索引二选一,而不是把两者连接到一起。GSI 让你能用一个不同的键_触及_项,这往往一开始就消除了连接的需要。
你能跨两个 AWS 账户(或不同账户里的两张表)连接吗?
原生不能——没有跨账户连接的原语。如果另一个账户那张表的基于资源的策略授予了你的调用方访问权(自 2024 年 3 月起),BatchGetItem 能触及那张表,但它仍然没有 ON 条件,所以那是一次多表读取,不是连接。你得读取每一侧,再在你的应用里或在像 DynoTable 的 Workbench 这样的工具里连接结果。
反规范化真的比连接更好吗? 对于 DynamoDB 的目标工作负载——可预测的、高流量的读取——是的。你把成本挪到写入时(并接受一些数据重复),换来能平坦扩展的单请求读取。单表设计指南讲了这些取舍。
手工为这些读取构建键和条件很琐碎——表达式构建器替你生成 KeyConditionExpression / FilterExpression 语法,而当一个变通办法不够用时,DynoTable 跑真正的 SQL。
PartiQL 的拒绝消息于 2026-08-11 针对 DynamoDB Local(us-east-1,@aws-sdk/client-dynamodb v3.1096.0)复现,逐字引用自 ValidationException.message。