在对象关系映射(ORM)讨论中,通常将“ N + 1选择问题”表示为问题,并且我了解到它与必须对对象中看起来很简单的内容进行大量数据库查询有关。世界。
有人对此问题有更详细的解释吗?
在对象关系映射(ORM)讨论中,通常将“ N + 1选择问题”表示为问题,并且我了解到它与必须对对象中看起来很简单的内容进行大量数据库查询有关。世界。
有人对此问题有更详细的解释吗?
Answers:
假设您有一个Car
对象集合(数据库行),每个对象Car
都有一个Wheel
对象集合(也行)。换句话说,Car
→Wheel
是一对多关系。
现在,假设您需要遍历所有汽车,并为每辆汽车打印出车轮清单。天真的O / R实现将执行以下操作:
SELECT * FROM Cars;
然后每个Car
:
SELECT * FROM Wheel WHERE CarId = ?
换句话说,您对汽车有一个选择,然后有N个附加选择,其中N是汽车总数。
或者,可以让所有的轮子都可以在内存中执行查找:
SELECT * FROM Wheel
这样可以将往返数据库的次数从N + 1减少到2。大多数ORM工具都提供了几种防止N + 1选择的方法。
参考:Java持久性Hibernate,第13章。
SELECT * from Wheel;
),而不是N + 1。如果N较大,则性能影响可能非常显着。
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”(默认)
用于查询产品的提取模式为“选择”
提取模式(默认):访问供应商信息
缓存第一次不起作用
供应商被访问
提取模式为选择提取(默认)
// 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=?
结果:
这是N + 1选择的问题!
我无法对其他答案直接发表评论,因为我的声誉不够。但是值得注意的是,这个问题的出现本质上是因为,从历史上看,很多dbms在处理联接时都非常差(MySQL是一个特别值得注意的例子)。因此,n + 1通常比联接要快得多。然后有一些方法可以在n + 1上进行改进,但是仍然不需要连接,这就是原始问题所在。
但是,MySQL现在比联接时要好得多。当我第一次学习MySQL时,我经常使用join。然后,我发现它们有多慢,并在代码中改为n + 1。但是,最近,我一直在转而加入联接,因为与现在开始使用MySQL相比,MySQL现在在处理它们方面要好得多。
如今,就性能而言,在索引正确的表集上进行简单联接已很少成为问题。如果确实对性能产生了影响,那么使用索引提示通常可以解决它们。
这是由MySQL开发团队之一讨论的:
http://jorgenloland.blogspot.co.uk/2013/02/dbt-3-q3-6-x-performance-in-mysql-5610.html
因此,摘要是:如果过去由于MySQL的糟糕性能而避免使用联接,请重试最新版本。您可能会感到惊喜。
JOIN
RDBMS中使用的三种常见算法之一称为嵌套循环。从根本上讲,它是一个N + 1选择。唯一的区别是,数据库管理人员明智地选择了基于统计信息和索引来使用它,而不是通过客户端代码来强制将其沿着该路径分类。
由于这个问题,我们放弃了Django中的ORM。基本上,如果你尝试去做
for p in person:
print p.car.colour
ORM会很高兴地返回所有人(通常作为Person对象的实例),但是随后它将需要查询每个Person的汽车表。
一种简单有效的方法就是我所说的“ 扇形折叠 ”,它避免了一个荒谬的想法,即关系数据库的查询结果应映射回组成查询的原始表。
第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:物化
将结果吸到具有参数的通用对象创建器中,该参数在第三项之后拆分。这意味着“琼斯”对象将不会被制作多次。
第三步:渲染
for p in people:
print p.car.colour # no more car queries
请参阅此网页以获取用于python 的折叠功能的实现。
select_related
旨在解决此问题-实际上,其docs以与您的示例相似的示例开头p.car.colour
。
select_related()
和prefetch_related()
在Django现在。
select_related()
和朋友似乎并没有对联接进行任何明显有用的外推,如LEFT OUTER JOIN
。问题不是接口问题,而是与对象和关系数据可映射的奇怪想法有关的问题。
当数据访问框架执行N条其他SQL语句以获取执行主SQL查询时可能已检索到的相同数据时,就会发生N + 1查询问题。
N的值越大,将执行的查询越多,对性能的影响越大。而且,与慢查询日志可以帮助您查找慢查询的日志不同,N + 1问题不会被发现,因为每个附加查询的运行速度都足够快,不会触发慢查询日志。
问题是执行大量其他查询,这些查询总体上需要花费足够的时间来减慢响应时间。
让我们考虑一下,我们具有以下post和post_comments数据库表,它们构成了一对多的表关系:
我们将创建以下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)
如果选择post_comments
使用此SQL查询:
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
title
为每个获取相关联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查询问题的方法,因此了解如何避免这种情况非常重要。
对于下一个示例,请考虑我们将post
and 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
}
FetchType.EAGER
FetchType.EAGER
对您的JPA关联使用隐式或显式使用是一个坏主意,因为您将获取所需的更多数据。此外,该FetchType.EAGER
策略还容易出现N + 1查询问题。
不幸的是,@ManyToOne
and @OneToOne
关联FetchType.EAGER
默认使用,因此,如果您的映射如下所示:
@ManyToOne
private Post post;
您正在使用该FetchType.EAGER
策略,并且每次通过JPQL或Criteria API查询JOIN FETCH
加载某些PostComment
实体时都忘记使用该策略:
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语句,因为在post
返回List
of 之前必须先获取关联PostComment
实体。
与调用的find
方法时使用的默认提取计划不同EnrityManager
,JPQL或Criteria API查询定义了一个显式计划,Hibernate无法通过自动注入JOIN FETCH来更改该计划。因此,您需要手动进行操作。
如果您根本不需要该post
关联,则使用时会很不幸,FetchType.EAGER
因为无法避免获取该关联。这就是为什么最好使用FetchType.LAZY
默认值的原因。
但是,如果您想使用post
关联,则可以使用A JOIN FETCH
+ 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.EAGER
获取策略的更多详细信息,也请参阅本文。
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语句。
同样,此修复程序包括在JOIN FETCH
JPQL查询中添加子句:
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查询问题。有关如何克服
@OneToOne
关联产生的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会限制整个结果集,而不仅是根子句。
假设您有COMPANY和EMPLOYEE。COMPANY有许多员工(即EMPLOYEE有一个字段COMPANY_ID)。
在某些O / R配置中,当您拥有一个映射的Company对象并访问其Employee对象时,O / R工具将为每个员工进行一次选择,如果您只是使用直接SQL进行操作,则可以select * from employees where company_id = XX
。因此,N(员工人数)加1(公司)
这就是EJB实体Bean的初始版本的工作方式。我相信像Hibernate这样的东西已经解决了,但是我不太确定。大多数工具通常都包含有关其映射策略的信息。
现在您已经了解了问题,通常可以通过在查询中进行联接提取来避免该问题。基本上,这将强制获取延迟加载的对象,以便在一个查询而不是n + 1个查询中检索数据。希望这可以帮助。
查看有关主题的Ayende帖子:打击NHibernate中的Select N +1问题。
基本上,当使用NHibernate或EntityFramework之类的ORM时,如果您具有一对多(主从细节)关系,并且希望列出每个主记录的所有详细信息,则必须对N数据库中,“ N”表示主记录的数量:1个查询以获取所有主记录,N个查询,每个主记录一个,以获取每个主记录的所有详细信息。
更多的数据库查询调用→更多的延迟时间→降低了应用程序/数据库性能。
但是,ORM具有避免此问题的选项,主要是使用JOIN。
在我看来,Hibernate Pitfall撰写的文章:为什么关系应该变得懒惰与真正的N + 1问题完全相反。
如果您需要正确的解释,请参考Hibernate-第19章:提高性能-获取策略。
选择获取(默认)极易受到N + 1选择问题的影响,因此我们可能要启用联接获取
提供的链接非常简单地说明了n + 1问题。如果将其应用于Hibernate,则基本上是在谈论同一件事。查询对象时,将加载实体,但是任何关联(除非另行配置)都将延迟加载。因此,一个查询针对根对象,而另一查询针对每个对象加载关联。返回的100个对象意味着一个初始查询,然后是100个其他查询以获取每个对象的关联,n + 1。
N + 1选择问题很痛苦,在单元测试中检测此类情况很有意义。我已经开发了一个小型库,用于验证由给定测试方法或只是任意代码块执行的查询数-JDBC Sniffer
只需在测试类中添加特殊的JUnit规则,然后在测试方法中放置带有预期查询数量的注释即可:
@Rule
public final QueryCounter queryCounter = new QueryCounter();
@Expectation(atMost = 3)
@Test
public void testInvokingDatabase() {
// your JDBC or JPA code
}
正如其他人更优雅地指出的那样,问题是您要么拥有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
列,您只需SELECT
在id表INNER JOIN
上对子表做一个WHERE batch_id=
(反之亦然)。您只需要确保按id列排序即可,因为这将使合并结果列更加容易(否则,整个结果集都需要HashMap / Table,这可能还不错。)
然后,您只需定期清理id表。
如果用户为某种批量处理选择说100个左右的不同项目,这也特别有效。将100个不同的ID放入临时表。
现在,您要执行的查询数是由OneToMany列数决定的。
以Matt Solnit为例,假设您将Car和Wheels之间的关联定义为LAZY,并且需要一些Wheels字段。这意味着在第一次选择之后,休眠将为每辆汽车执行“从* where car_id =:id的车轮中选择*”。
这使得每N辆汽车都具有第一选择权和更多选择权,这就是为什么它被称为n + 1问题。
为避免这种情况,请尽快获取关联,以便休眠状态通过联接加载数据。
但是请注意,如果很多次您都没有访问关联的Wheels,最好将其保持为LAZY或使用Criteria更改获取类型。