“N+1 选择问题”通常在对象关系映射 (ORM) 讨论中被表述为一个问题,我知道这与必须对对象中看似简单的东西进行大量数据库查询有关世界。
有人对这个问题有更详细的解释吗?
“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 个附加 SQL 语句以获取执行主 SQL 查询时可能已检索到的相同数据时。
N值越大,执行的查询越多,对性能的影响越大。而且,与可以帮助您找到运行缓慢的查询的慢查询日志不同,N+1 问题不会被发现,因为每个单独的附加查询运行速度足够快,不会触发慢查询日志。
问题在于执行大量附加查询,总体而言,这些查询需要足够的时间来减慢响应时间。
假设我们有以下 post 和 post_comments 数据库表,它们形成了一对多的表关系:
我们将创建以下 4post
行:
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)
而且,我们还将创建 4post_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
为 each获取关联的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
和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
PostComment
find
与调用方法时使用的默认获取计划不同EnrityManager
,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 语句以构建日志消息。
同样,修复包括向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 查询问题。
如果要在数据访问层自动检测 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
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=?
结果:
这是 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 的糟糕性能而一直在避免连接,那么请在最新版本上重试。你可能会感到惊喜。
由于这个问题,我们离开了 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的实现,请参阅此网页。
现在您了解了这个问题,通常可以通过在查询中执行连接提取来避免它。这基本上强制获取延迟加载的对象,以便在一个查询而不是 n+1 个查询中检索数据。希望这可以帮助。
假设您有 COMPANY 和 EMPLOYEE。COMPANY 有许多 EMPLOYEES(即 EMPLOYEE 有一个字段 COMPANY_ID)。
在某些 O/R 配置中,当您有一个映射的 Company 对象并访问其 Employee 对象时,O/R 工具将为每个员工执行一次选择,而如果您只是在直接 SQL 中执行操作,则可以select * from employees where company_id = XX
. 因此 N(员工人数)加 1(公司)
这就是 EJB 实体 Bean 的初始版本的工作方式。我相信像 Hibernate 这样的东西已经消除了这一点,但我不太确定。大多数工具通常包含有关其映射策略的信息。
检查 Ayende 关于主题的帖子:在 NHibernate 中解决 Select N + 1 问题。
基本上,当使用像 NHibernate 或 EntityFramework 这样的 ORM 时,如果您有一对多(主从)关系,并且想要列出每个主记录的所有详细信息,您必须对数据库,“N”是主记录的数量:1 次查询获取所有主记录,N 次查询,每个主记录一个,获取每个主记录的所有详细信息。
更多数据库查询调用 → 更多延迟时间 → 降低应用程序/数据库性能。
但是,ORM 有一些选项可以避免这个问题,主要是使用 JOIN。
发出 1 个返回 100 个结果的查询比发出 100 个每个返回 1 个结果的查询要快得多。
在我看来,Hibernate Pitfall:Why Relationships Should Be Lazy中的文章与真正的 N+1 问题完全相反。
如果您需要正确的解释,请参考Hibernate - 第 19 章:提高性能 - 获取策略
Select fetching(默认)极易受到 N+1 选择问题的影响,因此我们可能希望启用 join fetching
提供的链接有一个非常简单的 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
}
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 级别,User与Role有多对多的关系。
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 table。当您有条款限制时,我也会这样做IN ()
。
这不适用于所有情况(甚至可能不是大多数情况),但如果您有很多子对象,这样笛卡尔积就会失控(即很多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
列,您只需SELECT
在 ids 表INNER JOIN
上使用 a 对子表执行 a WHERE batch_id=
(反之亦然)。您只想确保按 id 列排序,因为它会使合并结果列更容易(否则您将需要一个 HashMap/Table 用于整个结果集,这可能不是那么糟糕)。
然后你只需定期清理 ids 表。
如果用户选择 100 个左右不同的项目进行某种批量处理,这也特别有效。将 100 个不同的 id 放入临时表中。
现在,您正在执行的查询数量是 OneToMany 列的数量。
在不深入技术栈实现细节的情况下,从架构上讲,N + 1 问题至少有两种解决方案:
以 Matt Solnit 为例,假设您将 Car 和 Wheels 之间的关联定义为 LAZY,并且您需要一些 Wheels 字段。这意味着在第一次选择之后,hibernate 将为每辆车执行“Select * from Wheels where car_id = :id”。
这使得每 N 辆车第一次选择和更多 1 次选择,这就是为什么它被称为 n+1 问题。
为避免这种情况,请使关联获取为急切,以便休眠加载带有连接的数据。
但请注意,如果您多次不访问关联的 Wheels,最好保持 LAZY 或使用 Criteria 更改获取类型。
N+1 问题是一个特定于 ORM 的问题名称,您将可以在服务器上合理执行的循环移动到客户端。通用问题并非特定于 ORM,您可以使用任何远程 API 来解决它。在本文中,我展示了如果您调用 API N 次而不是仅 1 次,JDBC 往返是多么昂贵。该示例的不同之处在于您是否调用了 Oracle PL/SQL 过程:
dbms_output.get_lines
(调用一次,收到N个物品)dbms_output.get_line
(调用N次,每次收到1件)它们在逻辑上是等价的,但是由于服务器和客户端之间的延迟,您在循环中添加了 N 个延迟等待,而不是只等待一次。
事实上,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)
带索引)
如果您使用 JDBC,则可以在幕后使用 jOOQ 作为 JDBC 代理来自动检测您的 N+1 问题。jOOQ 的解析器规范化您的 SQL 查询并缓存有关连续执行父查询和子查询的数据。如果您的查询不完全相同,但在语义上等价,这甚至也有效。
N+1 SELECT 问题真的很难发现,尤其是在具有大域的项目中,直到它开始降低性能的那一刻。即使问题得到解决,即通过添加急切加载,进一步的开发可能会破坏解决方案和/或在其他地方再次引入 N+1 SELECT 问题。
我创建了开源库jplusone来解决基于 JPA 的 Spring Boot Java 应用程序中的这些问题。该库提供了两个主要功能:
2020-10-22 18:41:43.236 调试 14913 --- [主要] cajcore.report.ReportGenerator : 根 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 [代理] 会话边界 操作 [隐含] com.adgadev.jplusone.test.domain.bookshop.BookshopService.getSampleBookDetailsUsingLazyLoading(BookshopService.java:35) com.adgadev.jplusone.test.domain.bookshop.Author.getName [代理] com.adgadev.jplusone.test.domain.bookshop.Author [获取实体] 声明 [阅读] 从中选择 [...] 作者作者0_ 在 author0_.genre_id=genre1_.id 上左外连接流派 Genre1_ 在哪里 author0_.id=1 操作 [隐含] 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 [获取收藏] 声明 [阅读] 从中选择 [...] 书籍书籍0_ 在哪里 book0_.author_id=1
@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);
}
}