领先一步
VMware提供培训和认证,以快速提升您的进度。
了解更多从 Spring Data JDBC 3.2.0-M2 开始,Spring Data JDBC 支持单查询加载。单查询加载使用单个 select 语句加载任意聚合。
要启用单查询加载,您需要在 RelationalMappingContext
上调用setSingleQueryLoadingEnabled(true)
。
在 3.2.0-M2 中,这仅适用于简单的聚合,包括聚合根和单个其他实体集合。它也仅限于CrudRepository
中的findAll
、findById
和findAllByIds
方法。未来版本将改进这一点。最后的限制是您使用的数据库必须支持分析函数(又名窗口函数)。除了内存数据库(H2 和 Hsql DB)之外,所有官方支持的数据库都支持。
您可以将单查询加载缩写为SQL,但是请不要这样做。
如果您想了解其工作原理以及我们是如何想出这个方法的,请继续阅读。
从概念上讲,Spring Data JDBC 一次加载完整的聚合。但是,到目前为止,如果您查看实际运行的 SQL 语句,您会发现对于非平凡的聚合,会运行多个 SQL 语句。例如,考虑引用Hobby
集合和Toy
实体集合的Minion
类型。当 Spring Data JDBC 加载一堆这样的 minion 时,它会
SELECT ... FROM MINION
SELECT ... FROM HOBBY
SELECT ... FROM TOY
这是低效的,被称为 N+1 问题,因为对于具有单个集合要加载 N 个聚合的聚合,会执行 N+1 个查询(一个用于根,N 个用于子实体)。如果只有一个集合,您可以进行连接,但是当有多个集合时,这就会失效。
这个问题绝不是 Spring Data JDBC 特有的。其他 ORM 使用不同的策略来最大限度地减少这个问题。例如,它们可以将一个子实体连接到聚合根。或者,它们可以使用批加载来加载相关的实体。所有这些方法都限制了问题的影响,但它们仅仅是治标不治本。此外,大多数人实际上会告诉你,你实际上无法用单个查询做到这一点,因为你会得到所有子表的所有交叉积,这可能会非常糟糕。想象一下 5 个子表,每个 minion 有 10 个条目。这些的交叉积将是 10*10*10*10*10 = 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 名称 | 玩具名称 | 爱好名称 |
---|---|---|---|
1 | Bob | 泰迪熊 | 抱着泰迪熊 |
蓝光 | 看起来很可爱 | ||
跟随 Kevin | |||
2 | Kevin | ... | ... |
从查询中获得这样的结果会非常不错。用单次遍历ResultSet
从该结果构建 Java 实例不会太难。
这时我记起 SQL 实际上是图灵完备的。因此,我可以在 SQL 中表达这一点。这只是方法问题!知道问题有一个解决方案总是很有帮助。当您可以关闭脑海中试图说服您没有解决方案并且您只是在浪费时间的那个声音时,找到解决方案就会容易得多。
集合的元素通过Minion
内行的索引“连接”。但是该索引不存在于数据库中。幸运的是,您可以使用row_number()
窗口函数相当轻松地创建这样的索引。
如果您不了解窗口函数(又名分析函数),它们类似于聚合函数,但是group by
不会将所有匹配的行折叠成一行。相反,分析函数应用于由group by
定义的窗口,并且结果在每一行中都可用。并且对于组中的所有行,结果并不总是相同的。您可以使用这些函数执行更多操作。您应该阅读更多相关内容。但是对于我们当前的问题,我们只需要
row_number()
,它为组中的所有行分配一个唯一的、连续递增的数字。count(*)
,它计算组中行的数量。我知道,这很令人惊讶。我们首先为每个子表创建一个子查询。每个子查询都从底层表中选择所有列、一个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
接下来,我们使用标准左连接将所有这些子查询连接在一起
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 名称 | 玩具名称 | t_rn | 爱好名称 | h_rn |
---|---|---|---|---|---|---|
1 | 1 | Bob | 泰迪熊 | 1 | 抱着泰迪熊 | 1 |
1 | 1 | Bob | 蓝光 | 2 | 抱着泰迪熊 | 1 |
1 | 1 | Bob | 泰迪熊 | 1 | 看起来很可爱 | 2 |
1 | 1 | Bob | 蓝光 | 2 | 看起来很可爱 | 2 |
1 | 1 | Bob | 泰迪熊 | 1 | 跟随 Kevin | 3 |
1 | 1 | Bob | 蓝光 | 2 | 跟随 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 名称 | 玩具名称 | t_rn | 爱好名称 | h_rn |
---|---|---|---|---|---|---|
1 | 1 | Bob | 泰迪熊 | 1 | 抱着泰迪熊 | 1 |
1 | 1 | Bob | 蓝光 | 2 | 看起来很可爱 | 2 |
1 | 1 | Bob | 泰迪熊 | 1 | 跟随 Kevin | 3 |
2 | 1 | Kevin | ... | ... | ... | ... |
例如,对于没有匹配玩具的爱好,一个玩具的数据会重复出现。我们真正想要将其减少到null
值。在这个玩具示例中,这并没有太大区别,但这些值可能是博客文章上的长注释,需要相当长的时间才能通过网络传输。为此,我们用如下表达式替换几乎所有列:
case when x_rn = rn then name end
这里x_rn
是作为该列来源的子查询的行号。rn
是总行号——也就是所有子查询连接的行号。此条件基本上表示:如果子查询对此行有数据,则使用它;否则,只使用null
。我们对所有普通列都使用此模式。只有在下一段中描述的进一步连接中使用的列才不会使用此模式。
现在我们的结果看起来正是我们想要的。
Minion ID | m_rn | Minion 名称 | 玩具名称 | t_rn | 爱好名称 | h_rn |
---|---|---|---|---|---|---|
1 | 1 | Bob | 泰迪熊 | 1 | 抱着泰迪熊 | 1 |
1 | 1 | 蓝光 | 2 | 看起来很可爱 | 2 | |
1 | 1 | 跟随 Kevin | 3 | |||
2 | 1 | Kevin | ... | ... | ... | ... |
我们返回最少数量的行,并且没有重复数据!但我们只对单层嵌套实体这样做!这可以通过简单的递归来解决:我们得到的结果看起来就像一个简单的表。因此,它可以作为这样的表来使用。准确地说,它可以代替向select添加行号的子查询,因为它已经有了行号。
到目前为止,我们基本上查看了`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告知我们。