N+1 问题终结的开端:引入单查询加载(Single Query Loading)。

工程 | Jens Schauder | 2023 年 8 月 31 日 | ...

TL;DR(一句话总结)

从 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 中的 findAllfindByIdfindAllByIds 方法。未来版本将对此进行改进。最后一个限制是您使用的数据库必须支持分析函数(也称为窗口函数)。除内存数据库(H2 和 Hsql DB)外,所有官方支持的数据库都支持。

您可以将单查询加载缩写为 SQL,但请,别这样做。

如果您想了解它是如何工作的,以及我们是如何想到这个方法的,请继续阅读。

问题

概念上,Spring Data JDBC 会一次性加载完整的聚合。然而,到目前为止,如果您查看实际运行的 SQL,您会发现对于非平凡的聚合,会运行多个 SQL 语句。例如,考虑一个 Minion 类型,它引用一个 Hobby 集合和一个 Toy 实体集合。当 Spring Data JDBC 加载一批这样的 Minions 时,它会:

  1. 运行一个 SELECT ... FROM MINION
  2. 对于该查询中的每个结果,它会:
  3. 运行一个 SELECT ... FROM HOBBY
  4. 运行一个 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;
}

ToyHobby 目前都只有一个 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 来表达这一点。问题只是如何表达!知道问题有解决方案总是很有帮助的。当你能够让你脑海中那个试图说服你没有解决方案、你只是在浪费时间的想法沉默下来时,找到解决方案就会容易得多。

行号(Row Numbers)

集合的元素通过行在 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

按 ID 连接

接下来,我们使用标准的左连接(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 但对于 findByIdfindByAddressName 等操作效率不高的解决方案。上面介绍的解决方案没有这个问题。任何 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)应该能够高效地执行它们。

当然,我们现在正在寻找实际应用经验和反馈:您是否遇到了问题?它对您的性能有影响吗?请通过 GithubStackoverflow 告知我们。

获取 Spring 时事通讯

订阅 Spring 时事通讯,保持联系

订阅

先行一步

VMware 提供培训和认证,助您快速提升。

了解更多

获取支持

Tanzu Spring 通过一个简单的订阅即可为 OpenJDK™、Spring 和 Apache Tomcat® 提供支持和二进制文件。

了解更多

即将举办的活动

查看 Spring 社区所有即将举办的活动。

查看全部