这是 N+1 问题终结的开始:引入单查询加载。

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

太长不看

从 Spring Data JDBC 3.2.0-M2 开始,Spring Data JDBC 支持单查询加载。单查询加载使用单个 select 语句加载任意聚合。

要启用单查询加载,您需要在您的 RelationalMappingContext 上调用 setSingleQueryLoadingEnabled(true)

在 3.2.0-M2 中,这仅适用于简单的聚合,由一个聚合根和单个实体集合组成。它也仅限于 CrudRepository 中的 findAllfindByIdfindAllByIds 方法。未来版本将对此进行改进。最后一个限制是您使用的数据库必须支持分析函数(又名窗口函数)。除了内存数据库(H2 和 Hsql DB)之外,所有官方支持的数据库都支持。

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

如果您想了解它是如何工作的,以及我们是如何想出它的,请继续阅读。

问题

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

  1. 运行 SELECT ... FROM MINION
  2. 对于该查询中的每个结果,它会
  3. 运行 SELECT ... FROM HOBBY
  4. 运行 SELECT ... FROM TOY

这是低效的,被称为 N+1 问题,因为对于一个具有单个集合的聚合,要加载 N 个聚合,需要执行 N+1 个查询(一个用于根,N 个用于子实体)。如果只有一个单个集合,您可以进行连接,但当有多个集合时,这就会失效。

这个问题绝非 Spring Data JDBC 所特有。其他 ORM 使用不同的策略来最小化这个问题。例如,它们可能会将一个子实体连接到聚合根。或者,它们可能会对相关实体使用批量加载。所有这些方法都限制了问题的影响,但它们只是治标不治本。此外,大多数人实际上会告诉您,您不能真正地在一个查询中完成此操作,因为您将获得所有子表的笛卡尔积,这可能会非常糟糕。想象一下 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 中表达这一点。这只是一个如何表达的问题!知道一个问题有解决方案总是有帮助的。当你能够压制脑海中试图说服你没有解决方案,你只是在浪费时间的那个声音时,找到解决方案就会容易得多。

行号

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

按 ID 连接

接下来,我们将所有这些子查询与标准左连接连接起来

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 子句都应用于聚合根的最内层选择,并且由于连接,它会限制所有数据。这得到了您为外键和 ID 创建的索引的良好支持,因此我们相信这种查询方式可以高效执行。

展望

如本文开头所述,此方法目前仅适用于 Spring Data JDBC、简单的聚合和非常特定的查询方法。我们希望将其提供给所有聚合、所有 Spring Data JDBC 查询方法,甚至 Spring Data R2DBC。后者将使 Spring Data R2DBC 能够读取完整的聚合!它肯定会对您未来指定 Spring Data Relational 查询的方式产生影响。当然,使用 Spring Data Relational 的下游项目也将受益于此。Spring 的 REST 和 GraphQL 支持就是很好的例子。

关注此 Github 问题以了解有关此主题的更多进展。

结论

我们找到了一种通过单个查询从任意表树加载数据的方法。这非常适合 Spring Data JDBC,因为它处理的聚合就是这样的树。生成的查询稍微复杂一些,但 RDBMS 应该能够高效地执行它们。

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

获取 Spring 新闻通讯

通过 Spring 新闻通讯保持联系

订阅

领先一步

VMware 提供培训和认证,助您加速进步。

了解更多

获得支持

Tanzu Spring 提供 OpenJDK™、Spring 和 Apache Tomcat® 的支持和二进制文件,只需一份简单的订阅。

了解更多

即将举行的活动

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

查看所有