先行一步
VMware 提供培训和认证,助您快速提升。
了解更多从 Spring Data JDBC 3.2.0-M2 开始,Spring Data JDBC 支持单查询加载(Single Query Loading)。单查询加载能够用一个 SELECT 语句加载任意聚合(aggregate)。
要启用单查询加载,您需要在 RelationalMappingContext
上调用 setSingleQueryLoadingEnabled(true)
方法。
在 3.2.0-M2 版本中,这仅适用于简单聚合,即由一个聚合根(aggregate root)和单个其他实体集合组成的聚合。它也仅限于 CrudRepository
中的 findAll
、findById
和 findAllByIds
方法。未来版本将对此进行改进。最后一个限制是您使用的数据库必须支持分析函数(也称为窗口函数)。除内存数据库(H2 和 Hsql DB)外,所有官方支持的数据库都支持。
您可以将单查询加载缩写为 SQL,但请,别这样做。
如果您想了解它是如何工作的,以及我们是如何想到这个方法的,请继续阅读。
概念上,Spring Data JDBC 会一次性加载完整的聚合。然而,到目前为止,如果您查看实际运行的 SQL,您会发现对于非平凡的聚合,会运行多个 SQL 语句。例如,考虑一个 Minion
类型,它引用一个 Hobby
集合和一个 Toy
实体集合。当 Spring Data JDBC 加载一批这样的 Minions 时,它会:
SELECT ... FROM MINION
SELECT ... FROM HOBBY
SELECT ... FROM TOY
这效率低下,被称为 N+1 问题,因为对于包含单个集合的聚合,要加载 N 个聚合,需要执行 N+1 个查询(一个用于根,N 个用于子实体)。如果只有一个集合,您可以进行 JOIN,但当存在多个集合时,这种方法就会失效。
这个问题绝非 Spring Data JDBC 特有。其他 ORM 使用不同的策略来尽量减少这个问题。例如,它们可能会将一个子实体 JOIN 到聚合根。或者,它们可能会使用批处理加载相关实体。所有这些方法都限制了问题的影响,但它们只是治标不治本。此外,大多数人实际上会告诉您,您不能真正通过单个查询做到这一点,因为您会得到所有子表的交叉积(cross product),这可能非常糟糕。想象一下 5 个子表,每个 Minion 有 10 个条目。它们的交叉积将是 1010101010 = 10000 行!
很久以前,我记起了我的前同事 Frank Gerberding 说过的一句话:“关系数据库的问题在于它们总是返回表格,而有时你需要一个树状结构。”嗯,他是用德语说的,我不记得他的确切用词了,但大致意思是这样。这让我开始思考:确实,SQL 查询基本上总是返回一个表格。但我如何在其中表示一个树状结构呢?换句话说:你如何在 Excel 中表示一个聚合的数据?如果你忽略 Excel 本质上是一个拥有超能力的关系数据库这一事实,而只是把它当作一个单一的电子表格来处理呢?
我们先从一个相当简单的例子开始。
class Minion {
@Id
Long id;
String name;
List<Toy> toys;
// the skills you need to excel at this hobby.
List<Hobby> hobbies;
}
Toy
和 Hobby
目前都只有一个 name
属性。
如果我想在 Excel 中表示它,我可能会这样做:
Minion id | Minion name | toys name | hobbies name |
---|---|---|---|
1 | Bob | Teddy | Hold Teddy |
Blue Light | Look Cute | ||
Follow Kevin | |||
2 | Kevin | ... | ... |
从查询中获得像这样的结果会非常棒。用一次对 ResultSet
的遍历来构造 Java 实例并不困难。
这时我记起 SQL 实际上是图灵完备的。因此,我可以用 SQL 来表达这一点。问题只是如何表达!知道问题有解决方案总是很有帮助的。当你能够让你脑海中那个试图说服你没有解决方案、你只是在浪费时间的想法沉默下来时,找到解决方案就会容易得多。
集合的元素通过行在 Minion
中的索引“连接”起来。但这个索引在数据库中并不存在。幸运的是,您可以使用 row_number()
窗口函数相当容易地创建这样的索引。
如果您不了解窗口函数(也称为分析函数),它们类似于聚合函数,但 group by
不会将所有匹配的行折叠成一行。相反,分析函数应用于由 group by
定义的窗口,并且结果在每一行中都可用。而且它并不总是对组中的所有行返回相同的结果。您可以使用这些函数做更多事情。您应该了解更多。但对于我们目前手头的问题,我们只需要:
row_number()
,它为组中的所有行分配唯一且连续递增的数字。count(*)
,它计算组中的行数。我知道,很意外吧。我们从为每个子表创建一个子查询(subselect)开始。每个子查询选择底层表的所有列,一个 row_number()
和 count(*)
,每个都按 minion_id
分组。
(
select *,
row_number() over (partition by minion_id) h_rn,
count(*) over (partition by minion_id) h_cnt
from hobby
) h
我们实际上对聚合根也做了同样的事情。但是,我们不需要 row_number
,因为我们知道每行只有一个 Minion。因此,我们可以将其固定为 1。
(
select *,
1 m_rn
from minion
) m
接下来,我们使用标准的左连接(left join)将所有这些子查询连接在一起
select *
from ( ... ) m
left join
( ... ) h
on m.id = h.minion_id
left join
( ... ) t
on m.id = t.minion_id
这正是上面我声明不可接受的交叉积。
Minion id | m_rn | Minion name | toys name | t_rn | hobbies name | h_rn |
---|---|---|---|---|---|---|
1 | 1 | Bob | Teddy | 1 | Hold Teddy | 1 |
1 | 1 | Bob | Blue Light | 2 | Hold Teddy | 1 |
1 | 1 | Bob | Teddy | 1 | Look Cute | 2 |
1 | 1 | Bob | Blue Light | 2 | Look Cute | 2 |
1 | 1 | Bob | Teddy | 1 | Follow Kevin | 3 |
1 | 1 | Bob | Blue Light | 2 | Follow Kevin | 3 |
2 | 1 | Kevin | ... | ... | ... | ... |
我们想要的更类似于对不同行号的full outer join
。不幸的是,在 SQL 中,您不能在一个列上使用 left join
,在另一个列上使用 full outer join
。但我们可以用一个 where 子句来解决这个问题。
where 子句的简单版本是:
where m_rn = h_rn
and m_rn = t_rn
这忽略了我们需要外连接语义的事实。为了解决这个问题,添加了许多 is null
检查和与 cnt
列的比较,使得 where 子句相当难以阅读。而且它足够复杂,以至于我无法在不犯大量错误的情况下写下来。因此,我在此省略细节。如果您真的想知道,请继续并启用 SQL 日志记录。
有了这个,我们将行数减少到了正确的数量。太棒了!但我们仍然在复制部分数据。
Minion id | m_rn | Minion name | toys name | t_rn | hobbies name | h_rn |
---|---|---|---|---|---|---|
1 | 1 | Bob | Teddy | 1 | Hold Teddy | 1 |
1 | 1 | Bob | Blue Light | 2 | Look Cute | 2 |
1 | 1 | Bob | Teddy | 1 | Follow Kevin | 3 |
2 | 1 | Kevin | ... | ... | ... | ... |
例如,对于没有匹配玩具的兴趣爱好,一个玩具的数据会被一遍又一遍地重复。我们真正想把它减少到 null
值。在玩具示例中这差别不大,但这些值可能是博客文章上的长篇评论,通过网络传输需要花费相当多的时间。为此,我们用以下表达式替换了几乎所有列:
case when x_rn = rn then name end
这里的 x_rn
是作为列来源的子查询的行号。rn
是总行号——即所有子查询连接时所依据的行号。这个条件基本表达的意思是:如果子查询对这一行有数据,就使用它;否则,只使用 null
。我们将此模式用于所有普通列。只有用于后续连接的列(如下一段所述)才不受此处理。
现在我们的结果看起来正是我们想要的。
Minion id | m_rn | Minion name | toys name | t_rn | hobbies name | h_rn |
---|---|---|---|---|---|---|
1 | 1 | Bob | Teddy | 1 | Hold Teddy | 1 |
1 | 1 | Blue Light | 2 | Look Cute | 2 | |
1 | 1 | Follow Kevin | 3 | |||
2 | 1 | Kevin | ... | ... | ... | ... |
我们返回最少数量的行,并且没有重复的数据!但我们只对嵌套实体的一个层级这样做!这通过简单的递归解决:我们得到的结果看起来就像一个简单的表格。因此,它可以像表格一样使用。更准确地说,它可以替代添加行号的子查询使用,因为它已经有了行号。
到目前为止,我们基本上讨论了 findAll
操作的查询。大约半年前,我曾有一个适用于 findAll
但对于 findById
或 findByAddressName
等操作效率不高的解决方案。上面介绍的解决方案没有这个问题。任何 where 子句都应用于聚合根的最内层 select,并且由于连接的存在,它会限制所有数据。这得到了您无论如何都会为外键和 ID 创建的索引的良好支持,因此我们相信这种查询方式可以高效执行。
正如本文开头所概述的,这种方法目前仅在 Spring Data JDBC、简单聚合和非常特定的查询方法中实现。我们希望将其应用于所有聚合、所有 Spring Data JDBC 查询方法,甚至 Spring Data R2DBC。后者将使 Spring Data R2DBC 能够读取完整的聚合!这肯定会对您将来如何为 Spring Data Relational 指定查询产生影响。当然,使用 Spring Data Relational 的下游项目也将从中受益。Spring 的 REST 和 GraphQL 支持就属于此类。
关注这个 Github issue,了解关于此主题的更多进展。
我们找到了一种通过单个查询加载任意表格树状结构数据的方法。这与 Spring Data JDBC 完美契合,因为它处理的聚合就是这样的树状结构。生成的查询稍微复杂一些,但关系型数据库管理系统(RDBMS)应该能够高效地执行它们。
当然,我们现在正在寻找实际应用经验和反馈:您是否遇到了问题?它对您的性能有影响吗?请通过 Github 或 Stackoverflow 告知我们。