“N+1 选择问题”通常在对象关系映射 (ORM) 讨论中被表述为一个问题,我知道这与必须对对象中看似简单的东西进行大量数据库查询有关世界。
有人对这个问题有更详细的解释吗?
假设您有一个 Car
对象(数据库行)的集合,每个 Car
都有一个 Wheel
对象(也是行)的集合。换句话说,Car
→ Wheel
是一对多的关系。
现在,假设您需要遍历所有汽车,并为每辆汽车打印出车轮列表。天真的 O/R 实现将执行以下操作:
SELECT * FROM Cars;
然后对于每个Car
:
SELECT * FROM Wheel WHERE CarId = ?
换句话说,您有一个用于 Cars 的选择,然后是 N 个额外的选择,其中 N 是汽车的总数。
或者,可以获取所有轮子并在内存中执行查找:
SELECT * FROM Wheel
这将数据库的往返次数从 N+1 减少到 2。大多数 ORM 工具为您提供了几种防止 N+1 选择的方法。
参考:Java Persistence with Hibernate,第 13 章。
什么是 N+1 查询问题
N+1 查询问题发生在数据访问框架执行 N 个附加 SQL 语句以获取执行主 SQL 查询时可能已检索到的相同数据时。
N值越大,执行的查询越多,对性能的影响越大。而且,与可以帮助您找到运行缓慢的查询的慢查询日志不同,N+1 问题不会被发现,因为每个单独的附加查询运行速度足够快,不会触发慢查询日志。
问题在于执行大量附加查询,总体而言,这些查询需要足够的时间来减慢响应时间。
假设我们有以下 post 和 post_comments 数据库表,它们形成了一对多的表关系:
https://i.stack.imgur.com/T1uWG.png
我们将创建以下 4 个 post
行:
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 1', 1)
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 2', 2)
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 3', 3)
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 4', 4)
而且,我们还将创建 4 个 post_comment
子记录:
INSERT INTO post_comment (post_id, review, id)
VALUES (1, 'Excellent book to understand Java Persistence', 1)
INSERT INTO post_comment (post_id, review, id)
VALUES (2, 'Must-read for Java developers', 2)
INSERT INTO post_comment (post_id, review, id)
VALUES (3, 'Five Stars', 3)
INSERT INTO post_comment (post_id, review, id)
VALUES (4, 'A great reference book', 4)
纯 SQL 的 N+1 查询问题
如果您使用此 SQL 查询选择 post_comments
:
List<Tuple> comments = entityManager.createNativeQuery("""
SELECT
pc.id AS id,
pc.review AS review,
pc.post_id AS postId
FROM post_comment pc
""", Tuple.class)
.getResultList();
然后,您决定为每个 post_comment
获取关联的 post
title
:
for (Tuple comment : comments) {
String review = (String) comment.get("review");
Long postId = ((Number) comment.get("postId")).longValue();
String postTitle = (String) entityManager.createNativeQuery("""
SELECT
p.title
FROM post p
WHERE p.id = :postId
""")
.setParameter("postId", postId)
.getSingleResult();
LOGGER.info(
"The Post '{}' got this review '{}'",
postTitle,
review
);
}
您将触发 N+1 查询问题,因为您执行了 5 (1 + 4) 而不是一个 SQL 查询:
SELECT
pc.id AS id,
pc.review AS review,
pc.post_id AS postId
FROM post_comment pc
SELECT p.title FROM post p WHERE p.id = 1
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
SELECT p.title FROM post p WHERE p.id = 2
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
SELECT p.title FROM post p WHERE p.id = 3
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
SELECT p.title FROM post p WHERE p.id = 4
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'
修复 N+1 查询问题非常容易。您需要做的就是提取原始 SQL 查询中所需的所有数据,如下所示:
List<Tuple> comments = entityManager.createNativeQuery("""
SELECT
pc.id AS id,
pc.review AS review,
p.title AS postTitle
FROM post_comment pc
JOIN post p ON pc.post_id = p.id
""", Tuple.class)
.getResultList();
for (Tuple comment : comments) {
String review = (String) comment.get("review");
String postTitle = (String) comment.get("postTitle");
LOGGER.info(
"The Post '{}' got this review '{}'",
postTitle,
review
);
}
这一次,只执行一个 SQL 查询来获取我们进一步感兴趣使用的所有数据。
JPA 和 Hibernate 的 N+1 查询问题
在使用 JPA 和 Hibernate 时,有几种方法可以触发 N+1 查询问题,因此了解如何避免这些情况非常重要。
对于下一个示例,假设我们将 post
和 post_comments
表映射到以下实体:
https://i.stack.imgur.com/rZJne.png
JPA 映射如下所示:
@Entity(name = "Post")
@Table(name = "post")
public class Post {
@Id
private Long id;
private String title;
//Getters and setters omitted for brevity
}
@Entity(name = "PostComment")
@Table(name = "post_comment")
public class PostComment {
@Id
private Long id;
@ManyToOne
private Post post;
private String review;
//Getters and setters omitted for brevity
}
FetchType.EAGER
对 JPA 关联隐式或显式使用 FetchType.EAGER
是一个坏主意,因为您将获取更多所需的数据。此外,FetchType.EAGER
策略也容易出现 N+1 查询问题。
不幸的是,@ManyToOne
和 @OneToOne
关联默认使用 FetchType.EAGER
,因此如果您的映射如下所示:
@ManyToOne
private Post post;
您正在使用 FetchType.EAGER
策略,并且每次在使用 JPQL 或 Criteria API 查询加载某些 PostComment
实体时忘记使用 JOIN FETCH
:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
您将触发 N+1 查询问题:
SELECT
pc.id AS id1_1_,
pc.post_id AS post_id3_1_,
pc.review AS review2_1_
FROM
post_comment pc
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4
请注意执行的附加 SELECT 语句,因为必须在返回 PostComment
实体的 List
之前获取 post
关联。
与调用 EntityManager
的 find
方法时使用的默认提取计划不同,JPQL 或 Criteria API 查询定义了一个显式计划,Hibernate 无法通过自动注入 JOIN FETCH 来更改该计划。因此,您需要手动执行此操作。
如果您根本不需要 post
关联,那么您在使用 FetchType.EAGER
时就不走运了,因为无法避免获取它。这就是为什么最好默认使用 FetchType.LAZY
。
但是,如果你想使用 post
关联,那么你可以使用 JOIN FETCH
来避免 N+1 查询问题:
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
join fetch pc.post p
""", PostComment.class)
.getResultList();
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
这一次,Hibernate 将执行一条 SQL 语句:
SELECT
pc.id as id1_1_0_,
pc.post_id as post_id3_1_0_,
pc.review as review2_1_0_,
p.id as id1_0_1_,
p.title as title2_0_1_
FROM
post_comment pc
INNER JOIN
post p ON pc.post_id = p.id
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'
FetchType.LAZY
即使您切换到对所有关联显式使用 FetchType.LAZY
,您仍然会遇到 N+1 问题。
这一次,post
关联映射如下:
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
现在,当您获取 PostComment
实体时:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Hibernate 将执行一条 SQL 语句:
SELECT
pc.id AS id1_1_,
pc.post_id AS post_id3_1_,
pc.review AS review2_1_
FROM
post_comment pc
但是,如果之后,您将引用延迟加载的 post
关联:
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
您将收到 N+1 查询问题:
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'
由于 post
关联是延迟获取的,因此在访问延迟关联时将执行辅助 SQL 语句以构建日志消息。
同样,修复包括向 JPQL 查询添加 JOIN FETCH
子句:
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
join fetch pc.post p
""", PostComment.class)
.getResultList();
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
而且,就像在 FetchType.EAGER
示例中一样,此 JPQL 查询将生成单个 SQL 语句。
即使您使用 FetchType.LAZY 并且不引用双向 @OneToOne JPA 关系的子关联,您仍然可以触发 N+1 查询问题。
如何自动检测 N+1 查询问题
如果您想在数据访问层中自动检测 N+1 查询问题,您可以使用 db-util
开源项目。
首先,您需要添加以下 Maven 依赖项:
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>db-util</artifactId>
<version>${db-util.version}</version>
</dependency>
之后,您只需使用 SQLStatementCountValidator
实用程序来断言生成的底层 SQL 语句:
SQLStatementCountValidator.reset();
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
SQLStatementCountValidator.assertSelectCount(1);
如果您使用 FetchType.EAGER
并运行上述测试用例,您将得到以下测试用例失败:
SELECT
pc.id as id1_1_,
pc.post_id as post_id3_1_,
pc.review as review2_1_
FROM
post_comment pc
SELECT p.id as id1_0_0_, p.title as title2_0_0_ FROM post p WHERE p.id = 1
SELECT p.id as id1_0_0_, p.title as title2_0_0_ FROM post p WHERE p.id = 2
-- SQLStatementCountMismatchException: Expected 1 statement(s) but recorded 3 instead!
SELECT cars, wheels FROM cars JOIN wheels LIMIT 0, 5
。但是你得到的是 2 辆有 5 个轮子的汽车(第一辆有 4 个轮子的汽车,第二辆只有 1 个轮子的汽车),因为 LIMIT 会限制整个结果集,而不仅仅是根子句。
join fetch
吗?
SELECT
table1.*
, table2.*
INNER JOIN table2 ON table2.SomeFkId = table1.SomeId
这将为您提供一个结果集,其中 table2 中的子行通过返回 table2 中每个子行的 table1 结果来导致重复。 O/R 映射器应根据唯一键字段区分 table1 实例,然后使用所有 table2 列填充子实例。
SELECT table1.*
SELECT table2.* WHERE SomeFkId = #
N+1 是第一个查询填充主对象的位置,第二个查询填充每个返回的唯一主对象的所有子对象。
考虑:
class House
{
int Id { get; set; }
string Address { get; set; }
Person[] Inhabitants { get; set; }
}
class Person
{
string Name { get; set; }
int HouseId { get; set; }
}
和具有类似结构的表。对地址“22 Valley St”的单个查询可能会返回:
Id Address Name HouseId
1 22 Valley St Dave 1
1 22 Valley St John 1
1 22 Valley St Mike 1
O/RM 应该用 ID=1、Address="22 Valley St" 填充 Home 的实例,然后用 Dave、John 和 Mike 的 People 实例填充 Inhabitants 数组,只需一个查询。
对上面使用的相同地址的 N+1 查询将导致:
Id Address
1 22 Valley St
使用单独的查询,例如
SELECT * FROM Person WHERE HouseId = 1
并产生一个单独的数据集,如
Name HouseId
Dave 1
John 1
Mike 1
并且最终结果与上面的单个查询相同。
单选的优点是您可以预先获得所有数据,这可能是您最终想要的。 N+1 的优点是降低了查询复杂性,并且您可以使用延迟加载,其中子结果集仅在第一次请求时加载。
与产品具有一对多关系的供应商。一个供应商拥有(供应)许多产品。
***** Table: Supplier *****
+-----+-------------------+
| ID | NAME |
+-----+-------------------+
| 1 | Supplier Name 1 |
| 2 | Supplier Name 2 |
| 3 | Supplier Name 3 |
| 4 | Supplier Name 4 |
+-----+-------------------+
***** Table: Product *****
+-----+-----------+--------------------+-------+------------+
| ID | NAME | DESCRIPTION | PRICE | SUPPLIERID |
+-----+-----------+--------------------+-------+------------+
|1 | Product 1 | Name for Product 1 | 2.0 | 1 |
|2 | Product 2 | Name for Product 2 | 22.0 | 1 |
|3 | Product 3 | Name for Product 3 | 30.0 | 2 |
|4 | Product 4 | Name for Product 4 | 7.0 | 3 |
+-----+-----------+--------------------+-------+------------+
因素:
供应商的惰性模式设置为“true”(默认)
用于查询 Product 的 Fetch 模式是 Select
获取模式(默认):访问供应商信息
缓存第一次没有发挥作用
供应商被访问
获取模式是选择获取(默认)
// It takes Select fetch mode as a default
Query query = session.createQuery( "from Product p");
List list = query.list();
// Supplier is being accessed
displayProductsListWithSupplierName(results);
select ... various field names ... from PRODUCT
select ... various field names ... from SUPPLIER where SUPPLIER.id=?
select ... various field names ... from SUPPLIER where SUPPLIER.id=?
select ... various field names ... from SUPPLIER where SUPPLIER.id=?
结果:
1 选择产品声明
供应商的 N 条选择语句
这是 N+1 选择问题!
我不能直接评论其他答案,因为我没有足够的声誉。但值得注意的是,问题的出现本质上只是因为从历史上看,许多 dbms 在处理连接方面一直很差(MySQL 是一个特别值得注意的例子)。因此,n+1 通常比连接快得多。然后有一些方法可以改进 n+1 但仍然不需要连接,这就是最初的问题所涉及的。
然而,在连接方面,MySQL 现在比以前好多了。当我第一次学习 MySQL 时,我经常使用连接。然后我发现它们有多慢,并在代码中切换到 n+1 。但是,最近,我又回到了连接,因为 MySQL 现在在处理它们方面比我刚开始使用它时要好得多。
如今,在性能方面,对一组正确索引的表进行简单的连接很少成为问题。如果它确实会影响性能,那么使用索引提示通常可以解决它们。
MySQL 开发团队之一在此处讨论了这一点:
http://jorgenloland.blogspot.co.uk/2013/02/dbt-3-q3-6-x-performance-in-mysql-5610.html
所以总结是:如果您过去因为 MySQL 的糟糕性能而一直在避免连接,那么请在最新版本上重试。你可能会感到惊喜。
JOIN
算法之一称为嵌套循环。它基本上是引擎盖下的 N+1 选择。唯一的区别是数据库做出了明智的选择,根据统计数据和索引使用它,而不是客户端代码明确地强制它走这条路。
由于这个问题,我们离开了 Django 中的 ORM。基本上,如果你尝试去做
for p in person:
print p.car.colour
ORM 将愉快地返回所有人员(通常作为 Person 对象的实例),但随后它需要查询每个 Person 的 car 表。
一种简单且非常有效的方法是我称之为“折叠”的方法,它避免了来自关系数据库的查询结果应该映射回组成查询的原始表的荒谬想法。
第 1 步:广泛选择
select * from people_car_colour; # this is a view or sql function
这将返回类似
p.id | p.name | p.telno | car.id | car.type | car.colour
-----+--------+---------+--------+----------+-----------
2 | jones | 2145 | 77 | ford | red
2 | jones | 2145 | 1012 | toyota | blue
16 | ashby | 124 | 99 | bmw | yellow
第 2 步:客观化
将结果吸入通用对象创建器,并在第三项之后拆分参数。这意味着“琼斯”对象不会被多次制作。
第 3 步:渲染
for p in people:
print p.car.colour # no more car queries
有关 python 的 fanfolding 实现,请参阅 this web page。
select_related
,它旨在解决这个问题 - 事实上,它的文档以类似于您的 p.car.colour
示例的示例开头。
select_related()
和 prefetch_related()
。
select_related()
和朋友似乎没有对诸如 LEFT OUTER JOIN
的连接进行任何明显有用的推断。问题不是接口问题,而是与对象和关系数据是可映射的奇怪想法有关的问题......在我看来。
Here's a good description of the problem
现在您了解了这个问题,通常可以通过在查询中执行连接提取来避免它。这基本上强制获取延迟加载的对象,以便在一个查询而不是 n+1 个查询中检索数据。希望这可以帮助。
假设您有 COMPANY 和 EMPLOYEE。 COMPANY 有许多 EMPLOYEES(即 EMPLOYEE 有一个字段 COMPANY_ID)。
在某些 O/R 配置中,当您有一个映射的 Company 对象并访问其 Employee 对象时,O/R 工具将为每个员工执行一次选择,而如果您只是在直接 SQL 中执行操作,您可以{1 }。因此 N(员工人数)加 1(公司)
这就是 EJB 实体 Bean 的初始版本的工作方式。我相信像 Hibernate 这样的东西已经消除了这一点,但我不太确定。大多数工具通常包含有关其映射策略的信息。
查看 Ayende 关于主题的帖子:Combating the Select N + 1 Problem In NHibernate。
基本上,当使用像 NHibernate 或 EntityFramework 这样的 ORM 时,如果您有一对多(主从)关系,并且想要列出每个主记录的所有详细信息,您必须对数据库,“N”是主记录的数量:1 次查询获取所有主记录,N 次查询,每个主记录一个,获取每个主记录的所有详细信息。
更多数据库查询调用 → 更多延迟时间 → 降低应用程序/数据库性能。
但是,ORM 有一些选项可以避免这个问题,主要是使用 JOIN。
发出 1 个返回 100 个结果的查询比发出 100 个每个返回 1 个结果的查询要快得多。
在我看来,Hibernate Pitfall: Why Relationships Should Be Lazy中写的文章与真正的 N+1 问题完全相反。
如果您需要正确的解释,请参考Hibernate - Chapter 19: Improving Performance - Fetching Strategies
Select fetching(默认)极易受到 N+1 选择问题的影响,因此我们可能希望启用 join fetching
提供的链接有一个非常简单的 n + 1 问题示例。如果将它应用于 Hibernate,它基本上是在谈论同一件事。当您查询对象时,会加载实体,但任何关联(除非另有配置)都将被延迟加载。因此,对根对象的一个查询和另一个为每个对象加载关联的查询。返回的 100 个对象意味着一个初始查询,然后是 100 个附加查询以获取每个查询的关联,n + 1。
http://pramatr.com/2009/02/05/sql-n-1-selects-explained/
N+1 选择问题很痛苦,在单元测试中检测这种情况是有意义的。我开发了一个小型库,用于验证给定测试方法或任意代码块执行的查询数量 - JDBC Sniffer
只需向您的测试类添加一个特殊的 JUnit 规则,并在您的测试方法上放置带有预期查询数量的注释:
@Rule
public final QueryCounter queryCounter = new QueryCounter();
@Expectation(atMost = 3)
@Test
public void testInvokingDatabase() {
// your JDBC or JPA code
}
Hibernate & Spring Data JPA 中的 N+1 问题
N+1 问题是对象关系映射中的一个性能问题,它会在数据库中为应用层的单个选择查询触发多个选择查询(准确地说是 N+1,其中 N = 表中的记录数)。 Hibernate & Spring Data JPA 提供了多种方法来捕捉和解决这个性能问题。
什么是 N+1 问题?
为了理解 N+1 问题,让我们考虑一个场景。假设我们有一组用户对象映射到数据库中的 DB_USER 表,并且每个用户都有一个使用连接表 DB_USER_ROLE 映射到 DB_ROLE 表的集合或角色。在 ORM 级别,用户与角色有多对多的关系。
Entity Model
@Entity
@Table(name = "DB_USER")
public class User {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
private String name;
@ManyToMany(fetch = FetchType.LAZY)
private Set<Role> roles;
//Getter and Setters
}
@Entity
@Table(name = "DB_ROLE")
public class Role {
@Id
@GeneratedValue(strategy= GenerationType.AUTO)
private Long id;
private String name;
//Getter and Setters
}
一个用户可以有很多角色。角色被延迟加载。现在假设我们要从该表中获取所有用户并为每个用户打印角色。非常天真的对象关系实现可能是 - 带有 findAllBy 方法的 UserRepository
public interface UserRepository extends CrudRepository<User, Long> {
List<User> findAllBy();
}
ORM 执行的等效 SQL 查询将是:
首先获取所有用户 (1)
Select * from DB_USER;
然后为每个执行 N 次的用户获取角色(其中 N 是用户数)
Select * from DB_USER_ROLE where userid = <userid>;
所以我们需要一个用户选择和 N 个额外选择来为每个用户获取角色,其中 N 是用户总数。这是 ORM 中一个经典的 N+1 问题。
如何识别它?
Hibernate 提供了在控制台/日志中启用 SQL 日志记录的跟踪选项。使用日志,您可以轻松查看 hibernate 是否针对给定调用发出 N+1 个查询。
如果您看到给定选择查询的多个 SQL 条目,则很有可能是由于 N+1 问题。
N+1 分辨率
在 SQL 级别,为了避免 N+1,ORM 需要实现的是触发一个连接两个表的查询,并在单个查询中获取组合结果。
在单个查询中检索所有内容(用户和角色)的连接 SQL
或纯 SQL
select user0_.id, role2_.id, user0_.name, role2_.name, roles1_.user_id, roles1_.roles_id from db_user user0_ left outer join db_user_roles roles1_ on user0_.id=roles1_.user_id left outer join db_role role2_ on roles1_.roles_id=role2_.id
Hibernate 和 Spring Data JPA 提供了解决 N+1 ORM 问题的机制。
1. Spring Data JPA 方法:
如果我们使用 Spring Data JPA,那么我们有两种选择来实现这一点 - 使用 EntityGraph 或使用带有 fetch join 的 select 查询。
public interface UserRepository extends CrudRepository<User, Long> {
List<User> findAllBy();
@Query("SELECT p FROM User p LEFT JOIN FETCH p.roles")
List<User> findWithoutNPlusOne();
@EntityGraph(attributePaths = {"roles"})
List<User> findAll();
}
使用left join fetch在数据库级别发出N+1个查询,我们使用attributePaths解决了N+1个问题,Spring Data JPA避免了N+1个问题
2.休眠方式:
如果它是纯 Hibernate,那么以下解决方案将起作用。
使用 HQL:
from User u *join fetch* u.roles roles roles
使用标准 API:
Criteria criteria = session.createCriteria(User.class);
criteria.setFetchMode("roles", FetchMode.EAGER);
所有这些方法的工作方式都相似,并且它们使用左连接提取发出类似的数据库查询
正如其他人所说的更优雅的问题是,您要么拥有 OneToMany 列的笛卡尔积,要么正在执行 N+1 选择。分别可能是巨大的结果集或与数据库聊天。
我很惊讶没有提到这一点,但这就是我解决这个问题的方法...... 我制作了一个半临时 ids 表。 I also do this when you have the IN ()
clause limitation。
这不适用于所有情况(甚至可能不是大多数情况),但如果您有很多子对象,这样笛卡尔积就会失控(即很多 OneToMany
列结果的数量),它的效果特别好将是列的乘积),它更像是一个批处理作业。
首先,您将父对象 ID 作为批次插入到 ids 表中。这个 batch_id 是我们在应用程序中生成并保留的东西。
INSERT INTO temp_ids
(product_id, batch_id)
(SELECT p.product_id, ?
FROM product p ORDER BY p.product_id
LIMIT ? OFFSET ?);
现在,对于每个 OneToMany
列,您只需在 ids 表上执行 SELECT
,INNER JOIN
使用 WHERE batch_id=
将子表添加(反之亦然)。您只想确保按 id 列排序,因为它会使合并结果列更容易(否则您将需要一个 HashMap/Table 用于整个结果集,这可能还不错)。
然后你只需定期清理 ids 表。
如果用户选择 100 个左右不同的项目进行某种批量处理,这也特别有效。将 100 个不同的 id 放入临时表中。
现在,您正在执行的查询数量是 OneToMany 列的数量。
在不深入技术栈实现细节的情况下,从架构上讲,N + 1 问题至少有两种解决方案:
只有 1 个 - 大查询 - 有连接。这使得大量信息从数据库传输到应用层,尤其是在有多个子记录的情况下。数据库的典型结果是一组行,而不是对象图(对于不同的数据库系统有解决方案)
有两个(或更多需要加入的孩子)查询 - 1 个用于父母,在你拥有它们之后 - 通过 ID 查询孩子并映射它们。这将最大限度地减少 DB 和 APP 层之间的数据传输。
以 Matt Solnit 为例,假设您将 Car 和 Wheels 之间的关联定义为 LAZY,并且您需要一些 Wheels 字段。这意味着在第一次选择之后,hibernate 将为每辆车执行“Select * from Wheels where car_id = :id”。
这使得每 N 辆车第一次选择和更多 1 次选择,这就是为什么它被称为 n+1 问题。
为避免这种情况,请使关联获取为急切,以便休眠加载带有连接的数据。
但请注意,如果您多次不访问关联的 Wheels,最好保持 LAZY 或使用 Criteria 更改获取类型。
N+1 的推广
N+1 问题是一个特定于 ORM 的问题名称,您将可以在服务器上合理执行的循环移动到客户端。通用问题并非特定于 ORM,您可以使用任何远程 API 来解决它。 In this article, I've shown how JDBC roundtrips are very costly,如果您调用 API N 次而不是仅 1 次。该示例的不同之处在于您是否调用了 Oracle PL/SQL 过程:
dbms_output.get_lines(调用一次,接收N项)
dbms_output.get_line(调用N次,每次接收1个项目)
它们在逻辑上是等价的,但是由于服务器和客户端之间的延迟,您在循环中添加了 N 个延迟等待,而不是只等待一次。
ORM 案例
事实上,ORM-y N+1 问题甚至也不是 ORM 特定的,您也可以通过手动运行自己的查询来实现它,例如,当您在 PL/SQL 中执行以下操作时:
-- This loop is executed once
for parent in (select * from parent) loop
-- This loop is executed N times
for child in (select * from child where parent_id = parent.id) loop
...
end loop;
end loop;
使用连接来实现这个会更好(在这种情况下):
for rec in (
select *
from parent p
join child c on c.parent_id = p.id
)
loop
...
end loop;
现在,循环只执行一次,并且循环的逻辑已从客户端 (PL/SQL) 转移到服务器 (SQL),服务器甚至可以对其进行不同的优化,例如通过运行哈希连接 (O(N)
) 而不是嵌套循环连接 (O(N log N)
with index)
自动检测 N+1 个问题
如果您使用的是 JDBC,you could use jOOQ as a JDBC proxy behind the scenes to auto-detect your N+1 problems。 jOOQ 的解析器规范化您的 SQL 查询并缓存有关连续执行父查询和子查询的数据。如果您的查询不完全相同,但在语义上等价,这甚至也有效。
N+1 SELECT 问题真的很难发现,尤其是在具有大域的项目中,直到它开始降低性能的那一刻。即使问题得到解决,即通过添加急切加载,进一步的开发可能会破坏解决方案和/或在其他地方再次引入 N+1 SELECT 问题。
我创建了开源库 jplusone 来解决基于 JPA 的 Spring Boot Java 应用程序中的这些问题。该库提供了两个主要功能:
生成将 SQL 语句与触发它们的 JPA 操作的执行相关联的报告,并将其放置在涉及它的应用程序的源代码中
2020-10-22 18:41:43.236 DEBUG 14913 --- [ main] c.a.j.core.report.ReportGenerator : ROOT com.adgadev.jplusone.test.domain.bookshop.BookshopControllerTest.shouldGetBookDetailsLazily(BookshopControllerTest.java:65) com.adgadev.jplusone.test.domain.bookshop.BookshopController.getSampleBookUsingLazyLoading(BookshopController.java:31) com.adgadev.jplusone.test.domain.bookshop.BookshopService.getSampleBookDetailsUsingLazyLoading [PROXY] SESSION BOUNDARY OPERATION [IMPLICIT] com.adgadev.jplusone.test.domain.bookshop.BookshopService.getSampleBookDetailsUsingLazyLoading(BookshopService.java:35) com.adgadev.jplusone.test.domain.bookshop.Author.getName [PROXY] com.adgadev.jplusone.test.domain.bookshop.Author [FETCHING ENTITY] STATEMENT [READ] select [...] from author author0_ left outer join genre genre1_ on author0_.genre_id=genre1_.id where author0_.id=1 OPERATION [IMPLICIT] com.adgadev.jplusone.test.domain.bookshop.BookshopService.getSampleBookDetailsUsingLazyLoading(BookshopService.java:36) com.adgadev.jplusone.test.domain.bookshop.Author.countWrittenBooks(Author.java:53) com.adgadev.jplusone.test.domain.bookshop.Author.books [FETCHING COLLECTION] STATEMENT [READ] select [...] from book books0_ where books0_.author_id=1
提供 API,允许编写测试检查您的应用程序使用 JPA 的效率(即断言延迟加载操作的数量)
@SpringBootTest
class LazyLoadingTest {
@Autowired
private JPlusOneAssertionContext assertionContext;
@Autowired
private SampleService sampleService;
@Test
public void shouldBusinessCheckOperationAgainstJPlusOneAssertionRule() {
JPlusOneAssertionRule rule = JPlusOneAssertionRule
.within().lastSession()
.shouldBe().noImplicitOperations().exceptAnyOf(exclusions -> exclusions
.loadingEntity(Author.class).times(atMost(2))
.loadingCollection(Author.class, "books")
);
// trigger business operation which you wish to be asserted against the rule,
// i.e. calling a service or sending request to your API controller
sampleService.executeBusinessOperation();
rule.check(assertionContext);
}
}
SELECT * from Wheel;
) 而不是 N+1 来获得所有轮子。 N 较大时,性能影响可能非常显着。