“N+1选择问题”在对象关系映射(ORM)讨论中通常被称为一个问题,我理解这与必须为对象世界中看似简单的东西进行大量数据库查询有关。
有人对这个问题有更详细的解释吗?
“N+1选择问题”在对象关系映射(ORM)讨论中通常被称为一个问题,我理解这与必须为对象世界中看似简单的东西进行大量数据库查询有关。
有人对这个问题有更详细的解释吗?
当前回答
正如其他人更优雅地指出的那样,问题是您要么拥有OneToMany列的笛卡尔积,要么正在进行N+1选择。无论是可能的巨大结果集,还是与数据库的聊天。
我很惊讶没有提到这一点,但我是如何解决这个问题的。。。我制作了一个半临时ID表。当您有IN()条款限制时,我也会这样做。
这并不适用于所有情况(可能甚至不适用于大多数情况),但如果您有很多子对象,使得笛卡儿乘积无法控制(即大量的OneToMany列,结果的数量将是列的乘积),并且它更像是一个批处理作业,那么它就特别适用。
首先,将父对象ID作为批处理插入到ID表中。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列,您只需在id表INNER上执行SELECT,然后使用WHERE batch_id=(反之亦然)将子表JOIN。您只需要确保按id列排序,因为这将使合并结果列更容易(否则,您将需要一个HashMap/Table用于整个结果集,这可能不会那么糟糕)。
然后,只需定期清理ids表。
如果用户选择例如100个左右不同的项目进行某种批量处理,这也特别有效。将100个不同的ID放入临时表中。
现在,您正在执行的查询数量是OneToMany列的数量。
其他回答
正如其他人更优雅地指出的那样,问题是您要么拥有OneToMany列的笛卡尔积,要么正在进行N+1选择。无论是可能的巨大结果集,还是与数据库的聊天。
我很惊讶没有提到这一点,但我是如何解决这个问题的。。。我制作了一个半临时ID表。当您有IN()条款限制时,我也会这样做。
这并不适用于所有情况(可能甚至不适用于大多数情况),但如果您有很多子对象,使得笛卡儿乘积无法控制(即大量的OneToMany列,结果的数量将是列的乘积),并且它更像是一个批处理作业,那么它就特别适用。
首先,将父对象ID作为批处理插入到ID表中。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列,您只需在id表INNER上执行SELECT,然后使用WHERE batch_id=(反之亦然)将子表JOIN。您只需要确保按id列排序,因为这将使合并结果列更容易(否则,您将需要一个HashMap/Table用于整个结果集,这可能不会那么糟糕)。
然后,只需定期清理ids表。
如果用户选择例如100个左右不同的项目进行某种批量处理,这也特别有效。将100个不同的ID放入临时表中。
现在,您正在执行的查询数量是OneToMany列的数量。
SELECT
table1.*
, table2.*
INNER JOIN table2 ON table2.SomeFkId = table1.SomeId
这将获得一个结果集,其中表2中的子行通过返回表2中每个子行的表1结果而导致重复。O/R映射器应根据唯一的键字段区分表1实例,然后使用所有表2列填充子实例。
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的优点是减少了查询复杂性,并且可以使用延迟加载,其中子结果集仅在第一次请求时加载。
N+1查询问题是什么
当数据访问框架执行N个额外的SQL语句以获取执行主SQL查询时可能检索到的相同数据时,就会出现N+1查询问题。
N值越大,执行的查询越多,性能影响越大。而且,与可以帮助您查找运行缓慢的查询的慢速查询日志不同,N+1问题不会出现,因为每个单独的附加查询运行速度都足够快,不会触发慢速查询日志。
问题是执行大量额外的查询,总的来说,这些查询需要足够的时间来降低响应时间。
让我们考虑以下post和post_comments数据库表,它们形成了一对多的表关系:
我们将创建以下4个柱行:
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的相关文章标题:
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表映射到以下实体:
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
}
获取类型.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实体列表之前必须获取post关联。
与调用EntityManager的find方法时使用的默认获取计划不同,JPQL或Criteria API查询定义了Hibernate无法通过自动注入JOIN fetch来更改的显式计划。因此,您需要手动执行。
如果你根本不需要post关联,那么你在使用FetchType.EAGER时就不走运了,因为无法避免获取它。这就是为什么默认情况下最好使用FetchType.LAZY。
但是,如果您想使用后关联,那么可以使用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'
获取类型.LAZY
即使您切换到对所有关联显式使用FetchType.LAZY,您仍然会遇到N+1问题。
这一次,后关联映射如下:
@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'
由于后期关联是延迟获取的,因此在访问延迟关联时将执行一个辅助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!
Hibernate和Spring数据JPA中的N+1问题
N+1问题是对象关系映射中的一个性能问题,它为应用层的单个选择查询在数据库中触发多个选择查询(准确地说是N+1,其中N=表中的记录数)。Hibernate&Spring Data JPA提供了多种方法来捕获和解决这个性能问题。
什么是N+1问题?
为了理解N+1问题,让我们考虑一个场景。假设我们有一个映射到数据库中DB_User表的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>;
因此,我们需要为User选择一个选项,为每个用户提取角色选择N个选项,其中N是用户总数。这是ORM中的一个经典N+1问题。
如何识别?
Hibernate提供了跟踪选项,可以在控制台/日志中启用SQL日志记录。使用日志,您可以很容易地看到hibernate是否为给定的调用发出N+1个查询。
如果您在给定的select查询中看到多个SQL条目,那么很可能是由于N+1问题。
N+1分辨率
在SQL级别,ORM要避免N+1,需要实现的是启动一个连接两个表的查询,并在单个查询中获得组合结果。
获取联接SQL,用于检索单一查询中的所有内容(用户和角色)
OR普通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.弹簧数据JPA方法:
如果我们使用的是SpringDataJPA,那么我们有两个选项来实现这一点-使用EntityGraph或使用带有fetch连接的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();
}
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);
所有这些方法的工作原理都类似,它们使用左连接获取发出类似的数据库查询
我不能直接评论其他答案,因为我没有足够的声誉。但值得注意的是,这个问题本质上只会出现,因为从历史上看,很多dbm在处理连接时都非常糟糕(MySQL是一个特别值得注意的例子)。因此,n+1通常比join快得多。然后有一些方法可以改进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的性能糟糕,那么请在最新版本上重试。你可能会感到惊喜。