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

在对象关系映射(ORM)讨论中,通常将 “N + 1 选择问题” 表示为问题,并且我了解到它与必须对对象中看起来很简单的内容进行大量数据库查询有关。世界。

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

答案

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

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

结果:

  • 1 个产品选择语句
  • N 个针对供应商的选择语句

这是 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 的糟糕性能而避免使用联接,请重试最新版本。您可能会感到惊喜。

由于这个问题,我们放弃了 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:物化

将结果吸到具有参数的通用对象创建器中,该参数在第三项之后拆分。这意味着 “琼斯” 对象将不会被制作多次。

步骤 3:渲染

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

请参阅此网页获取用于 python 的折叠功能的实现。

当您忘记获取关联然后需要访问它时,就会发生 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 查询问题)。

首先,您需要适当的 SQL 日志记录和监视,以便可以发现此问题。

其次,这种问题最好由集成测试来解决。您可以使用自动 JUnit 断言来验证生成的 SQL 语句的预期计数db-unit 项目已经提供了此功能,并且它是开源的。

确定 N + 1 查询问题后, 您需要使用 JOIN FETCH,以便在一个查询中而不是 N 提取子关联 。如果需要获取多个子关联,则最好在初始查询中获取一个集合,而在第二个 SQL 查询中获取第二个集合。

假设您有 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 选择问题的影响,因此我们可能要启用联接获取