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

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

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

625551 次浏览

假设你有公司和雇员,公司有许多雇员(即雇员有一个字段COMPANY_ID)。

在一些O/R配置中,当你有一个映射的Company对象并访问其员工对象时,O/R工具将为每个员工做一个选择,如果你只是直接SQL做事情,你可以select * from employees where company_id = XX。因此N(员工人数)加1(公司)

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

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

现在,假设您需要遍历所有汽车,并为每个汽车打印出车轮列表。简单的O/R实现将执行以下操作:

SELECT * FROM Cars;

然后对于每个#0:

SELECT * FROM Wheel WHERE CarId = ?

换句话说,你有一个选择的汽车,然后N个额外的选择,其中N是汽车的总数。

或者,可以获取所有轮子并在内存中执行查找:

SELECT * FROM Wheel;

这将数据库的往返次数从N+1减少到2。大多数ORM工具都提供了几种防止N+1选择的方法。

参考:JavaHibernate的持久性,第13章。

SELECTtable1.*, 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 HouseId1  22 Valley St Dave 11  22 Valley St John 11  22 Valley St Mike 1

O/RM应该用ID=1、地址="22 Valley St"填充Home的实例,然后用Dave、John和Mike的People实例填充居民数组,只需一个查询。

对上面使用的相同地址进行N+1查询将导致:

Id Address1  22 Valley St

使用单独的查询,例如

SELECT * FROM Person WHERE HouseId = 1

并产生一个单独的数据集,如

Name    HouseIdDave    1John    1Mike    1

最终结果与上述单个查询相同。

单选的优点是你可以预先获得所有数据,这可能是你最终想要的。N+1的优点是查询复杂性降低,你可以使用延迟加载,其中子结果集仅在第一次请求时加载。

这里有一个很好的问题描述

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

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

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

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

***** 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 defaultQuery query = session.createQuery( "from Product p");List list = query.list();// Supplier is being accesseddisplayProductsListWithSupplierName(results);
select ... various field names ... from PRODUCTselect ... 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选择问题!

在我看来,用冬眠陷阱:为什么关系应该懒惰写的文章与真正的N+1问题完全相反。

如果您需要正确的解释,请参阅Hibernate-第19章:提高性能-获取策略

选择获取(默认)是极易受到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     | red2    | jones  | 2145    | 1012   | toyota   | blue16   | ashby  | 124     | 99     | bmw      | yellow

第2步:目标化

将结果吸收到一个通用对象创建者中,并在第三项之后使用一个参数进行拆分。这意味着“jones”对象不会被制作多次。

步骤3:渲染

for p in people:print p.car.colour # no more car queries

有关python的折叠的实现,请参阅此网页

查看Ayende关于主题的帖子:解决NHibernate中的选择N+1问题

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

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

但是,ORM可以选择避免此问题,主要使用JOIN。

正如其他人更优雅地指出的那样,问题是您要么拥有OneTo许多列的笛卡尔乘积,要么正在进行N+1选择。分别是可能的巨大结果集或与数据库聊天。

我很惊讶没有提到这一点,但这是我如何解决这个问题的……我做了一个半临时的ID表

这并不适用于所有情况(甚至可能不是大多数),但如果你有很多子对象,这样笛卡尔乘积就会失控(即大量OneToMany列,结果的数量将是列的乘法),并且它更像是一个批处理作业,它就会特别有效。

首先将父对象id作为批处理插入IDS表。这个batch_id是我们在应用程序中生成并保留的东西。

INSERT INTO temp_ids(product_id, batch_id)(SELECT p.product_id, ?FROM product p ORDER BY p.product_idLIMIT ? OFFSET ?);

现在,对于每个OneToMany列,您只需在id表INNER JOIN上执行SELECT,在子表中执行WHERE batch_id=(反之亦然)。您只需确保按id列排序,因为这将使合并结果列更容易(否则您将需要一个HashMap/Table来处理整个结果集,这可能没那么糟糕)。

然后,您只需定期清理id表。

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

现在,您正在执行的查询数量是OneTo多个列数。

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

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

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

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

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

对问题的简短解释可以在Phabricator留档中找到:

N+1查询问题是一个常见的性能反模式。它看起来像这样:

$cats = load_cats();foreach ($cats as $cat) {$cats_hats => load_hats_for_cat($cat);// ...}

假设load_cats()有一个实现,可以归结为:

SELECT * FROM cat WHERE ...

…并且load_hats_for_cat($cat)有一个这样的实现:

SELECT * FROM hat WHERE catID = ...

…您将在代码执行时发出“N+1”查询,其中N是猫的数量:

SELECT * FROM cat WHERE ...SELECT * FROM hat WHERE catID = 1SELECT * FROM hat WHERE catID = 2SELECT * FROM hat WHERE catID = 3SELECT * FROM hat WHERE catID = 4...

解决方案:

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

在迭代之前加载所有数据。

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

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

@Rulepublic final QueryCounter queryCounter = new QueryCounter();
@Expectation(atMost = 3)@Testpublic void testInvokingDatabase() {// your JDBC or JPA code}

什么是N+1查询问题

当数据访问框架执行N个额外的SQL语句以获取执行主SQL查询时可能检索到的相同数据时,就会发生N+1查询问题。

N的值越大,执行的查询越多,对性能的影响就越大。而且,与可以帮助你找到运行缓慢的查询的慢查询日志不同,N+1问题不会是现货,因为每个单独的额外查询运行得足够快,不会触发慢查询日志。

问题是执行大量额外的查询,总的来说,这些查询需要足够的时间来减慢响应时间。

让我们考虑下面的post和post_comments数据库表,它们形成一对多的表关系:

帖子和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)

N+1个纯SQL查询问题

如果您使用此SQL查询选择post_comments

List<Tuple> comments = entityManager.createNativeQuery("""SELECTpc.id AS id,pc.review AS review,pc.post_id AS postIdFROM post_comment pc""", Tuple.class).getResultList();

然后,您决定为每个post_comment获取关联的posttitle

for (Tuple comment : comments) {String review = (String) comment.get("review");Long postId = ((Number) comment.get("postId")).longValue(); 
String postTitle = (String) entityManager.createNativeQuery("""SELECTp.titleFROM post pWHERE p.id = :postId""").setParameter("postId", postId).getSingleResult(); 
LOGGER.info("The Post '{}' got this review '{}'",postTitle,review);}

您将触发N+1查询问题,因为您执行的不是一个SQL查询,而是5(1+4):

SELECTpc.id AS id,pc.review AS review,pc.post_id AS postIdFROM 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("""SELECTpc.id AS id,pc.review AS review,p.title AS postTitleFROM post_comment pcJOIN 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表映射到以下实体:

发布和发布评论的实体

JPA映射如下所示:

@Entity(name = "Post")@Table(name = "post")public class Post { 
@Idprivate Long id; 
private String title; 
//Getters and setters omitted for brevity} 
@Entity(name = "PostComment")@Table(name = "post_comment")public class PostComment { 
@Idprivate Long id; 
@ManyToOneprivate Post post; 
private String review; 
//Getters and setters omitted for brevity}

FetchType.EAGER

对您的JPA关联隐式或显式使用FetchType.EAGER是一个坏主意,因为您将获取更多所需的数据。此外,FetchType.EAGER策略也容易出现N+1个查询问题。

不幸的是,@ManyToOne@OneToOne关联默认使用FetchType.EAGER,所以如果你的映射看起来像这样:

@ManyToOneprivate Post post;

您正在使用FetchType.EAGER策略,并且每次在使用JPQL或Criteria API查询加载一些PostComment实体时忘记使用JOIN FETCH

List<PostComment> comments = entityManager.createQuery("""select pcfrom PostComment pc""", PostComment.class).getResultList();

您将触发N+1查询问题:

SELECTpc.id AS id1_1_,pc.post_id AS post_id3_1_,pc.review AS review2_1_FROMpost_comment pc
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3SELECT 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 pcfrom PostComment pcjoin 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语句:

SELECTpc.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_FROMpost_comment pcINNER JOINpost 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 pcfrom PostComment pc""", PostComment.class).getResultList();

Hibernate将执行一个SQL语句:

SELECTpc.id AS id1_1_,pc.post_id AS post_id3_1_,pc.review AS review2_1_FROMpost_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 pcfrom PostComment pcjoin 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个查询问题,您可以使用#0开源项目。

首先,您需要添加以下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 pcfrom PostComment pc""", PostComment.class).getResultList();
SQLStatementCountValidator.assertSelectCount(1);

如果您使用FetchType.EAGER并运行上述测试用例,您将获得以下测试用例失败:

SELECTpc.id as id1_1_,pc.post_id as post_id3_1_,pc.review as review2_1_FROMpost_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!

N+1 SELECT问题真的很难被发现,尤其是在大型领域的项目中,直到它开始降低性能的那一刻。即使问题已经修复,即通过添加紧急加载,进一步的开发可能会破坏解决方案和/或在其他地方再次引入N+1 SELECT问题。

我创建了开源库jplusone来解决基于JPA的Spring BootJava应用程序中的这些问题。该库提供了两个主要功能:

  1. 生成将SQL语句与触发它们的JPA操作的执行相关联的报告,并将其放置在参与其中的应用程序的源代码中
2020-10-22 18:41:43.236 DEBUG 14913 --- [           main] c.a.j.core.report.ReportGenerator        :ROOTcom.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 BOUNDARYOPERATION [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 [...] fromauthor author0_left outer join genre genre1_ on author0_.genre_id=genre1_.idwhereauthor0_.id=1OPERATION [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 [...] frombook books0_wherebooks0_.author_id=1
  1. 提供允许编写测试的API,检查您的应用程序使用JPA的效率(即断言延迟加载操作的数量)
@SpringBootTestclass LazyLoadingTest {
@Autowiredprivate JPlusOneAssertionContext assertionContext;
@Autowiredprivate SampleService sampleService;
@Testpublic 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 controllersampleService.executeBusinessOperation();
rule.check(assertionContext);}}

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

  • 使用连接只有1个大查询。这使得大量信息从数据库传输到应用程序层,特别是如果有多个子记录。数据库的典型结果是一组行,而不是对象图(不同的数据库系统有解决方案)
  • 有两个(或更多,需要加入更多的孩子)查询——父查询1个,拥有它们之后——通过ID查询孩子并映射它们。这将最大限度地减少数据库和APP层之间的数据搬迁。

Hibernate和Spring Data JPA中的N+1问题

N+1问题是对象关系映射中的一个性能问题,它在应用层为单个选择查询在数据库中触发多个选择查询(确切地说是N+1,其中N=表中的记录数)。Hibernate和Spring Data JPA提供了多种方法来捕获和解决这个性能问题。

什么是N+1问题?

为了理解N+1问题,让我们考虑一个场景。假设我们有一个用户对象的集合映射到数据库中的DB_USER表,每个用户都有一个集合或角色映射到DB_ROLE表,使用一个连接表DB_USER_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}

一个用户可以有多个角色。角色延迟加载。现在假设我们想要从此表中获取所有用户并打印每个用户的角色。非常简单的对象关系实现可能是-使用查找所有作者方法的用户存储库

public interface UserRepository extends CrudRepository<User, Long> {
List<User> findAllBy();}

ORM执行的等效SQL查询将是:

首先获取所有用户(1)

Select * from DB_USER;

然后get每个用户的角色执行N次(其中N是用户数)

Select * from DB_USER_ROLE where userid = <userid>;

所以我们需要一个选择用户为每个用户获取角色的N个额外选择,其中N是用户总数这是ORM中的一个典型的N+1问题。

如何识别它?

Hibernate提供跟踪选项,支持在控制台/logs中SQL日志记录。使用日志,您可以轻松查看hibernate是否为给定调用发出N+1个查询

如果您看到给定选择查询的SQL有多个条目,那么很可能是由于N+1问题。

N+1分辨率

在SQL层面,ORM为了避免N+1需要实现的是触发连接两个表的查询并在单个查询中获得组合结果

Fetch JoinSQL检索单一查询中的所有内容(用户和角色)

或纯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,那么我们有两种选择来实现这一点-使用实体图或使用选择带有获取连接的查询。

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个查询,我们使用属性路径解决N+1问题,Spring Data JPA避免了N+1问题

2. Hibernate方法:

如果它是纯Hibernate,那么以下解决方案将起作用。

使用HQL

from User u *join fetch* u.roles roles roles

使用标准 API:

Criteria criteria = session.createCriteria(User.class);criteria.setFetchMode("roles", FetchMode.EAGER);

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

N+1的推广

N+1问题是一个特定于ORM的问题名称,您将可以在服务器上合理执行的循环移动到客户端。通用问题并不特定于ORM,您可以使用任何远程API解决它。在本文中,我展示了JDBC往返是如何非常昂贵的,如果您调用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 oncefor parent in (select * from parent) loop
-- This loop is executed N timesfor child in (select * from child where parent_id = parent.id) loop...end loop;end loop;

最好使用连接来实现这一点(在这种情况下):

for rec in (select *from parent pjoin child c on c.parent_id = p.id)loop...end loop;

现在,循环只执行一次,循环的逻辑已经从客户端(PL/SQL)转移到服务器(SQL),服务器甚至可以以不同的方式优化它,例如通过运行哈希连接(O(N))而不是嵌套循环连接(O(N log N)带索引)

自动检测N+1个问题

如果您使用的是JDBC,那么您可以在幕后使用jOOQ作为JDBC代理来自动检测您的N+1问题. jOOQ的解析器会使您的SQL查询正常化,并缓存有关父查询和子查询连续执行的数据。