ChatGPT解决这个技术问题 Extra ChatGPT

ORM(对象关系映射)中的“N+1 选择问题”是什么?

“N+1 选择问题”通常在对象关系映射 (ORM) 讨论中被表述为一个问题,我知道这与必须对对象中看似简单的东西进行大量数据库查询有关世界。

有人对这个问题有更详细的解释吗?

对于寻找解决此问题的每个人,我找到了一篇描述它的帖子。 stackoverflow.com/questions/32453989/…
考虑到答案,这不应该被称为 1+N 问题吗?由于这似乎是一个术语,我并不是特别要问 OP。

C
Community

假设您有一个 Car 对象(数据库行)的集合,每个 Car 都有一个 Wheel 对象(也是行)的集合。换句话说,CarWheel 是一对多的关系。

现在,假设您需要遍历所有汽车,并为每辆汽车打印出车轮列表。天真的 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 章。


为了澄清“这很糟糕” - 您可以使用 1 个选择 (SELECT * from Wheel;) 而不是 N+1 来获得所有轮子。 N 较大时,性能影响可能非常显着。
@tucuxi 我很惊讶你因为错误而获得了如此多的支持。数据库非常擅长索引,对特定 CarID 进行查询会很快返回。但是如果你得到了所有的轮子一次,你将不得不在你的应用程序中搜索 CarID,它没有被索引,这比较慢。除非您在到达数据库时遇到重大延迟问题,否则 n + 1 实际上更快 - 是的,我用大量真实世界的代码对其进行了基准测试。
@ariel“正确”的方法是获取按 CarId 排序的所有车轮(1 个选择),如果需要比 CarId 更多的详细信息,请对所有汽车进行第二次查询(总共 2 个查询)。现在打印出来是最佳的,不需要索引或辅助存储(您可以迭代结果,无需全部下载)。您对错误的事物进行了基准测试。如果您仍然对自己的基准测试充满信心,您介意发表更长的评论(或完整的答案)来解释您的实验和结果吗?
“Hibernate(我不熟悉其他 ORM 框架)为您提供了几种处理它的方法。”这些方式是什么?
@Ariel 尝试在不同的机器上使用数据库和应用程序服务器运行您的基准测试。根据我的经验,往返数据库的开销比查询本身要高。所以,是的,查询确实很快,但造成严重破坏的是往返。我已将“WHERE Id = const”转换为“WHERE Id IN (const, const, ...)”,并从中获得了数量级的增长。
b
banan3'14

什么是 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 查询问题,因此了解如何避免这些情况非常重要。

对于下一个示例,假设我们将 postpost_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 关联。

与调用 EntityManagerfind 方法时使用的默认提取计划不同,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!

但是现在您遇到了分页问题。如果您有 10 辆汽车,每辆汽车有 4 个轮子,并且您想以每页 5 辆汽车对汽车进行分页。所以你基本上有SELECT cars, wheels FROM cars JOIN wheels LIMIT 0, 5。但是你得到的是 2 辆有 5 个轮子的汽车(第一辆有 4 个轮子的汽车,第二辆只有 1 个轮子的汽车),因为 LIMIT 会限制整个结果集,而不仅仅是根子句。
谢谢你的文章。我会读的。通过快速滚动 - 我看到解决方案是窗口函数,但它们在 MariaDB 中相当新 - 所以问题在旧版本中仍然存在。 :)
@VladMihalcea,每次您在解释 N+1 问题时提到 ManyToOne 案例时,我都会从您的文章或帖子中指出。但实际上人们最感兴趣的是与 N+1 问题有关的 OneToMany 案例。您能否参考并解释 OneToMany 案例?
@VladMicalcea 可以使用实体图代替 join fetch 吗?
感谢您的回答,它清除了一些东西。这本可以被接受的答案:)
c
cfeduke
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 的优点是降低了查询复杂性,并且您可以使用延迟加载,其中子结果集仅在第一次请求时加载。


n + 1 的另一个优点是速度更快,因为数据库可以直接从索引返回结果。进行连接然后排序需要一个临时表,这比较慢。避免 n + 1 的唯一原因是,如果您与数据库交谈时有很多延迟。
加入和排序可以非常快(因为您将加入索引和可能排序的字段)。你的“n+1”有多大?你真的相信 n+1 问题只适用于高延迟的数据库连接吗?
@ariel - 你认为 N+1 是“最快”的建议是错误的,即使你的基准测试可能是正确的。这怎么可能?请参阅 en.wikipedia.org/wiki/Anecdotal_evidence,以及我在此问题的其他答案中的评论。
@Ariel - 我想我理解得很好:)。我只是想指出您的结果仅适用于一组条件。我可以很容易地构建一个相反的例子。那有意义吗?
重申一下,SELECT N + 1 问题的核心是:我有 600 条记录要检索。在一个查询中获得全部 600 个,还是在 600 个查询中一次获得 1 个更快。除非您使用的是 MyISAM 和/或您的规范化/索引模式不佳(在这种情况下 ORM 不是问题),否则经过适当调整的数据库将在 2 毫秒内返回 600 行,同时返回单个行每个大约 1 毫秒。所以我们经常看到 N + 1 需要数百毫秒,而连接只需要几个
M
Manuel Berger

