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

时间:2008-09-18 21:30:01

标签: orm select-n-plus-1

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

有没有人对问题有更详细的解释?

18 个答案:

答案 0 :(得分:884)

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

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

SELECT * FROM Cars;

然后为每个Car

SELECT * FROM Wheel WHERE CarId = ?

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

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

SELECT * FROM Wheel

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

参考: Java Persistence with Hibernate ,第13章。

答案 1 :(得分:103)

SELECT 
table1.*
, table2.*
INNER JOIN table2 ON table2.SomeFkId = table1.SomeId

通过返回table2中每个子行的table1结果,得到一个结果集,其中table2中的子行导致重复。 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的优点是减少了查询复杂度,并且您可以使用延迟加载,其中子结果集仅在第一次请求时加载。

答案 2 :(得分:61)

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

***** 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=?

结果:

  • 1选择产品声明
  • 供应商的N选择声明

这是N + 1选择问题!

答案 3 :(得分:36)

我无法直接评论其他答案,因为我没有足够的声誉。但值得注意的是,问题基本上只会产生,因为从历史上看,许多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的糟糕表现而过去一直在避免加入,那么再试一次最新版本。你可能会感到惊喜。

答案 4 :(得分:26)

由于这个问题,我们离开了Django的ORM。基本上,如果你尝试做

for p in person:
    print p.car.colour

ORM会愉快地返回所有人(通常作为Person对象的实例),但随后它将需要查询每个人的汽车表。

一种简单而有效的方法是我称之为“ fanfolding ”,这避免了一种荒谬的想法,即来自关系数据库的查询结果应该映射回查询所依据的原始表。组成

第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步:客体化

将结果吸收到通用对象创建器中,并在第三个项目后分割参数。这意味着“jones”对象不会多次出现。

第3步:渲染

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

有关python的粉丝的实现,请参阅this web page

答案 5 :(得分:17)

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

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

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

答案 6 :(得分:15)

以下是对问题的详细描述 - https://web.archive.org/web/20160310145416/http://www.realsolve.co.uk/site/tech/hib-tip-pitfall.php?name=why-lazy

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

答案 7 :(得分:13)

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

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

  

选择提取(默认)是   极易受到N + 1选择的影响   问题,所以我们可能想要启用   加入提取

答案 8 :(得分:13)

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

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

更多数据库查询调用 - >延迟时间更长 - >应用程序/数据库性能下降。

但是,ORM可以选择避免这个问题,主要是使用“连接”。

答案 9 :(得分:13)

当您忘记获取关联然后需要访问它时,会发生N + 1查询问题:

List<PostComment> comments = entityManager.createQuery(
    "select pc " +
    "from PostComment pc " +
    "where pc.review = :review", PostComment.class)
.setParameter("review", review)
.getResultList();

LOGGER.info("Loaded {} comments", comments.size());

for(PostComment comment : comments) {
    LOGGER.info("The post title is '{}'", comment.getPost().getTitle());
}

生成以下SQL语句:

SELECT pc.id AS id1_1_, pc.post_id AS post_id3_1_, pc.review AS review2_1_
FROM   post_comment pc
WHERE  pc.review = 'Excellent!'

INFO - Loaded 3 comments

SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM   post pc
WHERE  pc.id = 1

INFO - The post title is 'Post nr. 1'

SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM   post pc
WHERE  pc.id = 2

INFO - The post title is 'Post nr. 2'

SELECT pc.id AS id1_0_0_, pc.title AS title2_0_0_
FROM   post pc
WHERE  pc.id = 3

INFO - The post title is 'Post nr. 3'

首先,Hibernate执行JPQL查询,并获取PostComment个实体列表。

然后,对于每个PostComment,关联的post属性用于生成包含Post标题的日志消息。

由于post关联未初始化,Hibernate必须使用辅助查询获取Post实体,并且 对于N PostComment个实体,将执行N个更多查询(因此N + 1查询问题)。

首先,您需要proper SQL logging and monitoring,以便发现此问题。

其次,这种问题最好被集成测试捕获。您可以使用automatic JUnit assert to validate the expected count of generated SQL statementsdb-unit project已经提供了此功能,而且它是开源的。

确定N + 1查询问题后,you need to use a JOIN FETCH so that child associations are fetched in one query, instead of N。如果需要获取多个子关联,最好在初始查询中获取一个集合,而第二个集合使用辅助SQL查询。

答案 10 :(得分:10)

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

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

答案 11 :(得分:9)

一位百万富翁有N辆车。你想获得所有(4)轮子。

一(1)个查询加载所有汽车,但是对于每个(N)汽车,都会提交一个单独的查询来装载车轮。

费用:

假设索引符合ram。

1 + N查询解析和规划+索引搜索和1 + N +(N * 4)板访问以加载有效载荷。

假设索引不适合ram。

最坏情况下的额外成本1 + N板加载索引。

摘要

瓶颈是板块通道(在硬盘上每秒约70次随机存取) 对于有效载荷,急切的连接选择也将访问板1 + N +(N * 4)次。 因此,如果索引符合ram - 没问题,它足够快,因为只涉及ram操作。

答案 12 :(得分:9)

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

答案 13 :(得分:8)

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

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

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

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

答案 14 :(得分:5)

正如其他人所说的更优雅的问题是,您要么拥有OneToMany列的笛卡尔积,要么您正在进行N + 1选择。无论是可能的巨大结果集,还是分别与数据库聊天。

我很惊讶这没有被提及,但这是我如何解决这个问题... 我制作了一个半临时的ID表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列,您只需在子表SELECT上使用INNER JOIN执行WHERE batch_id=(反之亦然)。您只是想确保按id列进行排序,因为它会使合并结果列更容易(否则您将需要一个HashMap / Table来表示整个结果集,这可能不是那么糟糕)。

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

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

现在,您正在执行的查询数量取决于OneToMany列的数量。

答案 15 :(得分:2)

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

  • 只有 1 个 - 大查询 - 带有连接。这使得大量信息从数据库传输到应用层,尤其是在有多个子记录的情况下。数据库的典型结果是一组行,而不是对象图(对于不同的 DB 系统有解决方案)
  • 有两个(或更多,因为需要加入更多的孩子)查询 - 1 个用于父级,在你拥有它们之后 - 通过 ID 查询子级并映射它们。这将最大限度地减少 DB 层和 APP 层之间的数据传输。

答案 16 :(得分:1)

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

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

为避免这种情况,请将关联提取设置为急切,以便hibernate通过连接加载数据。

但是注意,如果你多次不访问相关车轮,最好保持LAZY或用Criteria更改提取类型。

答案 17 :(得分:0)

N + 1 SELECT问题确实很难发现,尤其是在具有较大领域的项目中,直到它开始降低性能的那一刻。即使该问题已得到解决,即通过增加急切的加载,进一步的开发也可能会破坏解决方案和/或在其他地方再次引入N + 1 SELECT问题。

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

  1. 生成将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
  1. 提供API,该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);
    }
}