“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列的数量。
与产品有一对多关系的供应商。一个供应商拥有(供应)许多产品。
***** 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”(默认)用于查询产品的获取模式为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选择问题!
以Matt Solnit为例,假设您将Car和Wheels之间的关联定义为LAZY,并且需要一些Wheels字段。这意味着在第一次选择后,休眠将为每辆车执行“select*from Wheels where car_id=:id”。
这使得每个N辆车的第一次选择和更多的1次选择,这就是为什么它被称为N+1问题的原因。
为了避免这种情况,请使关联获取变得急切,以便hibernate使用连接加载数据。
但请注意,如果您多次无法访问关联的Wheels,最好将其保持为LAZY或使用Criteria更改获取类型。
假设你有公司和雇员。公司有许多雇员(即雇员有一个字段COMPANY_ID)。
在某些O/R配置中,当您有一个映射的Company对象并访问其Employee对象时,O/R工具将为每个员工执行一次选择,如果您只是在直接SQL中执行操作,则可以从Company_id=XX的员工中选择*。因此,N(员工人数)加1(公司)
这就是EJB实体bean的初始版本是如何工作的。我相信像Hibernate这样的东西已经解决了这个问题,但我不太确定。大多数工具通常包含有关其映射策略的信息。
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!