与产品具有一对多关系的供应商。一个供应商拥有(供应)许多产品。

***** 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 选择问题!


是否应该为供应商选择 1 次,然后为产品选择 N 次?
@bencampbell_ 是的,最初我也有同感。但是以他的例子,它是许多供应商的一种产品。
M
Mark Goodge

我不能直接评论其他答案,因为我没有足够的声誉。但值得注意的是,问题的出现本质上只是因为从历史上看,许多 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 的糟糕性能而一直在避免连接,那么请在最新版本上重试。你可能会感到惊喜。


将 MySQL 的早期版本称为关系 DBMS 有点牵强……如果遇到这些问题的人使用的是真正的数据库,他们就不会遇到这些问题。 ;-)
有趣的是,随着 INNODB 引擎的引入和后续优化,MySQL 解决了许多此类问题,但您仍然会遇到试图推广 MYISAM 的人,因为他们认为它更快。
仅供参考,RDBMS 中使用的 3 种常见 JOIN 算法之一称为嵌套循环。它基本上是引擎盖下的 N+1 选择。唯一的区别是数据库做出了明智的选择,根据统计数据和索引使用它,而不是客户端代码明确地强制它走这条路。
@布兰登是的!就像 JOIN 提示和 INDEX 提示一样,在所有情况下强制执行某个执行路径很少会击败数据库。数据库几乎总是非常非常擅长选择获取数据的最佳方法。也许在 dbs 的早期,您需要以一种特殊的方式来“表达”您的问题以哄骗 db,但是经过数十年的世界级工程,您现在可以通过向您的数据库询问一个关系问题并让它获得最佳性能理清如何为您获取和组装这些数据。
不仅数据库使用索引和统计信息,所有操作也是本地 I/O,其中大部分操作通常针对高效缓存而不是磁盘。数据库程序员将大量精力投入到优化这些事情上。
r
rorycl

由于这个问题,我们离开了 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


我很高兴我偶然发现了你的帖子,因为我以为我快疯了。当我发现 N+1 问题时,我的直接想法是——好吧,你为什么不创建一个包含你需要的所有信息的视图,然后从该视图中提取?你证实了我的立场。谢谢你,先生。
因为这个问题,我们离开了 Django 中的 ORM。 嗯? Django 有 select_related,它旨在解决这个问题 - 事实上,它的文档以类似于您的 p.car.colour 示例的示例开头。
这是一个老答案,我们现在在 Django 中有 select_related()prefetch_related()
凉爽的。但是 select_related() 和朋友似乎没有对诸如 LEFT OUTER JOIN 的连接进行任何明显有用的推断。问题不是接口问题,而是与对象和关系数据是可映射的奇怪想法有关的问题......在我看来。
U
Uwe Keim

Here's a good description of the problem

现在您了解了这个问题,通常可以通过在查询中执行连接提取来避免它。这基本上强制获取延迟加载的对象,以便在一个查询而不是 n+1 个查询中检索数据。希望这可以帮助。


d
davetron5000

假设您有 COMPANY 和 EMPLOYEE。 COMPANY 有许多 EMPLOYEES(即 EMPLOYEE 有一个字段 COMPANY_ID)。

在某些 O/R 配置中,当您有一个映射的 Company 对象并访问其 Employee 对象时,O/R 工具将为每个员工执行一次选择,而如果您只是在直接 SQL 中执行操作,您可以{1 }。因此 N(员工人数)加 1(公司)

这就是 EJB 实体 Bean 的初始版本的工作方式。我相信像 Hibernate 这样的东西已经消除了这一点,但我不太确定。大多数工具通常包含有关其映射策略的信息。


U
Uwe Keim

查看 Ayende 关于主题的帖子:Combating the Select N + 1 Problem In NHibernate

基本上,当使用像 NHibernate 或 EntityFramework 这样的 ORM 时,如果您有一对多(主从)关系,并且想要列出每个主记录的所有详细信息,您必须对数据库,“N”是主记录的数量:1 次查询获取所有主记录,N 次查询,每个主记录一个,获取每个主记录的所有详细信息。

更多数据库查询调用 → 更多延迟时间 → 降低应用程序/数据库性能。

但是,ORM 有一些选项可以避免这个问题,主要是使用 JOIN。


连接不是一个好的解决方案(通常),因为它们可能会产生笛卡尔积,这意味着结果行数是根表结果数乘以每个子表中的结果数。在多个等级制度层面上尤其糟糕。选择 20 个“博客”,每个博客有 100 个“帖子”,每个帖子有 10 个“评论”,将产生 20000 个结果行。 NHibernate 有一些变通方法,比如“batch-size”(在父 id 上使用 in 子句选择子项)或“subselect”。
R
Redoman

发出 1 个返回 100 个结果的查询比发出 100 个每个返回 1 个结果的查询要快得多。


I
Ian Boyd

在我看来,Hibernate Pitfall: Why Relationships Should Be Lazy中写的文章与真正的 N+1 问题完全相反。

如果您需要正确的解释,请参考Hibernate - Chapter 19: Improving Performance - Fetching Strategies

Select fetching(默认)极易受到 N+1 选择问题的影响,因此我们可能希望启用 join fetching


我读了休眠页面。它并没有说 N+1 选择问题实际上是什么。但它说你可以使用连接来修复它。
选择提取需要批量大小,以便在一个选择语句中选择多个父对象的子对象。子选择可能是另一种选择。如果您有多个层次结构级别并且创建了笛卡尔积,则连接可能会变得非常糟糕。
佚名

提供的链接有一个非常简单的 n + 1 问题示例。如果将它应用于 Hibernate,它基本上是在谈论同一件事。当您查询对象时,会加载实体,但任何关联(除非另有配置)都将被延迟加载。因此,对根对象的一个查询和另一个为每个对象加载关联的查询。返回的 100 个对象意味着一个初始查询,然后是 100 个附加查询以获取每个查询的关联,n + 1。

http://pramatr.com/2009/02/05/sql-n-1-selects-explained/


b
bedrin

N+1 选择问题很痛苦,在单元测试中检测这种情况是有意义的。我开发了一个小型库,用于验证给定测试方法或任意代码块执行的查询数量 - JDBC Sniffer

只需向您的测试类添加一个特殊的 JUnit 规则,并在您的测试方法上放置带有预期查询数量的注释:

@Rule
public final QueryCounter queryCounter = new QueryCounter();

@Expectation(atMost = 3)
@Test
public void testInvokingDatabase() {
    // your JDBC or JPA code
}

J
Jimmy

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);

所有这些方法的工作方式都相似,并且它们使用左连接提取发出类似的数据库查询


C
Community

正如其他人所说的更优雅的问题是,您要么拥有 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 表上执行 SELECTINNER JOIN 使用 WHERE batch_id= 将子表添加(反之亦然)。您只想确保按 id 列排序,因为它会使合并结果列更容易(否则您将需要一个 HashMap/Table 用于整个结果集,这可能还不错)。

然后你只需定期清理 ids 表。

如果用户选择 100 个左右不同的项目进行某种批量处理,这也特别有效。将 100 个不同的 id 放入临时表中。

现在,您正在执行的查询数量是 OneToMany 列的数量。


T
Toma Velev

在不深入技术栈实现细节的情况下,从架构上讲,N + 1 问题至少有两种解决方案:

只有 1 个 - 大查询 - 有连接。这使得大量信息从数据库传输到应用层,尤其是在有多个子记录的情况下。数据库的典型结果是一组行,而不是对象图(对于不同的数据库系统有解决方案)

有两个(或更多需要加入的孩子)查询 - 1 个用于父母,在你拥有它们之后 - 通过 ID 查询孩子并映射它们。这将最大限度地减少 DB 和 APP 层之间的数据传输。


m
martins.tuga

以 Matt Solnit 为例,假设您将 Car 和 Wheels 之间的关联定义为 LAZY,并且您需要一些 Wheels 字段。这意味着在第一次选择之后,hibernate 将为每辆车执行“Select * from Wheels where car_id = :id”。

这使得每 N 辆车第一次选择和更多 1 次选择,这就是为什么它被称为 n+1 问题。

为避免这种情况,请使关联获取为急切,以便休眠加载带有连接的数据。

但请注意,如果您多次不访问关联的 Wheels,最好保持 LAZY 或使用 Criteria 更改获取类型。


同样,连接不是一个好的解决方案,尤其是在可能加载超过 2 个层次结构级别时。改为检查“subselect”或“batch-size”;最后一个将通过“in”子句中的父 ID 加载子代,例如“select ... from wheel where car_id in (1,3,4,6,7,8,11,13)”。
L
Lukas Eder

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 查询并缓存有关连续执行父查询和子查询的数据。如果您的查询不完全相同,但在语义上等价,这甚至也有效。


A
Adam Gaj

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);
    }
}

关注公众号,不定期副业成功案例分享
关注公众号

不定期副业成功案例分享

领先一步获取最新的外包任务吗?

立即订阅